I called Unload(). I nulled my references. I did the WeakReference poll dance. I called GC.Collect() like a desperate man. The garbage collector collected everything in my application except the one thing I actually wanted it to collect.

This is the story of how I inherited/volunteered for a codebase that used AppDomains, watched Microsoft replace them with something worse, discovered that the .NET standard library actively sabotages its own unloading primitive, and eventually solved the problem by giving up and spawning child processes like Microsoft’s own CreateProcess from 1993. Buckle up, we’re starting at the beginning. dun dun dun… AppDomains.

AppDomains Used To Be Cool

When AppDomains first came out, you could hot swap plugins, workers, or basically anything that fit that architecture. The pattern was straightforward: you define a contract interface, throw it in a shared NuGet package or library, and build your plugins against it. The host service would spin up a child AppDomain, load the plugin assembly into it, use it, and when it was time to swap, call AppDomain.Unload() on the whole domain.

But Unload() alone didn’t do all the work. It would abort threads, fire the DomainUnload event, and finalize objects inside the domain, but you still had to null out every reference to the AppDomain and its proxies so the GC could actually reclaim the memory. If you held onto even one MarshalByRefObject proxy or the AppDomain variable itself, the GC wouldn’t touch it. Once all that was properly dereferenced, create a new domain, load the updated assembly, and you’re back in business. Zero downtime.

This was a star. Not everyone had distributed systems or the budget to spin up a bunch of separate services for every little thing. Deploying multiple services costs money, adds complexity, and eats time. When you’re smaller and the load doesn’t justify it, you don’t want five different deployed workers when one service launching them dynamically does the job. With AppDomains you could have one service (or a few) that spins up workers on demand, swaps them out when a new version is ready, and keeps running. No extra deployments, no extra infrastructure. The downside? If the worker was a full service or had dependencies, you’d still need to turn things off to swap it. But for the simple case, deactivate the schedule or pause consumption, deploy over top of it, and it would just reload. The old AppDomain gets unloaded, dereferenced, garbage collected. SO awesome and so cool.

Until it stopped working.

So now deployments start requiring the master service to fully stop. Scheduling downtime breaks because so much depended on this thing working. What went wrong? It wasn’t one update that broke it. AppDomains worked fine when things were simple. The problems showed up as codebases grew, dependencies got more complex, and libraries started leaking into the default domain through shared references and caches you didn’t write. Here’s what that actually looked like.

For starters, any assembly loaded into an AppDomain is locked on disk for the lifetime of that domain. Can’t replace it, can’t recompile over it, can’t redeploy. The file is locked until the domain unloads.

Then there’s type leaking. If any code in the default AppDomain referenced a type from your plugin assembly, even indirectly through a cast or return value, the CLR would load that assembly into the default domain too. The default domain cannot be unloaded. Ever. Those assemblies are stuck.

On top of that, domain-neutral assemblies could never be unloaded either. Depending on how your host was configured (ASP.NET for example used MultiDomainHost, which made all strongly-named assemblies domain-neutral), system assemblies and GAC packages would get loaded into something called the SharedDomain for performance. Even after every AppDomain using them was gone, that code stayed in SharedDomain until the process died. Workers get unloaded but their domain-neutral copies don’t. Over time it accumulates. A fresh process starts clean, but as workers cycle through, it fills up. That’s why it worked fine at first and got worse over time. Not a sudden break. A slow buildup.

And the APIs themselves didn’t help. AppDomain.Load is a straight up trap. When you call it, the assembly gets loaded in the target domain, but it also returns a reference to the same assembly back to your parent domain. You said “load it over there” and it loaded it in both places. The fix is to write a remoting proxy class that loads the assembly inside the target domain and does NOT return a reference. But you must never get a direct reference to the loaded assembly. Not even to an interface. The moment you touch it, it gets loaded in your parent domain. The API leaks types just by looking at them.

Version mismatches make it worse. Say the parent domain has A.dll version 2, and the child domain needs version 1. ChildDomain.Load(ABytes) fails because Load tries to return a reference to the parent domain, which forces the parent to also resolve A.dll. But it already has its own version and blows up. And if you try to catch it with AssemblyResolve? You can trap the event, but you get an assembly name with no file path. Nothing you can do. The hook fires too late.

Shadow Copy: The Workaround That Became A Feature

So the files are locked, types leak, the APIs betray you, and versions collide. Microsoft’s answer? Later down the road they introduced shadow copying for AppDomains. The idea was solid: copy assemblies to a cache directory on load so the original files aren’t locked on disk, allowing you to overwrite them while the app runs. ASP.NET used this by default and it’s why you could deploy new DLLs to a running site. But let’s be real about what this was. A workaround to not lock the original file. That’s all it did. A workaround shipped as a feature.

But outside of that sweet spot it was finicky. It added startup latency, only worked for assemblies in the application directory (not the GAC), and several of its APIs were marked obsolete as early as .NET Framework 2.0. Not exactly a confidence booster.

And even when you did enable it? It still had the same fundamental problems. The type leaking, the version mismatches, the references bleeding into the parent domain. Shadow copy didn’t fix any of that. It just unlocked the file on disk. GAC assemblies and domain-neutral assemblies couldn’t even be shadow copied, so for a plugin/worker architecture where system libraries are doing the leaking, shadow copy didn’t touch the problem. And even for your plugin DLLs that were shadow copied, it only helped if the assembly stayed in the worker’s AppDomain. The moment it leaked into the default domain through a type reference or bad Load call, the default domain loads it directly with no shadow copy, locks the file, and since the default domain never unloads, that lock is permanent. Your worker can unload fine but the file is still held by the host. All the other AppDomain landmines were still right where you left them. It wasn’t moving the platform forward, it was buying time. And if you were on .NET Framework 4.7.2, which plenty of shops were and still are, you were stuck with this band-aid on a system that had been broken for years with no real path forward. Shadow copy was a patch on a patch, and the foundation underneath was still crumbling.

Then they killed it. Shadow copy is .NET Framework only. It doesn’t apply to .NET Core, .NET 5, .NET 6, or any newer implementation. They removed the escape hatch. ASP.NET Core doesn’t use shadow copying by default, and as a result, even with app_offline.htm, publish operations on IIS frequently fail with ERROR_FILE_IN_USE. Not only did the publish not work, but you’d screw up your live deployed site because files were partially published, leaving the app inconsistent. The historical solution: try again, recycle the App Pool, then try again. Let the punishment continue until morale improves.

Then shadow copy came back in .NET 7 as an opt-in feature for the ASP.NET Core Module on IIS. Killed it, then walked it back when production reality demanded it. Classic.

So AppDomains are busted. No one wants to fight with this anymore. The response? Everyone starts spinning up separate deployed services, Web APIs, and other absolute slop that was never designed to be one. Processes that were perfectly fine as single-instance workers are now sitting behind APIs, and because someone decided to load balance them they’re creating duplicate results and causing havoc. In short: AppDomains suck now. Microsoft shipped an unpolished product and moved on. Same energy as adding Copilot to Microsoft Paint. They probably thought it was the best thing since sliced bread too.

The New, The Improved: AssemblyLoadContext

Yeah, that cool new thing Microsoft released with .NET Core. The almighty replacement for AppDomain. Except it wasn’t a replacement. AppDomain.CreateDomain() now throws PlatformNotSupportedException. They ripped out the ability to create multiple AppDomains entirely. What they gave us instead was AssemblyLoadContext (ALC). And in their own migration design doc, using multiple ALCs is listed as option 5 out of 5. The last option. The most complex one. The doc literally says “This is an advanced scenario. It should not be used without seriously considering the benefits and costs of other solutions.” Option 3? Migrate to IPC. Process isolation. They recommend spawning separate processes before they recommend using their own ALC primitive. Does ALC serve a purpose? Yes. Can it work? Yes, it can. Did it solve the original problem I wanted solved? No. No it did not.

“Cooperative” Unloading, A Polite Suggestion To The GC

The fundamental change from AppDomain to ALC is this: AppDomain had forced unload. It aborted threads, killed COM objects, and tore down the domain whether it liked it or not. ALC replaced that with cooperative unload. When you call AssemblyLoadContext.Unload(), it doesn’t unload anything. It initiates unloading. The actual unload happens when, and only when, nothing references anything in the context anymore and the GC gets around to collecting it. If something still holds a reference? The ALC stays alive. Forever, if necessary. No exception thrown, no warning, no timeout. It just silently doesn’t unload.

“Cooperative” is a generous word for “we removed the forceful option and replaced it with a prayer.”

The list of things that can silently pin your ALC is absurd:

  • JIT register pinning. The JIT can keep a reference to a plugin type in a CPU register. If your plugin host method is still on the call stack, the JIT may not have released it. The fix? Wrap your load/unload logic in a separate method decorated with [MethodImpl(MethodImplOptions.NoInlining)] so the JIT doesn’t optimize references into the caller’s frame. Yes, really. Your unload correctness depends on a JIT hint.
  • Static fields. Any static field in the default ALC that holds a reference to a type from your collectible ALC will pin it. This includes fields you didn’t write. More on that in a minute.
  • Fields on the ALC subclass itself. If your custom AssemblyLoadContext subclass stores references to the assemblies it loaded (which is natural, you’d want to track them), those references pin the ALC. You have to null them out before calling Unload(). To throw out the trash bag, please first empty the trash bag.
  • Threads still running. If a thread is executing code from an assembly in the ALC, the whole context stays pinned.
  • The debugger. There’s an open bug where inspecting a Type object in the debugger keeps the ALC alive indefinitely, even after Unload() and a full GC. The act of debugging prevents unload. Good luck diagnosing your unload issues when your diagnostic tool is the cause.

Oh and Unload() doesn’t throw if it fails. It returns, you think it worked, and the ALC is still alive. The only way to know if it actually unloaded is to hold a WeakReference to the ALC and poll it in a loop after calling GC.Collect(). The thing every .NET book tells you never to do in production.

The Framework Leaks Into Itself

Because shared contract interfaces have to live in the host’s default ALC for casts to work, the host ends up holding references to plugin types. That alone pins the plugin ALC. But it gets worse. It’s not just your code holding references. Microsoft’s own standard library holds references that pin your ALC, and there’s nothing you can do about it.

System.ComponentModel.TypeDescriptor, the one that kills your ALC via JSON.NET. If your plugin ever serializes one of its own types through Newtonsoft.Json, JsonConvert.SerializeObject() internally calls TypeDescriptor.GetConverter(type), which adds your plugin’s type to a static cache inside System.ComponentModel. That cache lives in the default ALC because System.ComponentModel does. The plugin Type lives in the collectible ALC. The static cache in the default ALC now holds a strong reference to a Type in your plugin ALC. Game over, your plugin can never unload. The cache is private. The library is System. You didn’t write it, you can’t see it, and the only “fix” anyone has found is to reflect into System.ComponentModel.ReflectTypeDescriptionProvider and manually clear private caches. A customer-written hack, reflecting into runtime internals, to make Microsoft’s own primitive work with Microsoft’s own serializer.

System.Dynamic.Utils.TypeExtensions. This was filed in September 2025. A static field (s_paramInfoCache) inside a core runtime library caches ParameterInfo entries keyed by type. Touch dynamic dispatch, lambdas, or expression trees in your plugin and this cache catches a reference. You didn’t write the cache. You can’t clear it without reflecting into private fields.

The runtime itself was leaking until 2022. A PR from the runtime team fixed a memory leak where Module::m_pJitInlinerTrackingMap, a native runtime data structure, was never freed on ALC unload. For years. A customer ran 80 million load/unload iterations before the OOM proved it. The runtime that exposes the unload API was failing to unload its own bookkeeping.

Autofac, ASP.NET Core, EF Core. All had the same disease. Autofac’s static ConcurrentDictionary caches keyed by Type pinned entire ALCs. ASP.NET Core’s ApplicationPartManager leaked property caches on every load/unload cycle. EF Core’s model caches do the same. Autofac eventually shipped ALC-aware lifetime scopes in 7.0. Note the word “best-effort.” Even the library that explicitly solved this can’t guarantee it works.

Microsoft’s own debugging docs say the quiet part out loud: when a pinned GCHandle shows up in WinDbg, “this one is actually a static variable, but unfortunately, there’s no way to tell.” You’re expected to debug a problem the debugger itself can’t fully diagnose.

So yeah. Microsoft built two products that don’t work together: the unload API and the standard library that ships beside it. The standard library doesn’t know about unload. The unload API can’t force the standard library to forget. And you, the application developer, are the one standing in the middle when those two products collide and your production box runs out of memory.

The Wrapper Tax (ALC Edition)

Just like AppDomains, every team that tries to use collectible ALCs in production ends up writing the same wrapper:

  • A [MethodImpl(MethodImplOptions.NoInlining)] helper method to keep JIT references off the caller’s stack
  • A custom ALC subclass with isCollectible: true and a Dispose that nulls out internal dictionaries before calling Unload()
  • A WeakReference polling loop with GC.Collect() and GC.WaitForPendingFinalizers() retries
  • A retry budget, try 10 collections, then give up and log
  • LoadFromStream(new MemoryStream(...)) instead of LoadFromAssemblyPath to avoid file locks

That’s the actual product. Not what Microsoft shipped, what the community had to build on top of it to make it usable. Every team writes it from scratch. Nobody ships it as a library because the failure modes are environment-specific. The wrapper tax gets paid in person, by hand, by every .NET shop that tried to make this work.

Even McMaster.NETCore.Plugins, one of the more popular plugin loaders in the .NET ecosystem, supports unloading but warns you have to clean up all usages of plugin types before it actually works. In practice, that’s almost impossible to guarantee when the framework itself is holding references you can’t see.

When The Wrapper Isn’t Enough: Process Isolation

At some point you stop fighting the framework and reach for the only primitive that actually delivers what AppDomain used to promise: a separate OS process.

After enough OOM-kills, enough WinDbg sessions chasing static caches in System.ComponentModel and System.Dynamic.Utils, enough mornings explaining to multiple divisions why the entire service needs to restart just so their hotfix can take effect, I gave up on making ALC unload work and built something simpler.

The architecture I ended up with:

  • A host service, the long-lived one, the orchestrator
  • For each plugin invocation, the host spawns a console app wrapper via ProcessStartInfo, one .exe per plugin run
  • Host and plugin communicate over named pipes. Request in, response out, lifecycle signals on the side
  • When the plugin’s work concludes, the .exe exits
  • Process exit is the unload. The OS reaps the memory, the file handles, the JIT caches, the native DLLs, the static caches in System.ComponentModel that ALC couldn’t touch. All of it. Atomically. No cooperation required from anyone.

The ALC still lives inside the console app. It loads the plugin assembly, runs it, and when the process exits, everything goes with it. The difference is you’re no longer asking the GC to please maybe eventually clean up if nothing is holding a reference. The operating system doesn’t ask. It terminates the process and closes every handle the kernel gave it. That contract has been working reliably since Microsoft shipped CreateProcess with Windows NT 3.1 in 1993.

Is it perfect? No. Spinning up a .NET console app isn’t free. You pay JIT, you pay deps.json resolution, you pay CoreCLR startup. You can pre-warm a pool of idle workers or use NativeAOT for the worker exe, but there’s overhead. Everything across the pipe has to serialize too. You can’t pass live object references. Honestly though? That’s a feature, not a bug. It forces you to define a real contract, and there’s no “shared interface in the default ALC” trap because there’s no shared address space to trap into.

And yeah, you’re running a process tree now instead of a single process. Crash handling, zombie cleanup, pipe lifecycle, that’s all on you. But those are well-understood problems with 30 years of tooling behind them. I’ll take that over ALC unload diagnostics where the runtime team tells you to attach WinDbg and read raw GC roots any day.

Why named pipes? Sockets are overkill and bring port management. stdin/stdout works but mixes channels with logging. Named pipes give you a real bidirectional duplex channel with OS-level access control, and they go away when the process goes away. Cross-platform, you get the same model via Unix domain sockets.

This isn’t some rogue workaround either. Remember that migration doc I mentioned earlier? IPC is option 3, multiple ALCs is option 5. Their official guidance recommends process isolation before their own in-process primitive. A Microsoft runtime engineer gave the same advice to a customer who couldn’t get ALC unload to work: run it as a child process.

The 1990s called. Process isolation is back, because the in-process replacement never actually worked.

I Love The Garbage Collector. I Also Hate It.

After all that ranting about ALC and unloading, I want to talk about the GC itself. Because here’s the thing. I genuinely love garbage collection as a concept. The idea that I can allocate objects, use them, and walk away while something else figures out when to clean up? That’s magic. It’s the reason C# is productive. It’s the reason millions of developers ship working software without ever thinking about free(). When it works, it’s the best thing in the runtime.

When it doesn’t work, you get everything I just described above.

The GC’s biggest crime isn’t that it exists. It’s the false promise. It collects its garbage on its schedule, and what you consider garbage and what the GC considers garbage are often two very different things. You called Unload(). You nulled your references. You did everything right. But somewhere in System.ComponentModel there’s a static cache the GC doesn’t consider garbage because technically something still points to it. And now your “collected” objects are alive forever, your memory climbs, and your service needs a restart. The garbage collector collected everything except the garbage.

Then there are the hiccups. Gen 2 collections that pause your application for tens of milliseconds. In a high-throughput service, those pauses add up. You start thinking about object allocation patterns, struct vs class decisions, Span<T>, pooling. All the things that feel like you’re fighting the runtime instead of using it. The GC is fast until it isn’t, and when it isn’t, the debugging story is “attach a profiler and stare at allocation traces for three hours.”

And that’s where someone inevitably says “just use Rust.”

The Rust Detour

Look, I like Rust. I do. The crate ecosystem is genuinely impressive, the tooling is solid, and the language forces you to think about ownership in a way that prevents entire categories of bugs. There’s no GC to betray you because there’s no GC. You own your memory, you drop it when you’re done, the compiler yells at you until you get it right.

And man, does it yell. Rust is the language equivalent of a code reviewer who is always technically correct and never lets anything slide. My code is bad sometimes. I know it’s bad. I don’t need the borrow checker to have an existential crisis over a temporary reference I’m going to drop in two lines. But that’s the deal. Rust yells at you for your sloppy code, and in exchange, you don’t get use-after-free bugs, you don’t get dangling pointers, and you don’t get a garbage collector that decides your objects aren’t actually garbage.

It’s annoying. It’s also correct.

But here’s where I push back on the Rust evangelists. The folks who show up in every thread about GC performance and say “this wouldn’t happen in Rust.” Cool. You’re right. You know what also wouldn’t happen in C? And C has been around since 1972. If you truly care about GC overhead that much, go write C. If your response is “well C is unsafe” or some other excuse, that’s a skill issue. Manual memory management isn’t dark magic. People shipped operating systems and databases with malloc and free for decades. If the argument against C is that it’s too hard to get right, and Rust is the answer because the compiler holds your hand, then maybe using Rust is compensating for something.

I’m being intentionally provocative here, but the point is real: every language is a set of trade-offs. C# trades deterministic cleanup for productivity and safety. C trades safety for raw control. Rust trades readability and compile times for correctness guarantees. None of them are “the way.” Anyone who says Rust is the one true path has big “well actually” energy and probably has opinions about text editors that nobody asked for.

And the “Rust is great for AI” crowd, that one really gets me. The argument seems to be that if it compiles, it works, and that’s what you want when AI is generating your code. But compilation is not correctness. A Rust program that compiles is memory-safe. It is not logically correct. It is not deterministic in the way people seem to think it is. It can still deadlock, still have race conditions on shared state behind Arc<Mutex<T>>, still produce wrong results with perfect type safety. The compiler catches a specific class of bugs. It doesn’t catch the class of bugs that actually matter in most applications. Wrong business logic, bad assumptions, off-by-one errors. The idea that “it compiles therefore it works” is the kind of thing that sounds smart until you’ve shipped a bug in a language with a strict type system and realized the compiler had nothing to say about it.

Rust is a great language. It’s also sometimes awful to read. Lifetime annotations on a function signature that look like someone’s cat walked across the keyboard. But it has its place, and that place is systems programming, embedded, performance-critical paths. It’s not a universal answer to “my GC didn’t collect what I wanted.”

Back To The Point

The GC is wonderful. If only it collected what I told it was garbage instead of what it decided was garbage, it would be the best runtime feature ever shipped. I don’t want to go back to malloc. I just want the GC to hold up its end of the deal. And when it comes to ALC unloading, it doesn’t. Not because the GC is bad, but because the rest of the framework leaks references that the GC has to keep alive.

The garbage collector works perfectly. Everything around it is the problem.

Closing Thoughts

I’m annoyed. This whole thing is annoying. I spent way too long solving a problem I shouldn’t have had to solve in the first place. Everything has problems, every language, every framework, every runtime. That’s fine. But some problems make the tool useless for the thing it was supposed to do, and this is one of those.

The GC is great. The GC is also bad. I write bad code. It seems like everyone else does too, and now their newfound agents are writing bad code even faster. Everything is broken and nothing is getting simpler. Help. The world is ending.

Anyway. Back to spawning child processes.


Yes, AI helped edit this post. I used it to structure my thoughts, not generate them. The research is real, the rage is real, the experience is mine. Use the tool, stay in control of the outcome. The opinions are mine. The formatting is Claude’s. Deterministic collaboration.