Here's how we rebuilt our endpoint agent to be faster, lighter, and completely independent.
%2520Our%2520Plugin%2520System.png)
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.
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.
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.
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.
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.

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:
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.
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.

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.
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.

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.

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.

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.
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.
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.