12
 min read
November 12, 2026
|
Updated: 

How Moving to .NET NativeAOT Broke (and Saved) Our Plugin System

Engineering

Here's how we rebuilt our endpoint agent to be faster, lighter, and completely independent.

Engineering Blog cover: a modular browser window on a grassy lakeside hill connected to three puzzle-piece plugin icons

Each product we added to Island Desktop, our endpoint agent, made it use more memory and take longer to start. At the pace we were building, our metrics gave us about a year before that became a problem for our customers. We didn't want to wait that long.

In practice, the agent runs on every employee laptop in the organizations we serve. It starts when the machine boots and runs in the background all day, and anyone who opens a task manager can see its memory and startup time.

Written in C#, the agent has two parts: a core and a set of plugins. The core does the shared work: device posture, telemetry, policy enforcement. Each plugin is a product built on that core, with its own team behind it: zero-trust access, device visibility, digital employee experience, and more.

What Made Us Move

As Island grew, both parts grew with it. The core took on more shared work, the list of products kept getting longer, and the agent's size and startup time climbed. Customers running the agent across their fleets noticed it, too.

Growth brought a second problem. Every plugin was part of the agent release, so a team that wanted to get a new feature or a bug fix to a customer had to wait for the next one, whenever that was. The teams asked for control over their own release cycle.

We tried the obvious things first. We trimmed the framework, reworked the parts of the code that held the most memory, and tuned what the runtime let us tune. It helped at the margins. It didn't close the gap to the endpoint agents written in C++, Rust, and other languages that are compiled to machine code before they reach the laptop, which is the comparison our customers make. The cost we couldn't tune away was the .NET runtime itself. A .NET program is compiled on the laptop each time it starts, and the compiler stays in memory for as long as the program runs.

Why NativeAOT

After weighing the options, we decided to move the agent to .NET NativeAOT. NativeAOT compiles a .NET program to machine code when we publish it, the way a C++ compiler would, so the laptop runs native code and never compiles anything. It also takes away a long list of things .NET programs normally rely on, and the first item on that list is loading code at runtime, and our plugin system was built on exactly that.

We kept the plugin system and changed what a plugin is. The constraint that looked like a blocker produced a smaller boundary, plugins with their own release schedule, and a process that starts faster and stays smaller. For the organizations that run Island Desktop on their fleets, that means an agent that's lighter on every laptop and products that update without waiting for the next agent release.

How .NET Runs Code, in Brief

To see why tuning couldn't close the gap, it helps to know how a .NET program runs. A .NET process doesn't hold machine code for your program when it starts. It holds intermediate language (IL), and the runtime's just-in-time (JIT) compiler turns each method into machine code the first time it's called. Tiered compilation softens the cost: a method gets a quick, lightly optimized version first and a fully optimized one later, in the background, once it proves hot. ReadyToRun precompiles much of that work, but the IL stays in the file, and the JIT still runs for generic instantiations, interop, and any method the runtime decides to re-optimize.

That's fine for a server that runs for weeks, but not for an agent that starts on every laptop each morning. The JIT needs memory of its own, the IL has to stay in memory so the JIT can read it, and every startup spends time compiling before the agent does any work.

What NativeAOT Changes

NativeAOT moves compilation to publish time. The publish step runs the program and its dependencies through an ahead-of-time (AOT) compiler, trims everything unreachable, and links the result with the runtime into a native image for one operating system and one architecture. What reaches the laptop is machine code. The operating system's loader maps it, the runtime sets up the garbage collector and its threads, and our code runs with no compilation in between. Figure 1 shows the two pipelines side by side.

Diagram of where compilation happens: with a managed JIT, C# source becomes IL and metadata delivered to the employee laptop, where the runtime starts and the JIT compiles each method on its first call into machine code in memory; with NativeAOT, the AOT compiler trims, compiles and links at publish on the build machine, once per OS and architecture, and on the laptop the OS loader maps the native image and the code runs with no JIT in the process
Figure 1: Where compilation happens, method by method on the laptop under the JIT, or once at publish time per operating system and architecture under NativeAOT

Microsoft's publishing overview highlights four benefits, and we needed all four: faster startup, a smaller memory footprint, no dependency on an installed runtime, and a smaller deployment. The last one comes from trimming. The trimmer keeps only the code the program can reach and removes the rest of every library, which makes the installer and every later update smaller. A fifth is for an agent that runs on every employee's device with elevated privileges. A NativeAOT process generates no executable code at runtime, so every byte of code the CPU runs can be signed ahead of time, and reflection is limited to the code the compiler included. When something goes wrong on a laptop, there are fewer places to look.

AOT has costs as well. These are the ones that mattered to us:

  • The garbage collector stays. AOT removes the JIT and leaves memory management in place. Allocation patterns that hurt under the JIT still hurt.
  • Peak throughput isn't automatically better. The JIT can re-optimize hot code with runtime profile data; an AOT image is optimized once, statically. Startup and residency matter more than peak throughput for our workload, so this was the right side of the trade.
  • The compiler has to see everything. Trimming keeps only the code it can prove reachable, which is what shrinks the payload, and it removes anything it can't see. Reflection that finds types by name, runtime code generation, and compiled expression trees stop working or get slower.
  • Every trim or AOT warning is a potential runtime failure. Microsoft's guidance is that an AOT application should have no warnings at all, because each one marks a place where behavior can change or crash after compilation.
  • Builds get slower and platform-bound. There's no cross-OS compilation, so each operating system builds on its own kind of machine.
  • Debugging changes. You debug a native image with a separate symbol file, and managed heap analysis isn't available.

We accepted all of these costs. One of them reached much further than the others, though: with no dynamic loading, the plugin system couldn't survive in its current form.

From Loading Assemblies to Calling One Function

The plugin architecture was the first thing the native build broke. The assembly loader every plugin depended on no longer existed, so the core couldn't load a single plugin. The second thing to break was our integration with Island's cloud services, which leaned on reflection to serialize its messages; we come back to that in the section on the work behind it. The plugins came first because there was no workaround: the design had to change.

Before the move, a plugin was a .NET class library. A managed host loaded it through an assembly load context, the mechanism .NET provides for giving a plugin its own dependency graph, and the pattern Microsoft's plugin tutorial teaches. The host was either the core process itself or a second copy of it started for that plugin. In the second case the plugin started a small RPC server, and the core connected to it to send commands and receive events.

That design worked, but it had two problems, and both got worse with every plugin we added.

The first was version coupling. A plugin ran on the host's runtime, with the host's framework and dependency versions. The documentation spells out the limits: a process can't load two versions of the runtime, loading different versions of framework assemblies leads to hard-to-diagnose behavior, and a plugin can't bring in a framework the host didn't reference. A plugin that needed a newer library meant releasing a new host. So every plugin had to be released together with the agent.

The second was the boundary itself. The contract between core and plugin lived in the type system: shared interfaces, shared message types, shared serialization. A change had to compile on both sides at once. The boundary also offered little isolation. An assembly load context provides no isolation of its own, and all loaded code has the full permissions of the process. A plugin inside the core process shared the core's fate, and a plugin in its own managed host still ran on the core's runtime and library versions, because that host was the core's own binary in a second process. Microsoft's own tutorial points you at a process or virtualization boundary when you need one.

NativeAOT removed the assembly loader and, with it, the option of keeping that design. The replacement came from a different feature of the same compiler. A class library published with NativeAOT becomes a native shared library, and the compiler exports any static method marked for unmanaged callers, with an entry point name, as a plain C function. That's how any C library exposes its API, and it became our plugin contract.

A plugin is now a native shared library with one exported entry point. A small native host process, one per plugin, asks the operating system's loader to load the library, resolves that single export, and calls it with a handle to a bidirectional stream. The core starts the host and the core dials the stream. The plugin opens no port and runs no server of its own; it answers only on the stream the core handed it. Every command and every event travels over that one stream as versioned messages. Every plugin now runs outside the core, including the ones that used to live inside it. Figure 2 compares the two boundaries.

Diagram of the plugin boundary before and after: before, Plugin A is loaded in-process by the core through an assembly load context, and the core starts a managed host and connects to the RPC server inside it, where Plugin B listens, with shared interfaces and types compiled on both sides; after, each plugin is a native library with one exported entry point in its own host process, loaded by the OS loader with its own runtime and dependencies, and the core talks to each one over one stream with the same contract
Figure 2: The plugin boundary before and after, from plugins on the host's runtime, inside the core process or in a managed host the core reaches through a server, to a native library with one export, loaded into a small host process that the core reaches over a single stream

The constraints of a C export shaped the contract, and we think for the better. The exported function must be static, may take only blittable arguments, and can't be generic. A handle and a function pointer are about all you can pass. So the contract that used to be an interface hierarchy became two things: a C application binary interface (ABI) with one function, and a message protocol on one stream. Either side can change its compiler, its runtime version, or its libraries without the other noticing.

One more property of native libraries pushed us toward a process per plugin. .NET doesn't support unloading a NativeAOT library. A plugin in its own process can be stopped, replaced, and started again with ordinary process management. A plugin sharing the core's process could only be replaced by restarting the core.

Plugins on Their Own Release Cycle

This is where the plugin teams got the release cycle they had asked for. A native plugin includes its own copy of the runtime and all of its dependencies in a single file, so there's no shared runtime version to match and no framework to coordinate. The host is a small native program whose only job is to load a file and hand over a stream, and it doesn't depend on which .NET version or which libraries the plugin was built with.

That changes the release model. Each plugin is a single file with its own version and its own update channel. The core keeps the versions of a plugin it has on disk side by side and loads the highest one it can verify. The agent release bundles a version of each plugin, and that version is the floor: a plugin can move ahead of its bundled copy through its own channel, and a later agent release replaces it only when the bundle carries something newer. If a plugin wasn't bundled with the release, the core downloads it from the plugin's channel the first time it's needed. When an update arrives, the core downloads the new version, verifies it, switches the plugin to it, and keeps the previous version on disk. To roll back, the core switches back to the previous version. There's no package database to repair and no partial state to reconcile, because the unit of deployment and the unit of execution are the same file. Figure 3 traces both paths, startup and update.

Flowchart of resolving and updating a plugin: at startup the core loads the highest verified version on disk, or downloads one file from the plugin's update channel, verifies its signature and stores it, then starts the host and dials the stream, and if neither exists the plugin is unavailable and retried later; later, when the channel publishes a new version, it is downloaded to staging, verified, the host is stopped, the new version is switched in with the previous one kept, and the host restarts, switching back to the previous version and restarting if it is not healthy
Figure 3: Resolving a plugin at startup and applying an update later, where the core loads the highest verified version it has on disk, fetches a plugin from its own channel when none is installed, and switches to each new version while keeping the previous one for rollback

The Work Behind It

Getting there took work that had little to do with plugins and everything to do with making a large C# codebase compile under AOT. The cloud integration was where it started: serialization, dependency injection, and interop all depended on reflection, which the AOT compiler can't resolve at build time. Five changes took most of the effort.

Analyzers as errors. The first change was to catch reflection problems while writing the code. The .NET SDK includes trim and AOT analyzers that flag code the trimmer and the compiler can't prove safe, and a project can declare itself AOT compatible to turn all of them on. We turned them on early and promoted their diagnostics to build errors, so a developer sees the problem in an ordinary build rather than when a native publish fails later.

Compile-time dependency injection. Our container registrations were found by scanning assemblies for types at runtime, and the trimmer can't tell which types that scan will need. We replaced the scan with a source generator that emits the registrations at build time. The same approach covered serialization: the System.Text.Json source generator produces contracts at compile time, and we switched off its reflection fallback, so a missing contract produces an error right away instead of working on the JIT and failing later under AOT.

Interop shapes. Calls into native code declared the traditional way get their marshalling code generated at runtime, which AOT can't do; the source-generated form does that work at compile time. AOT on Windows doesn't support built-in interop with the Component Object Model (COM), so components that talk to the operating system through COM moved to generated COM wrappers. Callbacks from native code into managed code became function pointers rather than delegates.

Sidecar libraries. Some plugins depend on native libraries of their own. The .NET runtime's probing rules used to find them. Now the operating system's loader does, with the operating system's search rules, so those libraries sit beside the plugin and the plugin declares where to find them. A .NET 10 change narrowed the defaults: single-file apps no longer add the application directory to the native search list, and NativeAOT no longer sets an rpath to it on Linux and macOS. The default search flags still cover the application directory, but anything that leaned on those implicit paths, such as a sidecar library that loads a second library beside it, now has to say where to look.

Per-platform builds. NativeAOT doesn't compile across operating systems. The pipeline builds each plugin separately for every operating system and architecture we support, on a machine running that operating system, and stores the symbol file from each build so we can analyze crashes later. Figure 4 shows the pipeline for one plugin.

Diagram of the build for one plugin: C# source plus generated code (DI registrations, JSON contracts, interop stubs) goes through a build with trim and AOT analyzers treated as errors, then a publish step where the AOT compiler trims, compiles and links once per OS and architecture, producing a Windows x64 dll, a macOS arm64 dylib and a Linux x64 so, which are signed and released with symbols to the bundle or update channel
Figure 4: The build for one plugin, where generated code replaces reflection before the compiler runs, the analyzers gate the build, and the publish step produces one native library per operating system and architecture

What Changed on the Laptop

We measured managed and native builds of the same plugins on the same machine with the same workload. The results below include both changes at once: the native build and the move to one process per plugin. They were what the design predicted.

Cold start is the clearest win. Time from process start to a ready plugin fell by about 70 percent. The managed path pays for runtime initialization, assembly loading, and JIT compilation of everything on the startup path before the plugin does its first useful work. The native path pays for the loader mapping an image and a short runtime start, then runs. Microsoft describes NativeAOT startup as dominated by image load and the program's own initialization, and that's what we see: the startup time is now spent in our own code rather than in the runtime. Figure 5 shows what leaves the startup path.

Timeline comparing startup of a managed plugin and a native plugin: the managed plugin spends time on runtime init and on loading assemblies and JIT compiling the startup path before the plugin's own init, while the native plugin maps the image, runs a short runtime init and then its own init, becoming ready earlier, with the time removed from the startup path marked
Figure 5: What leaves the startup path, with JIT compilation gone, runtime start-up cut to a short fixed step, and the plugin's own initialization remaining (illustrative, not to scale)

Steady-state memory dropped by a little over half at idle and by about a third under load, for two reasons. The first is the runtime: there's no compiler to keep in memory, no intermediate language or metadata to hold for it, and nothing in the image that trimming found unreachable. The rest is our design: a plugin process no longer runs an RPC server, with its listener, thread pool, and buffers, because the core opens one stream to it instead.

The file size depends on what you compare it with. Measured against the small assembly that used to share the host's runtime, a native plugin is a larger file: it links its own copy of the runtime libraries and pre-generates the generic instantiations it uses, and the documentation calls out the disk-size impact of both. Measured against what a plugin has to carry to be independent of the host, its dependencies and everything they pull in, the native file is about 40 percent smaller. The trimmer keeps only the code the plugin reaches and drops the rest of every library, where a managed payload carries each assembly whole. The installer that bundles the plugins shrinks with them, and so does every update that follows. For an agent that lives on laptops, that trade is easy to make. Disk is plentiful and resident memory isn't, and because each plugin updates as its own file, an update downloads one plugin, about a tenth of a full agent release, rather than the whole agent.

What We'd Tell Another Team

The constraint we worried about most, no dynamic loading, is what gave us the better design. A plugin boundary made of a C ABI and one stream is smaller, it's clear about what crosses it, and it works regardless of which runtime either side was built with. We built it because AOT left us no other option. In hindsight, it's the boundary we should have built in the first place.

For a team considering the same move: turn the analyzers on before writing the first plugin, treat their warnings as errors from the first day, and design the boundary around what a C export can carry. The rest is the documented work of making a codebase trim-safe.

Every plugin has made the move. Each one is now a single native file, and its team releases updates to it whenever they're ready.

References

  1. Native AOT deployment, including the limitations list and the AOT-compatibility analyzers. Microsoft Learn.
  2. Introduction to AOT warnings and Introduction to trim warnings. Microsoft Learn.
  3. Understanding AssemblyLoadContext and the AssemblyLoadContext class reference. Microsoft Learn.
  4. Building native libraries with Native AOT. Microsoft Learn.
  5. Native code interop with Native AOT and the UnmanagedCallersOnlyAttribute reference. Microsoft Learn.

Island makes the agentic enterprise possible. One platform, one policy. Island enables, governs, and protects human and agent workers across the Island Enterprise Browser, network, data, and identity, so enterprises can put AI alongside their people with less complexity and more control than legacy architectures allow. Island's frontier control plane gives IT one place to see and govern all of it. Enterprises grant access, protect sensitive information, and audit how work gets done. Millions of users trust Island to simplify enterprise work and make the agentic era possible.

To learn more about how we're reimagining the enterprise workspace from the browser up, start here. If you're interested in building something that's changing everything, check out our open positions here.

Isaac Milstain

Isaac is a Senior Software Engineer at Island, building the core infrastructure of Island Desktop, the endpoint agent that brings zero-trust access, data protection, device visibility, zero-trust network access, and digital employee experience to the endpoint. Isaac works on the agent's core, the foundation the product plugins build on.