How does garbage collection work in .NET, with generations and the large object heap, and how do you find a memory leak?
8 reports in the question index: Accenture, EPAM, Deloitte, Infosys, Northcorp and others, mostly first technical rounds. Interviewers usually add "and how do you find a memory leak?".
Say this first · about 30 seconds
The .NET runtime keeps managed objects on the managed heap, the area it tracks, and frees them with a tracing garbage collector. The collector does not count references: it starts from the roots, the live pointers such as static fields, local variables and CPU registers, and frees every object those roots cannot reach.
The heap is split into generations by age. New objects start in generation 0, which is small and collected often. Anything that survives moves to generation 1, and long-lived objects end up in generation 2. Objects of 85,000 bytes or more go on the large object heap, which is only swept during a full generation 2 collection (a full collection sweeps gen 2 and the LOH).
So a leak in .NET is not a forgotten free. It is an object that is still reachable, held by something the collector treats as a root. On our BMW FS statement , a static dictionary kept every rendered PDF alive, so a full collection found them all reachable and freed none; the replica hit its memory limit and was killed and restarted.
To find one, I watch the memory trend first, with (a command-line tool that prints live runtime counters) for the gen 2 and LOH sizes (the bytes each one holds), then take a heap dump (a snapshot of every object in the heap) with and follow the path from the biggest objects to their root. That is how we found the static dictionary holding every PDF.
The fix is to remove the root: a cache with a size limit and eviction (dropping the oldest entry when full), keyed by contract id (one entry per contract) instead of by the current time, plus a test that proves an evicted PDF is collected. The same rule applies to a static event (a list of methods that other code adds itself to): the publisher (the class that owns the event) roots every subscriber, so a subscriber must unsubscribe when it goes.
I never call GC.Collect in application code. It forces an expensive full collection, pauses the app, and hides the object that is really holding the memory.
Make it yours · UDC Finance bank-file import (invented example)
Situation: UDC's end-of-day bank file is about 180 MB. Our importer read it whole into one byte array, which is over the LOH limit, then split it into lines. Every run allocated a fresh 180 MB block, and when two runs overlapped the pod hit its memory limit.
What I did: read the file in 64 KB chunks instead. Each chunk rents a buffer from , a shared pool of arrays, and returns it in a finally block, so the same buffer is reused for the next chunk and the next run. No single big array is allocated any more.
Result: the LOH stays flat during an import, and the memory limit no longer decides when the job runs. A small test rents a buffer, returns it, rents again and checks it is the same array.
What I'd say: the LOH is for big objects, so the goal is to stop creating one per request. Rent a buffer, return it, and watch loh-size before and after; the fix is an allocation change, not a collector setting.
The code, simplified
Invented to show the shape, in C#. Tap Next to walk one 64 KB chunk through the pool.
UDC.sln · where these 2 files sit
Imports/
Diagram · step 1The job opens the 180 MB bank file. No array is allocated yet: the file is a stream.
Bank importone run
Array poolreused buffers
LOHstays flat
180 MB stream
BankImportJob.cs · line 1 · 1 of 9The job that imports one bank file. means nothing inherits from it.
Fix later in your repoExample written for this card: invented, not from a real codebase.
Say it out loud, then check
Show the 30-second answer
The .NET runtime keeps managed objects on the managed heap, the area it tracks, and frees them with a tracing garbage collector. The collector does not count references: it starts from the roots, the live pointers such as static fields, local variables and CPU registers, and frees every object those roots cannot reach.
The heap is split into generations by age. New objects start in generation 0, which is small and collected often. Anything that survives moves to generation 1, and long-lived objects end up in generation 2. Objects of 85,000 bytes or more go on the large object heap, which is only swept during a full generation 2 collection (a full collection sweeps gen 2 and the LOH).
So a leak in .NET is not a forgotten free. It is an object that is still reachable, held by something the collector treats as a root. On our BMW FS statement , a static dictionary kept every rendered PDF alive, so a full collection found them all reachable and freed none; the replica hit its memory limit and was killed and restarted.
To find one, I watch the memory trend first, with (a command-line tool that prints live runtime counters) for the gen 2 and LOH sizes (the bytes each one holds), then take a heap dump (a snapshot of every object in the heap) with and follow the path from the biggest objects to their root. That is how we found the static dictionary holding every PDF.
The fix is to remove the root: a cache with a size limit and eviction (dropping the oldest entry when full), keyed by contract id (one entry per contract) instead of by the current time, plus a test that proves an evicted PDF is collected. The same rule applies to a static event (a list of methods that other code adds itself to): the publisher (the class that owns the event) roots every subscriber, so a subscriber must unsubscribe when it goes.
I never call GC.Collect in application code. It forces an expensive full collection, pauses the app, and hides the object that is really holding the memory.
Check your answer: tick what you said
You said 0 of 10
Then add your example: UDC Finance bank-file import (invented example)
Situation: UDC's end-of-day bank file is about 180 MB. Our importer read it whole into one byte array, which is over the LOH limit, then split it into lines. Every run allocated a fresh 180 MB block, and when two runs overlapped the pod hit its memory limit.
What I did: read the file in 64 KB chunks instead. Each chunk rents a buffer from , a shared pool of arrays, and returns it in a finally block, so the same buffer is reused for the next chunk and the next run. No single big array is allocated any more.
Result: the LOH stays flat during an import, and the memory limit no longer decides when the job runs. A small test rents a buffer, returns it, rents again and checks it is the same array.
What I'd say: the LOH is for big objects, so the goal is to stop creating one per request. Rent a buffer, return it, and watch loh-size before and after; the fix is an allocation change, not a collector setting.
See it
Statement jobone request
Contract textsmall · gen 0
draft: gen 0
1/9A statement request starts. The small text objects land in gen 0, the cheap area cleaned often.The PDF is 130 KB. At 85,000 bytes or more an object goes on the LOH, which is part of gen 2.The contract text survives a gen 0 collection and moves to gen 1; then it moves to gen 2.The service keeps the PDF in a static cache. A static field is a root, so it counts as reachable.The request ends, so its local variables are gone. The static cache still points at the PDF.A gen 0 collection is quick and never touches gen 2 or the LOH. The PDF waits for a full collection.A full collection walks the roots. The cache leads to the PDF, so the PDF is reachable and survives.The fix: the cache holds at most 500 entries and drops the oldest. The PDF has no root left.The next full collection frees the PDF and its 130 KB. Memory falls; no more restarts.
In plain words
The managed heap is the memory .NET owns for the objects your code makes with .The garbage collector, or GC, is the cleaner: it finds the objects nothing can reach and frees them, so you never call free yourself. Like a cleaner who only empties the bins nobody is holding, it cannot take anything a live part of the program still points at.
A root is a live pointer the collector starts from: a static field, a local variable on the stack (the method's own short-lived space), a CPU register, or a handle the runtime holds. If a chain of references leads from a root to an object, that object is reachable and stays.
Generations are age bands. New objects start in gen 0, a small area cleaned often because most objects die young. A survivor moves to gen 1, and an object that survives again moves to gen 2. Checking only the young objects is cheap; checking gen 2 is expensive, so it happens rarely. Like sorting post by age: the new pile is checked every day, the archive every few months.
The large object heap (LOH) is the shelf for big items: an object of 85,000 bytes or more, header included, goes there. It belongs to gen 2, so only a full collection (a gen 2 pass that also sweeps the LOH) sweeps it, and it is not compacted by default, which means the gaps left by freed big objects can stay. A 4 MB array is on the LOH; a 40 KB one is not.
A leak in .NET is not a forgotten free; it is an object that is still reachable. The usual holders are a static field, a event (a list of methods that other code adds itself to), a cache with no bound, a running timer or a thread that never ends. The collector is right: something still points at the object.
A weak reference () points at an object without keeping it alive. After a collection it reports whether the object is still there. It is the honest way to test that a leak is fixed, because a normal reference in the test would keep the object alive by itself.
Finding a leak: first the trend, not a guess. shows the heap, gen 2 and LOH sizes (gen-2-size is the bytes in gen 2), allocation rate and time in GC for a running process. A heap dump (a snapshot of every object in the heap; collects one, and the file opens on Windows in Visual Studio or PerfView) shows the biggest types and the path from an object to its root. That path names the owner to fix.
Dispose is not GC. The collector frees managed memory; it does not close a connection, a file or a socket for you. Those implement , and using calls at the end of the block. The has the full picture; a finalizer (a method the GC calls just before freeing an object) is the late, unpredictable backup, not the plan.
Do not call GC.Collect to fix memory. It forces a full, blocking collection, which pauses the app, and the next request allocates the same objects again because the root is still there. Fix the owner, not the symptom.
Two collector modes. Workstation GC uses one heap and one collector thread, for a small or latency-sensitive process. Server GC gives each core its own heap and collector thread, faster on a big server but using more memory. The ASP.NET Core project template sets server GC; a console app defaults to workstation.
The senior answer · about 2 minutes
What the collector actually does
The managed heap is the memory .NET owns and tracks for objects made with . The garbage collector is a tracing collector: it starts from the roots, which are static fields, local variables on the stack, CPU registers and GC handles, marks everything it can reach, and reclaims the rest. That is why a reference cycle is not a leak on its own.
It is not reference counting. If two objects point at each other but nothing else points at them, neither is a root, so both are freed. In an interview I use that to separate someone who has read the docs from someone who has only heard "the GC frees unused memory".
Generations and the LOH
The heap is divided by age because most objects die young. A new object starts in gen 0. When gen 0 fills, a collection checks only gen 0: it is fast and frequent. Survivors are promoted to gen 1, and objects that survive again go to gen 2, which is collected rarely.
An object of 85,000 bytes or more, including its header, goes on the large object heap. The LOH is part of gen 2 and is swept only in a full collection. It is not compacted by default: freed gaps stay where they are, so a long-running process can fragment it. or streaming avoids allocating a new big buffer per request.
The runtime has two modes. Workstation GC uses one heap and suits a small process or a latency-sensitive one; server GC uses a heap and a thread per core for throughput. The ASP.NET Core project template sets server GC, while a console app defaults to workstation. A container also needs a memory limit the runtime can see, or it will happily grow past the pod's limit and be killed.
Our leak, and how we found it
On the BMW FS statement , release 4.2 added a static dictionary as a cache. The key was the contract id plus the current time in ticks, so every request was a miss, and nothing ever removed an entry. Each PDF was about 130 KB, so it landed on the LOH. After a night the dictionary held thousands of them.
The collector was doing its job: the static field is a root, every PDF was reachable, so a full collection freed none of them. The app looked healthy and the logs were clean; the only signal was memory climbing until the replica was killed and restarted. A restart "fixed" it for a few hours, which is the classic shape of a managed leak.
I watched the trend with :loh-size and gen-2-size climbed while the request rate was flat. Then a heap dump (collected with ; the file opens in Visual Studio on Windows) showed the biggest type was byte[], and the path to root went through the static dictionary's Files field. I reproduced it with 10,000 requests in a load test and a test, then shipped the bounded cache.
Fixing it without hiding it
The fix is to remove the root, not to force collections. The cache is keyed by contract id, holds at most 500 entries, and evicts the oldest. The PDF is also written to Blob storage (Azure's file store in the cloud), so a reprint does not need the cache at all. The cache is a singleton (one instance shared by the whole app), so a (which lets one request at a time through) guards its dictionary and queue across concurrent requests (or with SizeLimit, which is thread-safe). Memory settles at about 240 MB and the nightly restarts stop.
The second leak we fixed was a static event. A view subscribed in its constructor and nothing unsubscribed, so the publisher's static event rooted every view ever created. The view implements , unsubscribes in , and is created by the DI container (the built-in object factory that builds services and owns their lifetime) as a scoped service (one instance per request), so the request scope disposes it at the end.
I would not ship a change like this without a test that fails before the fix: a to the PDF, evict it, collect, and assert is false. For the event, the same shape: build the view, dispose it, collect, and assert the weak reference is dead.
What I left behind
An alert on gen-2-size and loh-size growth per hour, not just on CPU and errors, so a slow leak pages before the container is killed. A 10,000-request load test in (system integration test, the environment) that asserts memory plateaus. And a review rule: every static collection and every subscription has an owner and a bound, or it does not merge.
In the interview I keep it short: a leak in .NET is still reachable memory; generations and the LOH explain the cost; the fix is the root, and a weak reference proves it.
One statement request, every idea
The BMW FS statement in time order. Watch where the PDF lives, what the collection checks, and what keeps it alive.
Requestone render
Heapgen 0 and LOH
Cachestatic dictionary
GCfull only
pdf: LOH
text: gen 0
1/8A request starts. Small text objects land in gen 0; the 130 KB PDF lands on the LOH.Gen 0 fills. A gen 0 collection checks only the young objects; the LOH is not touched.The service keeps the PDF in the static cache. That field is a root, so the PDF is reachable.The request ends. Its local variables are gone, but the cache still points at the PDF.A full gen 2 collection runs. It is the only one that sweeps the LOH and gen 2.The walk starts at the cache. It reaches the PDF, so the PDF survives and memory stays high.The fix: the cache is bounded to 500 entries. The oldest leaves, and the PDF loses its root.The next full collection frees the PDF. Memory plateaus, and the replica stops restarting.
The story
Before
BMW FS customers get a monthly statement: a PDF with their payments and . Our statement service on Azure (Azure's service that runs a container for you) renders it from the contract data and used to cache the last few in memory, so a reprint did not re-render. Each PDF is about 130 KB, so it goes on the large object heap (LOH), the heap's shelf for objects of 85,000 bytes or more. In release 4.2 a developer replaced that small cache with a static (a key-value map that belongs to the class and lives as long as the app), keyed by (looked up by) contract id plus the current time in ticks (the time as a long number that changes on every call), and called it "a fast cache". A static field belongs to the class itself and lives for the whole process.
Statement servicerenders one PDF
pdf: 130 KB
1/4A request renders one statement PDF: about 130 KB of bytes, held in a local variable.It goes into the static cache. The request ends, but the static field still points at the PDF.The next request builds the same PDF. A new timestamp means a miss, so another entry.By morning the cache holds thousands of PDFs. Memory is at 912 MB of the replica's 1 GiB limit.
Why it had to change
A cache is only useful if a later request can hit it, and a key with the current time can never be hit: every request is new. Nothing removed an entry either, so the static dictionary grew for as long as the process lived. Each PDF was over 85,000 bytes, so it went on the large object heap, the expensive part of the heap. The garbage collector was not broken: the static field is a root (a live pointer the collector starts from), every PDF was still reachable from it (a root could still get to it), and the collector rightly freed none of them. The replica hit its memory limit, was killed, restarted with an empty cache, and the cycle began again. That nightly restart is what hid the bug.
How it works now
Here is what the runtime does with that heap, and why calling the collector more often would not have helped. New small objects start in gen 0. When gen 0 fills, a collection checks the roots and frees the unreachable young objects; the survivors move to gen 1. An object that survives again goes to gen 2, which is checked rarely. A big object, 85,000 bytes or more including its header, starts on the large object heap, and only a full gen 2 collection sweeps it. The walk always starts at the roots: the static cache, the request's local variables, and anything the runtime holds.
Statement jobone request
Static cachea GC root
GCfrees unreachable
pdf: 130 KB
1/4The job renders a 130 KB PDF and hands it to the cache.The key has a new timestamp, so every request adds an entry: 1, then 2, then thousands.A full collection starts at the roots. The static cache is a root, and it leads to every PDF.Every PDF is reachable, so the collector frees none of them. The heap only grows.
What we gained
The fix was to remove the root, not to call . The cache is keyed by contract id, holds at most 500 entries, and evicts the oldest; the PDF is also written to Blob storage (Azure's object store for files), so the cache is only a fast path for a recent reprint. A test keeps a weak reference to a PDF, evicts it, forces a collection and asserts the reference is dead. Memory settles around 240 MB, and the nightly restarts stop. The same rule covers a static event: the publisher roots every subscriber, so a view that subscribes must unsubscribe in .
In the lending API
The four marked lines: where a small object starts, where a big one goes, and how a weak reference proves a PDF was freed.
Only the key lines are shown; Deep walks the whole file.
Lending.sln · where this file sits
tests/Statements.UnitTests/
⋯ 7 lines in Deep⋯ 7 lines in Deep⋯ 5 lines in Deep
MemoryTests.cs · line 12 · 1 of 4 fails the test if the two values differ. Passes: a small new object is in gen 0.
The code, file by file
Three xUnit tests (xUnit is a test tool for .NET) that prove the generation rules and the leak test. Tap Next to walk the scene with the code.
Lending.sln · where these 2 files sit
tests/Statements.UnitTests/
src/Statements/
MemoryTests.cs · line 1 · 1 of 28Brings in and , the two runtime classes these tests use. is the namespace, the folder-like name a class lives under.
What they ask next
The follow-ups interviews really ask. Answer out loud, then tap to check.
ReportedHow do you find a memory leak in production?
I start with the trend, not a guess. on the live process shows gc-heap-size,gen-2-size,loh-size,alloc-rate and time-in-gc; a line that climbs while traffic is flat is the signature. Then I take two heap dumps half an hour apart with and compare the biggest types: the one that grew is the suspect. Collect runs on Linux and Windows, but the .gcdump file opens in Visual Studio or PerfView only on Windows, and collect triggers a gen 2 collection that can pause the app, so I take it from a replica that is out of the load balancer. The last step is the path to root: static field, event, cache or timer. That path names the code to fix.
ReportedWhy are there three generations, and not one?
Because most objects die young. A gen 0 collection only walks the newest, smallest area, so it is cheap enough to run often. An object that survives is likely to live longer, so it is promoted and checked less often: gen 1, then gen 2. The expensive full collection, which also sweeps the large object heap, runs rarely. The trade-off is memory: a promoted object takes space in an older generation until a collection of that generation runs.
ReportedWhat is the large object heap, and when does it hurt?
Objects of 85,000 bytes or more, including the object header, are allocated on the large object heap. It is part of gen 2, so it is swept only by a full collection, and it is not compacted by default: freed gaps stay, and a busy process can fragment. That makes big arrays, PDFs and very large strings expensive to churn. I stream instead of , and I rent buffers from when I need a big temporary array.
Answer, then open the next level. That is the order an interviewer digs in.
Reporteda candidate was asked Intervieweran interviewer asks it
ReportedHow do you find a memory leak in production?
I start with the trend, not a guess. on the live process shows gc-heap-size,gen-2-size,loh-size,alloc-rate and time-in-gc; a line that climbs while traffic is flat is the signature. Then I take two heap dumps half an hour apart with and compare the biggest types: the one that grew is the suspect. Collect runs on Linux and Windows, but the .gcdump file opens in Visual Studio or PerfView only on Windows, and collect triggers a gen 2 collection that can pause the app, so I take it from a replica that is out of the load balancer. The last step is the path to root: static field, event, cache or timer. That path names the code to fix.
What if the GC heap is flat but the process still grows?
Then the memory is outside the managed heap: native buffers (memory allocated outside the .NET heap), thread stacks, sockets or memory-mapped files (files mapped into the process's address space). I compare the process working set (the memory the OS shows for the whole process, not just the .NET heap) with the GC heap size. If the working set climbs and the GC heap does not, a heap dump will not show it; I look at handle counts (open file and socket handles), thread counts and the native libraries, and still helps separate CPU from memory.
Why not just raise the memory limit?
It buys hours and moves the cost to the platform. The leak still grows, the replica still restarts, and the limit is now higher for every healthy replica too. Raising it is a mitigation while we find the root, not a fix.
ReportedWhy are there three generations, and not one?
Because most objects die young. A gen 0 collection only walks the newest, smallest area, so it is cheap enough to run often. An object that survives is likely to live longer, so it is promoted and checked less often: gen 1, then gen 2. The expensive full collection, which also sweeps the large object heap, runs rarely. The trade-off is memory: a promoted object takes space in an older generation until a collection of that generation runs.
and exist, and tests use them to make behaviour predictable. Application code should not: a forced collection is a pause with no guarantee the object you care about is unreachable, and it usually hides the root instead of removing it.
ReportedWhat is the large object heap, and when does it hurt?
Objects of 85,000 bytes or more, including the object header, are allocated on the large object heap. It is part of gen 2, so it is swept only by a full collection, and it is not compacted by default: freed gaps stay, and a busy process can fragment. That makes big arrays, PDFs and very large strings expensive to churn. I stream instead of , and I rent buffers from when I need a big temporary array.
shows loh-size and alloc-rate; a capture shows allocations and GC pauses per request. I compare before and after with a load test, not with a stopwatch on one request.
A static event is the same shape as the cache: why does it leak?
Because a static event is a root too. The publisher keeps a delegate for every subscriber in its invocation list; a delegate holds the target object. If a request-scoped view subscribes in its constructor and never unsubscribes, the root keeps every view alive for the life of the process. The fix is to unsubscribe in and let the DI scope call it, or to use a weak event pattern (a subscription that does not root the subscriber) when the subscriber's lifetime is shorter than the publisher's.
Does the GC call Dispose for me?
No. The collector frees managed memory; it does not know that a connection, a file handle or a socket needs returning now. Types that own those implement , and using calls at the end of the block. A finalizer is a late backup that runs on its own thread at an unpredictable time, so it is not a plan. The has the full answer.
What is a finalizer, and why avoid it?
A finalizer is a method the GC calls before it frees an object, written with a tilde before the class name. It runs on the finalizer thread at an unpredictable time, and an object with a finalizer survives at least one extra collection while it waits. That delays reclamation, and the docs say it can promote the object to gen 1 or gen 2, with no ordering guarantee. Use for deterministic cleanup; keep a finalizer only as a safety net for a class that owns a raw handle, and suppress it when has run.
ReportedHow can you influence the GC?
Mostly by reducing allocations, not by tuning knobs. Remove boxing in hot loops (boxing wraps a value type like int in an object, which allocates), pool big buffers, stream instead of loading a whole file, and avoid a new per call. At the runtime level you choose workstation or server GC, set a memory limit the container respects, and can ask for LOH compaction when a one-off big allocation fragmented the heap. I do not call and I do not use (an API that asks the runtime to pause collections for a while) in a normal web app.
Server GC gives each core its own heap and collector thread, which raises throughput on a multi-core server at the cost of more memory. The ASP.NET Core project template sets it; a console app defaults to workstation. Workstation GC uses one heap and suits small processes or latency-sensitive desktop work. tells you which one you are in.
Production bugs, and how to tell them
Each one: what you see, why, the fix in code, and the line to say.
Every statement reprint added another 130 KB
Container Apps replica log, 02:14:
Replica statement-worker--7f9c restarted
Reason: memory limit reached (1 GiB)
(no exception in the app log)
Cause
was a static whose key was the contract id plus . Every request was a miss, and nothing removed an entry, so every rendered PDF stayed in the dictionary for the life of the process. A static field is a root, so a full collection found every PDF reachable. Each PDF was about 130 KB, over the 85,000-byte limit, so the growth landed on the LOH, which is swept only by a full collection.
Fix
Key by contract id, bound the cache to 500 entries, and evict the oldest, all under a because one singleton serves every request. The PDF is also written to Blob storage for reprints. A test keeps a to a PDF, fills the cache past its limit, forces a full collection and checks the reference is dead. The cache becomes an instance created once in DI, not a static field.
Before · StatementService.cs · line 1 · 1 of 7The service that renders a statement. It is created per request by the controller.
Say it
A static cache with a timestamp in the key is the worst of both worlds: it never hits and it never stops growing. The GC could not free the PDFs because the static field was a root. We keyed by contract, bounded the cache, and proved with a weak reference that an evicted PDF is collected.
Every rate import left a dead view behind
UDC portal, a week after release 1.9:
memory at 1.2 GB and rising, p95 (the 95th-percentile response time) up 400 ms
no exception in the app log
Cause
subscribed to the static event in its constructor. The portal created a new view for every request with new RateView(), and nothing unsubscribed. A static event holds a delegate for each subscriber in its invocation list, so the event was a root and every view ever created stayed alive. The rate feed imported every 15 minutes, which made the leak faster without causing an error.
Fix
implements and unsubscribes in . The endpoint gets the view from DI as a scoped service, so the scope disposes it at the end of the request. A test makes a view, disposes it, forces a collection and checks a is dead.
Diagram · step 1The request creates a RateView, whose constructor subscribes. The event now holds a delegate to it.
Static eventRateImported · a root
RateView (now)this request
subscribers: 1
RateFeed.cs · line 1 · 1 of 5: one feed for the whole process. The event below lives as long as the app.
Say it
A static event is a root: the publisher's invocation list holds every subscriber. The view subscribed in its constructor and nothing ever unsubscribed, so the GC could not free any view. We made the view disposable and let the request scope dispose it, and a weak-reference test proves it is collected.
Production incident: The statement portal restarted twice before breakfast
P2 · serious, has a workaroundBMW FSlatent: hidden for weeksBMW FS statement portal, .NET 8 on Azure Container Apps
Composed, not a real event at BMW Financial Services. The mechanism is real C#, checked by runs on .NET 8 and 10: a static dictionary is a root, so a full GC keeps every value it points at; a PDF of about 130 KB sits on the LOH; a WeakReference turns false only after the last strong reference is gone. Times, counts, names, ticket and PR numbers are composed.
The story in 5 lines
On Wed 14 Oct the BMW FS statement portal restarted twice before 08:05. Statement downloads timed out for about 40 minutes, and the ops lead asked whether release 4.2 was the cause.
Release 4.2, out Tue 13 Oct at 21:30 IST, replaced a small in-memory cache with a static one, to stop re-rendering statements. The key included , so every request made a new key, and the static dictionary was never trimmed.
Each 130 KB PDF is on the LOH, and the static dictionary is a root, so a full GC could not free any of them. At 02:14 the replica passed 1 GiB and was killed; the second restart came at 08:02.
A gcdump showed 6,912 byte arrays of 130,000 bytes, 98% of the LOH, all rooted at StatementPdfCache.Files. The fix was a singleton cache (one instance for the whole app) bounded at 500 entries, oldest-first eviction, and the contract id as the key.
By 15:30 managed memory was 240 MB, and there was no restart in the next 24 hours. The added a memory alert, a load run for caches, and an eviction test with .
Who's who (4 people)
You
.NET developer on the vendor team. Finds the cause, writes the fix and the test.
Tech lead
Vendor senior. Reviews the change, runs the postmortem.
L2 pod (second-line support)
Vendor support. First look, collects restart times, reruns statements.
BMW FS ops lead
Owns the portal's availability. Decides who is told what, and when.
Tap a step. The map lights the place where it happens.
Worker1 GiB
Static cacheno bound
LOH130 KB
Opsrestarts
2 restarts
912 MB managed
1/4 Detect
Detect: two restarts before 08:05. Managed memory is 912 MB, with 874 MB on the LOH.
Wed 14 Oct, 08:05 ISTL2 pod, then you
What: BMW FS ops reported statement downloads timing out since about 07:50. showed two replica restarts, at 02:14 and 08:02, both at the 1 GiB memory limit; p95 (95th-percentile response time) was 4.1 s.
Why it hid: there was no exception in the app log and no 5xx (server error) spike. Requests queued while the replica was out of memory, then failed when it restarted. CPU stayed under 20%.
How we saw it: the surviving replica's health page showed managed memory 912 MB, generation 2 at 890 MB and the LOH at 874 MB. A healthy replica after release 4.2 started at about 175 MB.
First move: we stopped the second replica from taking traffic and pinned the release, so the portal stayed up while we looked.
Azure CLI: replicas and restarts (composed output)
az containerapp replica list \
-g bmw-fs-prod -n stmt-portal \
--query "[].{replica:name,restarts:
properties.containers[0].restartCount}" \
-o table
replica restarts
stmt-portal--7f9c 2
In theory
An alert on managed memory above 700 MB for 10 minutes, and on more than one restart in an hour.
In practice
We watched CPU and 5xx only. A memory leak that ends in a restart never shows as a 5xx, so nobody was paged until users called.
Try it yourself · 10 minutes, on your Mac
dotnet new console -n LeakLab. Add static Dictionary<string, byte[]> Files = new(); and a method whose key is contractId + ":" + DateTime.UtcNow.Ticks.
In a loop, call Keep($"BMW-{i}", new byte[130_000]) 2,000 times. Print KB, then and print again: it stays high.
Change the key to the contract id alone and add a 500-entry bound with a . Run the same loop with $"BMW-{i}": memory levels off near 65 MB.
Add a test: allocate the PDF in a [MethodImpl(MethodImplOptions.NoInlining)] helper that returns its , keep 3 PDFs in a 2-entry cache, collect, and assert the first PDF's weak reference is not alive. The helper matters: a local in the test method stays a hidden root in a Debug build, and the test then fails even when the cache is right.
You should see
A static field is a root; a cache needs a bound, a key that can hit, and a test that proves an evicted value dies.
The senior move
"A memory leak in .NET is almost always an ownership bug, not a collector bug: something still points at the object. I find the path to the root in a dump, then remove the ownership or bound it, and prove the fix with a WeakReference test instead of a bigger memory limit."
SourcesSourced: a static field is a GC root and keeps everything it reaches alive; objects of 85,000 bytes or more go on the LOH and are collected only in a gen-2 collection (Microsoft .NET garbage collection docs, fundamentals and large object heap); captures a managed heap snapshot, and Visual Studio shows the path to root; is false after the target is collected (API docs). Checked with real runs on .NET 8 and .NET 10: LOH boundary, static dictionary keeping a PDF alive, WeakReference turning false after Remove, and a bounded cache evicting the oldest entry. Composed: BMW FS's systems, dates, counts, names, ticket and numbers.
Traps
Don't say"The garbage collector will clean it up eventually."SayOnly if nothing can reach it. A static field, a static event, an unbounded cache, a running timer or a live thread is a root, and the collector is right to keep the object. For a connection or a file handle, the collector does not call at all; using does.
Don't say"Memory is high, so I will call GC.Collect."SayA forced full collection pauses the app and frees only what is already unreachable. If memory comes back, the object was unreachable and the collector would have got it; if it does not, the root is still there and you have added a pause. I find the root instead.
Don't say"A leak means someone forgot to free memory."SayIn .NET the memory is managed, so there is no free to forget. A leak is a reference that should have gone: a cache key that changes every time, an event subscription never removed, a singleton that captures a request object. The question is always "what still points at this?"
Traps
What should you say instead? Tap to check.
Don't say"The garbage collector will clean it up eventually."
Say instead
Only if nothing can reach it. A static field, a static event, an unbounded cache, a running timer or a live thread is a root, and the collector is right to keep the object. For a connection or a file handle, the collector does not call at all; using does.
Don't say"Memory is high, so I will call GC.Collect."
Say instead
A forced full collection pauses the app and frees only what is already unreachable. If memory comes back, the object was unreachable and the collector would have got it; if it does not, the root is still there and you have added a pause. I find the root instead.
Don't say"A leak means someone forgot to free memory."
Say instead
In .NET the memory is managed, so there is no free to forget. A leak is a reference that should have gone: a cache key that changes every time, an event subscription never removed, a singleton that captures a request object. The question is always "what still points at this?"
Live coding
Easy first, then harder. Set a timer and type it before you look.
Task 1
Question index: Musteasy · 10 minutes
Predict what this program prints, line by line, before you run it. Then say which objects are on the large object heap and why the held array behaves differently from the weak one.
One file. MakeWeak builds an array, returns a weak reference to it, and lets the array go out of scope.
Lending.sln · where this file sits
Stuck? Hints
Which objects are over 85,000 bytes?
After MakeWeak returns, is the array reachable from anywhere?
Does held.Length after the collection tell the JIT the array is still needed?
Show a solution
Lending.sln · where this file sits
Output (real run) · line 1 · 1 of 6small is a new object with no fields, so it is in gen 0.
What the interviewer checks
0: a new small object starts in gen 0.
2: 100,000 bytes is on the LOH, which reports gen 2.
False: the array from MakeWeak is unreachable after the method returns, so the collection frees it.
True: held is still a live local, so it is a root and survives.
1000: reading held.Length after the collection confirms the array was still in use.
True: it is still held, so it was never freed.
Can say which objects are roots and which are unreachable.
Task 2
Question index: Mustmedium · 20 minutes
The portal creates a new for every request, and the view subscribes to a static event. Find why memory grows, then make the view disposable, unsubscribe in , and get it from DI as a scoped service. Prove the fix with a test that uses a weak reference.
The publisher, the leaking view and the endpoint that creates it. The fix is in the view and in the endpoint's registration.
Lending.sln · where these 2 files sit
src/Portal/
Stuck? Hints
What keeps a subscriber alive after the request ends?
Where does the event keep its list of delegates?
Who calls at the end of a request?
Why does the test need a weak reference instead of a normal one?
Show a solution
Lending.sln · where these 3 files sit
src/Portal/
tests/Portal.UnitTests/
Diagram · step 5After: Dispose unsubscribes with -=, so the event drops the delegate. The view has no root.
Static eventRateImported · a root
RateView (now)this request
subscribers: 1
RateView.cs · line 1 · 1 of 4 promises a method. The DI scope calls it when the request ends.
What the interviewer checks
Names the static event's invocation list as the root.
Makes implement and removes the delegate with -= in .
Registers the view as scoped, so the request scope disposes it.
The test uses and asserts it is dead after and a collection.
Says a normal reference in the test would keep the view alive and make the test lie.
Task 3
Question index: Musthard · 25 minutes
A nightly statement job caches every rendered PDF in a static dictionary and runs out of memory. Replace the dictionary with a bounded cache: key by contract id, keep the newest 500 entries, evict the oldest, and add a test that proves an evicted PDF is collected. Then say what you would alert on in production.
The leaking cache and the service that fills it. The test file is the one you have to make pass.
Lending.sln · where these 2 files sit
src/Statements/
tests/Statements.UnitTests/
Stuck? Hints
A dictionary has no eviction policy by itself; what structure remembers insertion order?
What should happen when the same contract is requested twice?
Why is in the key a bug?
What does a weak reference prove that a normal one cannot?
Show a solution
Lending.sln · where these 2 files sit
src/Statements/
tests/Statements.UnitTests/
Diagram · step 1The test keeps a weak reference to BMW-1, a 130 KB PDF, then hands it to the cache.
Testthree requests
Bounded cachemax two
HeapPDFs
Keep(BMW-1)
PDF in dictionary
StatementPdfCache.cs · line 1 · 1 of 6Not static: make one instance and register it. If that one instance is shared, add a around the check-evict-add. Tests can then build their own isolated cache.
What the interviewer checks
The key is stable: contract id only.
The cache has a maximum; the oldest entry is evicted when it is full.
A repeat request hits the existing entry instead of adding a copy.
The test is green: the evicted PDF is not alive after the collection.
Says the alert is on gen-2-size and loh-size growth, plus a load test that asserts a plateau.
With AI tools
AI assistants are quick to suggest "add a cache" or "call ", and both are how this kind of bug starts or hides. When I review AI-written memory code I check four things: every static collection has a bound and a key that can hit; every subscription has a matching -= in ; there is no in a request path; and the fix has a test, not just a claim. For a heap question I ask the tool to walk from the biggest type to its root, then I verify the path in a dump myself.