10 Hidden Memory Leaks in ASP.NET Core — From 2.1 GB to 380 MB (Real Production Metrics)
🎥 Prefer to watch? I cover this end-to-end in a 2-3 min video here: https://youtu.be/05UAzS9LCQQ
2.1 GB to 380 MB Without Changing Architecture
Mattrx — six ASP.NET Core 9 instances, no architectural changes — went from 2.1 gigabytes of working set per instance down to 380 megabytes. Eight OOM-driven restarts per week became zero. Tail latency p99 dropped from 820 milliseconds to 180. The cause: ten boring leaks.
Mattrx production — same code, same SQL:
Working set 2.1 GB → 380 MB (−82%)
OOM restarts 8 / week → 0 (90 days)
GC Gen2 p99 240 ms → 35 ms
% Time in GC 18% → 4%
p99 latency 820 ms → 180 ms
Cost saved ~$420 / month (P2v3 → P1v3)
The Mental Model — Leaks Are References You Forgot
A leak in .NET is not a runtime myth. The garbage collector cannot free an object as long as something reachable still references it. A leak is a reference you forgot — a singleton holding a scope, a static event, a cache without a limit. Find the root. Cut it. Memory recovers.
// "Leak" in .NET = unreachable from your intent,
// but still reachable from a GC ROOT.
//
// GC roots: static fields, running threads,
// strong-rooted handles, the singleton graph.
//
// Fix the rooting. Memory follows.
Leak #1 — Singleton Captures a Scoped DbContext
A singleton constructor takes a DbContext. DI compiles, the app boots, and the first DbContext lives forever — pinned by the singleton. Three hundred and forty megabytes per instance, gone. The fix: inject IServiceScopeFactory and create a fresh scope per call.
// LEAK — singleton captures a scoped DbContext
class CampaignCache(AppDb db) { /* singleton */ }
// FIX — resolve a fresh scope per call
class CampaignCache(IServiceScopeFactory scopes)
{
public async Task RefreshAsync()
{
using var s = scopes.CreateScope();
var db = s.ServiceProvider.GetRequiredService<AppDb>();
/* use db here, then dispose with the scope */
}
}
// −340 MB / instance.
Leak #4 — EF Core Change-Tracker Bloat
A long-lived DbContext loads twenty thousand entities a day for a dashboard query. The change tracker hangs on to every one — three hundred and ten megabytes per instance. The fix is two words: AsNoTracking on read paths. Or just keep the DbContext scoped per request, the way ASP.NET Core wires it by default.
// LEAK — long-lived ctx, no AsNoTracking
var rows = await db.Campaigns
.Include(c => c.Reports)
.ToListAsync(); // 20k entities pinned
// FIX — read-only path stays read-only
var rows = await db.Campaigns.AsNoTracking()
.Include(c => c.Reports)
.ToListAsync();
// −310 MB / instance.
Watch the walkthrough
Go deeper
The full written guide has every code sample and the production checklist:
Which part have you actually hit in production? Drop a war story in the comments — I read every one.
If this saved you time, hit the ❤️ and the bookmark — that's what tells DEV's algorithm to show it to more devs.
Top comments (0)