The Mechanics of Finalization in .NET
Inside the .NET runtime, any object that wraps unmanaged resources—file handles, network sockets, raw memory allocations—depends on a finalizer to release those resources when the garbage collector (GC) reclaims the managed wrapper. You declare a finalizer with the ~ClassName() syntax. At allocation time the runtime places the object onto a dedicated structure called the finalizer queue. That queue acts as a root set, keeping the object and the whole graph it references alive well beyond their normal lifetime.
When the GC marks an object as unreachable and sees it has a finalizer, it moves the reference from the finalizer queue to the freachable queue. A separate, dedicated thread then walks that queue and calls each finalizer one after another. This design adds real latency: the object survives at least one full GC cycle before its finalizer even runs, and the managed memory it occupies cannot be reclaimed until finalization finishes.

How the Finalizer Queue Drains
The finalizer thread pulls entries from the freachable queue sequentially. If a finalizer blocks—maybe it hits a synchronous I/O call, waits on a lock, or spins in an infinite loop—the whole queue grinds to a halt. No other finalizers execute until that call returns or the thread gets aborted. By default you get one finalizer thread per process. In high-stress situations the runtime might inject an extra thread, but the behaviour is not guaranteed and you should never count on it. The serial execution model means any backlog in the freachable queue turns directly into a growing pile of dead objects that still hold native resources and cannot let them go.
Tools such as PerfView and dotnet-counters expose Finalization Survivors and Promoted Finalization-Memory. When those counters trend upward, objects are being bumped into higher GC generations purely because their finalizers have not run yet. That promotion drives up the frequency of expensive Gen2 collections and fragments the managed heap over time.
How Backlogs Develop and Degrade Performance
A finalizer queue backlog almost always starts quietly. Imagine a burst of network connections that all time out at once. Each socket’s SafeHandle-derived wrapper queues up a finalizer, but the finalizer thread burns 50 milliseconds per object releasing the underlying handle because of a blocking closesocket call. If 200 sockets become unreachable inside a second, the freachable queue jumps to 200 entries. The finalizer thread needs 10 seconds just to drain them. During that window the GC promotes these dead objects into Gen2, where they inflate the working set and force premature Gen2 collections. Application throughput drops as GC thread suspensions get more frequent.
Memory Pressure and Gen2 Heap Growth
An object sitting in the finalization pipeline hangs onto its own memory plus the whole transitive closure of objects it references. A DbConnection that never got closed might hold a 4 KB internal buffer, a command object, and a transaction reference. Let 500 such connections pile up in the freachable queue and the retained memory easily crosses several megabytes. That memory stays resident; it pushes up the process’s private bytes. When the GC eventually promotes these objects to Gen2, the Gen2 heap expands. A larger Gen2 heap means longer collection pauses because the GC has to scan more memory. In server-side apps running under sustained load, this feedback loop can shove a process straight toward an OutOfMemoryException even when the live object count looks modest.

Thread Pool Starvation and Finalizer Deadlocks
The finalizer thread doesn’t work in a bubble. If a finalizer tries to grab a lock that a user thread is holding while that user thread waits for a GC to finish, you get a deadlock. A more common mess: a finalizer blocks on an async operation—misusing Task.Wait() or Task.Result—and starves the thread pool when the synchronization context dispatches back to a pool thread. The runtime’s finalizer thread isn’t a thread-pool thread, but the chain of blocked dependencies can stall progress across the whole application. I have walked into production hangs where a single finalizer calling FileStream.Flush() on a network share blocked for 30 seconds, the freachable queue ballooned past 10,000 entries, and the process turned completely unresponsive.
Diagnosing a Finalizer Queue Backlog
Spotting a backlog means correlating several diagnostic signals. Grab a memory dump during high memory usage first. Use !finalizequeue in SOS (Son of Strike) to inspect the freachable and finalizer queues directly. The output gives you object counts in each queue, broken down by type. A fat count of objects from a single type—say, System.Net.Sockets.Socket or Microsoft.Win32.SafeHandles.SafeFileHandle—points straight at a specific resource leak.
0:000> !finalizequeue
SyncBlocks to be cleaned up: 0
Free-Threaded Interfaces to be released: 0
----------------------------------
Generation 0 has 12 finalizable objects (000001e0b5c01040->000001e0b5c010a0)
Generation 1 has 5 finalizable objects (000001e0b5c01018->000001e0b5c01040)
Generation 2 has 1483 finalizable objects (000001e0b5c00018->000001e0b5c01018)
Ready for finalization 1847 objects (000001e0b5c010a0->000001e0b5c01448)
Statistics for all finalizable objects:
MT Count TotalSize Class Name
00007ff8e5a8c7a0 912 145920 System.Net.Sockets.Socket
...
A high number under Ready for finalization confirms the backlog. Next, grab the finalizer thread’s call stack with ~* kb or !threads and check whether it is blocked. The ThreadState often reads WaitSleepJoin. The managed stack shows which finalizer method is executing or stuck. I once tracked down a SafeHandle.ReleaseHandle() override that called a blocking DeviceIoControl API; the native stack showed the thread sitting in ntdll!NtWaitForSingleObject for minutes.
Using ETW and PerfView for Real-Time Analysis
ETW (Event Tracing for Windows) lets you monitor without touching the process. The Microsoft-Windows-DotNETRuntime provider emits FinalizeObject and IncreaseMemoryPressure events. Inside PerfView the GCStats view graphs the Finalization Survivors rate. If that rate stays above zero during steady-state operation, finalizers can’t keep up with allocation. The FinalizeObject event includes the object’s type name and how long the finalizer call took. Sort by duration and the worst offenders jump right out. I have seen finalizer durations over 2 seconds for objects that should finish in microseconds, which isolates the root cause immediately.

Mitigation Strategies and Best Practices
Keeping backlogs from forming starts with ripping blocking work out of finalizers. The only safe code inside a finalizer releases native resources without waiting. That usually means calling CloseHandle, ReleaseSemaphore, or writing a flag to a memory-mapped file. Anything that might block—file I/O, network calls, lock acquisition—has to be deferred. The Dispose pattern gives you the primary path for deterministic cleanup. When a consumer forgets to call Dispose, the finalizer serves as a safety net, but it must be designed to finish fast and reliably.
Reach for SafeHandle instead of writing custom finalizers. The runtime treats SafeHandle-derived objects specially during finalization, which lowers the risk of premature release and async-dispose race conditions. The SafeHandle.ReleaseHandle() method runs in a constrained execution region (CER), yet it still executes on the finalizer thread. Keep that method non-blocking. For resources that need a complicated shutdown sequence, consider hooking the AppDomain.ProcessExit event or spinning up a dedicated background thread that drains a concurrent queue of disposal actions. That separates the finalizer’s tiny notification from the potentially slow resource release.
Monitoring and Alerting in Production
Set up performance counters that track finalization activity. The .NET CLR Memory / Finalization Survivors counter and the # Bytes in all Heaps counter together tell you whether a backlog is driving memory retention. A threshold of 100 finalization survivors sustained for more than 60 seconds should trigger an alert. In containerized environments, pair these with memory limit alerts. A process that stabilises near its memory limit because of promoted finalizable objects will eventually hit an OOM kill, so the diagnostic data has to be captured before that point.
For applications with high object churn, call GC.SuppressFinalize() right after deterministic cleanup. That removes the object from the finalizer queue entirely, bypasses the freachable queue, and cuts down on promotion. In hot paths, the difference between an object that gets Gen0-collected and one that survives into Gen2 can add up to hundreds of milliseconds of pause time across the application’s lifetime.
FAQ
What is the difference between the finalizer queue and the freachable queue?
The finalizer queue holds live objects that have been allocated and registered for finalization. When the GC decides an object is unreachable, it moves the reference to the freachable queue. The finalizer thread reads from the freachable queue and runs each object’s finalizer. The separation guarantees objects are not finalized while the application might still be using them.
How can I tell if my application has a finalizer backlog without a memory dump?
Use dotnet-counters to watch dotnet.gc.finalization_survivors and dotnet.gc.time_in_gc. If finalization survivors keep climbing and the time spent in GC tracks that climb, a backlog is likely. Also, when the process’s Gen2 heap size grows monotonically without a matching increase in live objects, finalizable objects are being promoted.
Why does the finalizer thread sometimes appear stuck even when finalizers are fast?
A single slow finalizer blocks everything queued behind it. The thread can also get stuck in a native call that never returns, deadlock with a user thread, or end up waiting on a task that can’t complete because of thread-pool exhaustion. Looking at the finalizer thread’s native and managed stacks during the hang is the most direct way to find the blocking call.