.NET OutOfMemoryException: Fix It Without Adding RAM
Don't buy RAM yet — find the leak with dotnet-dump, fix LOH fragmentation, replace string concat with StringBuilder, and target 64-bit.
20+ years shipping production .NET services in enterprise systems. Written from production experience, not tutorials.
- ✓C# collections and strings
- ✓How the garbage collector works
- ✓Capturing dotnet-dump locally
- OutOfMemoryException rarely means the box ran out of RAM — LOH fragmentation, the 2GB single-object cap, and leaks are the usual culprits
- String concatenation in loops creates O(n²) garbage; switch to StringBuilder and stream rows instead of buffering them all
- No object may exceed 2GB even on 64-bit, so chunk giant arrays, files, and responses instead of loading them whole
- Confirm 64-bit with Environment.Is64BitProcess — a 32-bit target caps you near 2-4GB no matter how much RAM the server has
- Capture a dump with dotnet-dump collect, diff two snapshots with windbg or dotMemory, and fix the rooted growth before scaling
Think of a parking lot with plenty of empty spaces — but they're all scattered motorcycle-sized spots, and you're driving a bus. That's .NET when memory runs out: free space exists, yet no single stretch fits what you need. Adding RAM builds a bigger lot while everyone keeps parking sideways. The real fix is finding who parks badly, using smaller vehicles, and painting better lines.
OutOfMemoryException in .NET almost never means what it says. Developers picture a server gasping with zero bytes free, but production dumps usually show gigabytes available — fragmented into unusable gaps, blocked by a single 2GB-plus object, or pinned by event handlers that never unsubscribe. Throwing hardware at fragmentation buys weeks; finding the root cause buys years.
The .NET garbage collector divides memory into generations plus a Large Object Heap for objects over 85KB. The LOH compacts only on demand, so allocating and dropping big buffers carves it into Swiss cheese. Add string concatenation in a loop — each += copies the whole string — plus 32-bit address limits and undisposed bitmaps, and you have the classic recipe for an app that dies with memory to spare.
This guide teaches the diagnosis first: confirming bitness, reading GC metrics, and diffing heap dumps to name the leaking type. Then the fixes in impact order — streaming over buffering, StringBuilder over concat, chunking over giant objects, weak events over permanent subscriptions. You'll leave able to tell a leak from fragmentation from a limit in one dump-diff session.
It's Not Always RAM: The Three Real Causes
When OutOfMemoryException lands, resist the urge to resize the VM and instead classify the failure. Leaks grow without bound — each dump shows more of some type than the last, because roots like static fields or events keep objects reachable forever. Fragmentation shows plenty of free bytes with no contiguous room — the LOH is Swiss cheese from big short-lived buffers. Limits show a hard ceiling — crashes near 2GB on 32-bit, or a single object near 2GB on any bitness.
Each class has a signature metric. Leaks show gen2 heap size climbing across dumps while request volume stays flat. Fragmentation shows LOH size large with % Time in GC high as the collector works harder for less room. Limits show a crash at a suspiciously round number — 2GB process, 2GB object — regardless of traffic. Reading these three numbers takes ten minutes and prevents weeks of wrong fixes.
The ordering matters because fixes for one class worsen others. Adding RAM helps genuine growth but hides leaks until they eat the new ceiling too. Forcing full collections helps fragmentation briefly but tanks throughput. The only durable path is naming the class first: diff two dumps for leaks, measure LOH for fragmentation, check bitness and object sizes for limits. Everything after that is execution.
LOH Fragmentation: Death by a Thousand Buffers
Objects over 85KB land on the Large Object Heap, which the collector sweeps but compacts only when asked — the default balances pause time over density. Allocate big buffers in a loop (export chunks, image tiles, response buffers) and each generation leaves holes the next allocation can't reuse unless it fits exactly. Over hours, free space grows while usable space shrinks, until a modest request finds no contiguous home and throws with gigabytes technically free.
Pinning makes it worse. fixed blocks, GCHandle pinning for interop, and async IO buffers pin objects so the compactor can't move them even when compaction runs. A few pinned buffers scattered across the LOH freeze the holes in place permanently. You'll see this as fragmentation that survives a forced compaction — the holes are nailed down.
The fixes attack allocation patterns, not the collector. Rent buffers from ArrayPool<byte>.Shared instead of newing them per operation — pooling reuses the same holes instead of digging new ones. Keep buffers small where possible so they stay in gen0, which compacts every collection. And when fragmentation is proven by metrics, trigger one compaction with GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce rather than fighting it continuously. Pool first, compact rarely, pin briefly.
The 2GB Single-Object Ceiling Still Applies
Even on 64-bit with 128GB RAM, no single object may exceed 2GB by default — one byte[] , one string, one List backing array. The limit is per object, not per process, and exceeding it throws OutOfMemoryException no matter how free the heap looks. A 3GB file read with File.ReadAllBytes, a 2.5GB JSON string, or a List<byte> grown past the boundary all die the same way.
The tell is a dump dominated by one giant instance, or a crash on a specific ReadAll/ToArray/ToList call with large inputs. Developers often meet it right after fixing 32-bit — the process ceiling lifts, then the object ceiling bites on the biggest payload. The gcAllowVeryLargeObjects flag relaxes array limits on 64-bit, but strings stay capped and the setting papers over designs that shouldn't hold gigabytes in one object anyway.
Chunk everything. Stream files with FileStream instead of ReadAllBytes, page database reads instead of ToList on millions of rows, and flush HTTP responses incrementally instead of building the body in memory. The chunked version uses near-constant memory regardless of input size — the same code handles 10MB and 10GB. Whenever you see ReadAll, ToArray, or ToList on unbounded data, treat it as a future outage with a date to be determined.
String Concat Loops: The Classic O(n²) Killer
The += operator on strings looks innocent and scales catastrophically. Strings are immutable, so each += allocates a fresh string copying all previous content plus the new piece. Building an N-character result this way copies roughly N²/2 characters total — a 180MB file forces about 11GB of allocation through gen2 and the LOH. The collector can't keep up, pauses spike, and the process dies near completion when the biggest copies land.
StringBuilder exists for exactly this shape: an internal char buffer that grows geometrically, amortizing appends to near-constant cost. Pre-size it when you know the scale (new StringBuilder(capacity)) to skip regrowth entirely. For file and network output, skip the in-memory result altogether — write through a StreamWriter with periodic flushes so peak memory reflects one batch, not the whole dataset.
The same disease appears in disguise: LINQ Aggregate with string concat, JSON built by +=, log messages assembled per row into a giant report string. Any loop accumulating text into one value needs StringBuilder or streaming. The regression test is cheap — assert peak bytes on a 100K-row fixture in CI — and it catches the next concat the week someone refactors the export.
64-bit vs 32-bit: Check Before You Buy RAM
A 32-bit process addresses near 2GB (4GB with special flags) no matter how much RAM the server holds. The Prefer32Bit MSBuild flag, an x86 PlatformTarget, or running under 32-bit IIS silently imposes this ceiling on modern 64-bit hardware. Symptoms are unmistakable in hindsight: crashes at the same round number across machines, hardware upgrades that shift crashes by minutes instead of fixing them, and plenty of free server memory at every crash.
Verification takes seconds. Log Environment.Is64BitProcess at startup and alert if it's false in production. Audit csproj files for Prefer32Bit and PlatformTarget, and check the deployed bitness — not just the build machine's. Container base images and app pools each have their own defaults that can surprise you independently of the project file.
Going 64-bit raises the ceiling but doesn't fix leaks — it gives fragmentation more room and delays the page. That's still worth doing for legitimately large workloads (exports, image processing, caches), because address space stops being the bottleneck. But pair the switch with the dump-diff discipline: confirm what grows, fix the growth, then size hardware to the fixed baseline. Bitness first, defects second, hardware last.
Dump-Driven Workflow: From Crash to Named Type
Start with two dumps, not theories. Collect one at baseline and one near the failure (or an hour apart during growth), then run dumpheap -stat on each and diff the top types. The type whose count or bytes grow without bound names your leak — DataTables held by a static cache, event handlers pinning view models, or contexts accumulated in a singleton. One diff beats a week of staring at code.
Next, find the root path. windbg's !gcroot on a sample instance prints the chain of references keeping it alive — static field to cache to list to your object. The fix targets the root, not the leaf: evict the cache, unsubscribe the event, scope the context. Killing leaves (nulling one field) while the root keeps accumulating just moves the crash a week out and teaches you nothing.
Lock it in with three guards: a memory regression test on realistic volume, live GC counters on the dashboard (heap size, gen2 rate, LOH size), and an alert on sustained growth rather than absolute bytes. Absolute thresholds page during legitimate peaks; growth-rate alerts page on leaks. The team that diffs dumps on every OOM never buys RAM twice for the same underlying bug — measurement is cheaper than hardware, every single time.
String Concat in a Loop Paged Us 6 Nights Straight
- Never concatenate strings in a loop — StringBuilder plus streaming writes turns O(n²) garbage into O(n) output.
- Check Prefer32Bit and bitness before buying RAM; a 32-bit ceiling makes hardware upgrades pure theater.
- Gate memory with regression tests on realistic volumes — a 10-row unit test can't catch an allocation bug that needs a million rows.
| File | Command / Code | Purpose |
|---|---|---|
| dotnet-dump collect -p | It's Not Always RAM | |
| PoolBuffers.cs | byte[] buffer = ArrayPool | LOH Fragmentation |
| StreamDontBuffer.cs | await using var fs = new FileStream(bigPath, FileMode.Open, | The 2GB Single-Object Ceiling Still Applies |
| ConcatFix.cs | var sb = new StringBuilder(capacity: 200 * 1024 * 1024); | String Concat Loops |
| BitnessCheck.cs | if (!Environment.Is64BitProcess) | 64-bit vs 32-bit |
| dotnet-dump collect -p | Dump-Driven Workflow |
Key takeaways
Common mistakes to avoid
5 patternsDoubling RAM before classifying the failure
Concatenating strings in a loop
Buffering unbounded datasets with ToList/ReadAll
Caching without eviction or scoping contexts as singletons
Forcing full GCs to treat fragmentation
Interview Questions on This Topic
What usually causes OutOfMemoryException when free RAM exists?
Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Written from production experience, not tutorials.
That's Exceptions. Mark it forged?
5 min read · try the examples if you haven't