DEV Community

kirandeepjassal-crypto
kirandeepjassal-crypto

Posted on

10 Hidden Memory Leaks in ASP.NET Core — From 2.1 GB to 380 MB (Real Production

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)
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Watch the walkthrough

Go deeper

The full written guide has every code sample and the production checklist:

https://prepstack.co.in/blog/hidden-memory-leaks-aspnet-core-applications-causes-fixes-production-metrics


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)