Sep 15, 2025 · 2 min read
How I cut memory leaks in a multi-tenant .NET SaaS
Memory that only ever climbs is one of the most unsettling failure modes in a long-running service. It doesn't crash on the happy path, it doesn't show up in a quick load test — it just grows until the process gets recycled and the cycle starts again.
The symptom
On a multi-tenant .NET SaaS, working set rose steadily under normal traffic. Nothing in the logs, no obvious leak of unmanaged handles — just a slow, monotonic increase that forced periodic recycles to stay healthy.
The cause: a captive dependency
The culprit was a classic captive dependency — a long-lived service holding a reference to something that was meant to be short-lived:
// A singleton that captured a per-request dependency
services.AddSingleton<TenantCache>(); // lives for the whole app
services.AddScoped<ITenantContext>(/* ... */); // meant to live per request
// TenantCache took ITenantContext in its constructor,
// so the very first request's scope was pinned in memory forever.
Because the singleton was resolved once, it froze the first request's scope — and everything that scope transitively referenced — for the lifetime of the process. Multiply that across tenants and the graph never gets collected.
The fix
Don't capture scoped state in a singleton. Resolve it per operation instead:
services.AddSingleton<TenantCache>();
// Inside TenantCache, take IServiceScopeFactory and create a scope per use:
using var scope = _scopeFactory.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<ITenantContext>();
The rule
A service may only depend on things that live at least as long as it does. Singletons can depend on singletons; scoped can depend on scoped or singleton; never the other way around. Turning on DI scope validation in development surfaces most of these before they ever ship.
The leak was a one-line registration mistake — but the discipline behind it is the whole point.