Plugin runtime
Your Dataverse plugin's statics outlive you
Microsoft’s rule for Dataverse plugins is “keep them stateless, because the platform caches and reuses the instance.” We drove about 3,000 plugin calls through a live sandboxed org to see what actually gets reused today. The answer turned the warning around: the instance is never reused, but statics are shared far more widely, and live far longer, than most developers assume.
The tests ran from 2026-10-01 to 2026-10-04 on a Dataverse Online developer environment (Canada, org versions 9.2.26092 and 9.2.26093), with sandbox isolation.
In short
- Instance fields are safe. Every call got a brand-new plugin instance, across sync and async steps, pre and post stages, twin steps, both constructor shapes and the package model.
- Statics are shared by everything. One static copy per container serves every step, every class in the assembly, every record and every user.
- Statics can live for days, or for minutes. One pair of containers lived 36 hours and survived 3 hours idle. The next day, every idle gap of 40 minutes or more replaced them.
- Config edits don’t clear statics. Neither do disabling a step or a platform update. An assembly update does, but reverting the assembly brings the old statics back.
- A context parked in a static bleeds between concurrent calls. Two parallel callers were enough: 28 of 100 calls read another record’s Target.
How we measured it
A probe plugin writes one row per call recording every kind of state it holds. A small .NET harness drives the calls and reads the rows back.
- What each row records:
- an instance GUID minted in the constructor, with its call count;
- a per-class static GUID and an assembly-wide static GUID, each with its call count;
- how many calls were running on that instance at the time;
- the machine, process, process start time and AppDomain the sandbox reports.
- Six steps on one custom table:
A-create-pre, plus a twin step with the same class and config;B-create-pre, another class in the same assembly;- pre, post and async steps on Update.
- Run shapes:
- bursts of sequential calls;
- idle gaps from 1 minute to 8 hours;
- a steady trickle of one call every 3 minutes;
- registration changes applied one at a time;
- up to 20 parallel callers.
- The probe never changes between runs. An assembly update is itself one of the events under test.
1. The plugin instance is never reused
In about 3,000 calls, the instance call counter never went past 1. Each call
constructed a fresh IPlugin object, ran it once and dropped it.
- It held for every step shape: sync and async, pre and post, Create and Update, and two steps sharing a class and config.
- It held for every constructor shape. In a 468-call run, a parameterless plugin on steps with no config, and a plugin taking only the unsecure config, both got a new instance every time.
- It held for the plugin package (NuGet) model. The same probe source shipped as a package gave the same result over 468 calls. It also ran in the same sandbox processes as the classic assembly.
- Config edits show up on the very next call, within 30 seconds, because each call constructs a new instance.
The old advice that “instance fields leak between calls” describes an earlier platform. On today’s Dataverse Online, an instance field can’t carry state from one call to the next.
2. Statics are shared by everything in the container
Nearly every call ran on statics an earlier call had already used: 464 of 468 in a typical run. The only cold calls were the first one on each fresh copy.
- One static copy per sandbox worker container. That copy serves every step, both plugin classes, sync and async, pre and post.
- Across users too. Calls alternated between our own user and an application user. Both landed on the same copies, and one static counter ran straight through both: 1315 (ours), 1316 (the application user), 1317 (ours).
- Usually two copies at a time. Two worker containers (one
SandboxWorker.exeprocess each) split the calls about 50/50, so a static counter interleaves instead of counting up evenly. Older blog posts saw the same pattern, with up to four copies in 2017. - The sandbox hides nothing about where you are. Machine name, process id and start time, and AppDomain id are all readable from plugin code. That made it possible to track each container’s lifetime from outside.
So a static is not “per step” or “per user”. Anything you put in one is visible to every plugin call that lands in the same container, whoever made it and whichever record it is about.
3. Almost nothing you do clears them
We made each registration change, then watched the statics for about 12 minutes. Only an assembly update produced new statics, and undoing it brought the old ones back.
| Event | Statics cleared? | What we saw |
|---|---|---|
| Unsecure config edited or restored | no | same static GUIDs before and after |
| Secure config added or removed | no | same static GUIDs |
| Step disabled, then re-enabled | no | the other steps kept their statics throughout |
| Org platform update (9.2.26092 → 26093) | no | the same containers served before and after |
| Assembly updated (new content, same name) | yes, within 0–8 min | 3 of 144 calls ran old code on old statics for up to 7.7 min |
| Assembly reverted to the earlier build | old statics came back | original GUIDs, and the call counter resumed from 771 to 773, 24 min later |
| Container recycled | yes | see the next section |
The revert row is the surprise. Each container keeps every assembly version it has loaded and goes back to it. So “deploy a fix, then roll back” does not give you clean statics: you get the ones from before the fix, still holding whatever they held.
4. Statics can live for days, or for minutes
A static lives as long as its sandbox container. Containers have neither a fixed idle timeout nor a fixed age cap, and the same idle gap gave opposite answers on different days.
- On October 3 a pair of containers lived 36 hours. It was still serving after 3 hours idle, and was replaced only during a 4-hour gap.
- On October 4 every gap ended both containers, from 40 minutes to 8 hours. The pair that answered after each gap was always 11 to 21 minutes old. That fits idle containers being recycled about every 30 minutes, with us only ever meeting the latest pair. It’s an inference, because our own calls count as activity and can’t show the swaps in between.
- We can’t say what changed. The org had moved to a newer platform build (9.2.26093.139) between the two days. Or other plugins in this shared org may have kept the containers busy on October 3; we can’t see that traffic.
- Replacements start on their own. In every case, the new pair started 10 to 24 minutes before our next call, not in response to it.
- Steady load doesn’t protect a container. With a call every 3 minutes, one container was replaced at 75 minutes while its sibling kept serving for 325.
So don’t count on a static surviving, and don’t count on it going away either: plan for anything from minutes to a day.
5. A context parked in a static bleeds between calls
This is the classic bug: a plugin saves the execution context in a static “for convenience”, and a concurrent call overwrites it. We wrote that bug on purpose, keeping the same context in a static field and in an instance field. Each call parked its context, slept 0 to 1 second, then read the Target back.
- The instance field never bled, because every call has its own instance.
- Two containers don’t keep callers apart. Both parallel calls often landed on the same container, with up to 14 calls in flight on one static copy.
- The bug from 2016 blog posts is still real. On today’s platform it happens only through statics.
What to do in your plugins
- Never put per-call data in a static. That includes the context, the Target,
the
IOrganizationServiceand the user. Pass them as method arguments instead. - Treat a static as shared by every user and every record, and as able to survive a day. A static cache of user-specific or record-specific data is a data leak waiting to happen.
- Read-only lookups can live in statics if you’re careful. Make them thread-safe, give them an expiry, and don’t rely on a deployment to reset them.
- Instance fields set from step config are fine. Every call constructs a new instance, so config edits apply immediately.
- Don’t count on a rollback to reset state. Reverting an assembly brings back its old statics.
Limits and what’s next
- One environment. Everything ran on a single developer environment in Canada. Sandbox and production environments are untested.
- Few lifetime samples. Each idle gap has one to four samples, the same gap behaved differently on different days, and only two containers were watched under steady load.
- We can’t see other traffic. Other plugins in the org may have kept containers warm.
- The platform moves. The probe and harness are built to be rerun after each release wave (roughly April and October), so a change shows up as a new row next to the old ones.