Hamza.
← blog

Sep 15, 2025 · 2 min read

How I cut memory leaks in a multi-tenant .NET SaaS

#.NET #Performance #DI
Share

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.