Skip to main content

Command Palette

Search for a command to run...

ASP.NET Core Memory Leak in Background Services: Root Cause and Fix

Updated
12 min read

Memory growth that never stops is one of the most disorienting production problems a .NET team can face. The service looks fine in development, passes every load test, and then steadily climbs toward an OOM restart in production — often at the worst possible time. When the culprit is a background service, the root cause is almost always the same: a dependency lifetime mismatch that turns short-lived services into accidental singletons.

For developers who want to see the complete solution wired together inside a real production API — with DI scope management, proper disposal patterns, and a working BackgroundService implementation — Chapter 12 of the Zero to Production course covers exactly this, alongside background job scheduling and the Outbox pattern.

ASP.NET Core Web API: Zero to Production

The deeper you go into this topic, the more patterns emerge. The production-ready implementations — including edge cases around scope disposal, cancellation, and restart behaviour — are covered on Patreon, with annotated source code you can drop straight into your own services.

Why Background Services Are Memory Leak Hotspots

ASP.NET Core's dependency injection system creates a new scope for every HTTP request. Scoped services — like DbContext, repository classes, and unit-of-work implementations — are created at the start of the request and disposed at the end. This lifecycle is automatic, predictable, and safe.

Background services registered with AddHostedService or AddSingleton<IHostedService> are different. They live for the entire lifetime of the application. The DI container registers them in the root scope, and the root scope only ends when the host shuts down. There is no request to define a scope boundary.

This matters because of what happens when a background service directly injects a scoped or transient service.

The Captive Dependency Problem

When a long-lived service (singleton or hosted service) directly injects a shorter-lived service, the shorter-lived service gets "captured" by the longer-lived one. It cannot be released by the DI container until its owner is released — which, for a hosted service, means never during normal operation.

The result: every transient DbContext, every scoped repository, every unit-of-work instance that gets injected at startup remains in memory for the entire application lifetime. Over hours and days, the change tracker inside each DbContext accumulates tracked entities. Each polling interval creates more objects that cannot be collected. Memory climbs.

The ASP.NET Core DI container actually catches the obvious case at startup — injecting a scoped service directly into a hosted service will throw an InvalidOperationException in development (when scope validation is enabled, which is the default). But transient services injected into singletons slip through the default validation, and they produce the same memory growth without the immediate error signal.

The fix is to never inject short-lived services directly into a background service. Instead, use the scope factory pattern described below.

How to Diagnose It in Production

Before fixing, confirm the diagnosis. Three signals point to captive dependencies as the cause:

Memory grows continuously — not spiky (which suggests LOH fragmentation) but a steady, linear climb that correlates with the number of times the background service loop has executed. Each iteration adds a small amount that never goes away.

The GC cannot reclaim it — Gen 2 collections happen, memory temporarily dips, then resumes climbing. Objects are rooted by the DI container's root scope and cannot be collected.

dotnet-counters or Application Insights shows stable managed heap size with growing working set — or alternatively, a profiler like dotMemory or Visual Studio's Memory Profiler shows thousands of instances of your DbContext or service class with the root path leading through the DI container root scope.

To confirm quickly, run dotnet-counters monitor --process-id <pid> and watch gc-heap-size over a few minutes while the service is polling. A steady upward trend with no corresponding deallocations after GC is the telltale sign.

What Is the Correct Pattern?

Why Can't You Just Inject IServiceProvider?

Some teams try to inject IServiceProvider directly into the background service and call GetRequiredService on it. This looks like it creates a new service instance, but it actually resolves from the root provider — the same root scope that keeps objects alive forever. The problem is not solved; it's hidden behind a layer of indirection.

The Right Fix: IServiceScopeFactory

The correct pattern is to inject IServiceScopeFactory and create an explicit scope inside the service's execution loop. The scope is created before the work begins and disposed when the unit of work completes — exactly mirroring what ASP.NET Core does automatically per HTTP request.

IServiceScopeFactory is safe to inject into a singleton or background service because it is itself registered as a singleton by the framework. Calling CreateScope() on it creates a child scope with its own lifetime, independent of the root scope. Services resolved within that child scope — including DbContext — are released when the scope is disposed.

The key discipline: create the scope inside the polling loop, resolve what you need, do the work, and dispose the scope before the next iteration. Never store the resolved service in a field on the background service class. Never resolve outside the loop. One iteration, one scope.

This pattern has three effects: scoped services are released after every iteration, the DbContext change tracker is cleared automatically on disposal, and concurrency bugs caused by a single shared DbContext across multiple iterations become impossible.

Transient Services Inside Background Services

The same principle applies to transient services. If your background service directly injects a transient service (via constructor injection), that transient instance is captured for the application lifetime. The fix is identical: inject IServiceScopeFactory, resolve the transient service from the child scope per iteration, and dispose the scope when done.

Does ValidateScopes Save You?

Partially. builder.Services.Configure<ServiceProviderOptions>(options => options.ValidateScopes = true) — which is on by default in the development environment — catches direct injection of scoped services into singletons at startup. It does not catch transient services, and it only runs when the application starts, so it catches misconfigurations but not future ones introduced by team members who are unaware of the constraint.

ValidateScopes = true in production is worth considering if your team has had recurring DI lifetime issues, but it does add a small startup overhead and can break services that were accidentally relying on captive resolution. Enable it in staging first.

The EF Core Specific Risk

DbContext registered with AddDbContext is scoped by default. This is correct for web requests. But in background services, it creates an additional concern beyond memory: stale data.

If a DbContext is captured for the application lifetime, its change tracker accumulates tracked entities from every operation. Queries that should return fresh data from the database instead return cached snapshots. Updates silently overwrite concurrent changes from other parts of the system. These bugs are far harder to trace than memory growth, and they can manifest as data correctness issues that only appear under load or after the service has been running for hours.

The IServiceScopeFactory pattern eliminates this entirely — each scope gets a fresh DbContext with an empty change tracker.

An alternative for read-heavy background services is IDbContextFactory<TContext>, which creates DbContext instances on demand without scope semantics. This is appropriate when you want full control over DbContext lifetime without the DI scope overhead. The tradeoff is that you take on responsibility for disposal — the factory gives you the instance but does not own it.

For more on EF Core production issues like this, the EF Core Connection Pool Exhaustion article covers related resource leak patterns.

How to Prevent It from Recurring

Three practices together close the loop:

Enable scope validation in development — this catches the most common mistake (direct scoped injection into singletons) before it reaches production. It ships as the default for the development environment; make sure your team has not disabled it.

Code review rule: no constructor-injected scoped or transient services in BackgroundService subclassesIServiceScopeFactory and ILogger<T> are the only acceptable constructor-injected dependencies. Everything else should be resolved per-iteration from the child scope. This rule is easy to enforce in a code review checklist.

Memory monitoring with alerting — add a metric for working set or managed heap size to your observability stack and alert when it crosses a threshold or grows beyond a defined rate. Catching this in staging is far cheaper than debugging it in production at 3 AM.

If you've hit similar issues with the DI container itself, the article on Cannot Resolve Scoped Service From Root Provider covers the related startup exception that accompanies improper scope injection.

What About Static State and Event Handlers?

Captive dependencies through DI are the most common cause, but two other background-service-specific patterns also produce memory leaks worth knowing:

Static collections — any static List<T>, static Dictionary<K,V>, or static cache in a class touched by your background service will grow unboundedly if items are added but never evicted. Static fields live in the high-frequency root and are never collected. The fix is explicit eviction logic or a bounded cache like IMemoryCache with expiry policies.

Unsubscribed event handlers — if your background service subscribes to events on domain objects, IHostApplicationLifetime, or third-party clients, and never unsubscribes, those event handler delegates root the subscriber. The GC cannot collect anything the delegate refers to. Always unsubscribe in StopAsync or in the IDisposable.Dispose implementation of the hosted service.

These are secondary compared to the DI lifetime mismatch, but they compound it in real applications.

Production Restart as a Temporary Measure

When the memory leak is actively causing production issues, restarting is a legitimate short-term response. Kubernetes and App Service both support liveness probes that trigger restarts when memory crosses a threshold — this is not a fix, but it contains the damage while the root cause is addressed.

Configure the liveness probe to restart before the service hits OOM, not after. An OOM kill is abrupt and may lose in-flight work; a graceful restart triggered by a memory threshold allows StopAsync to complete cleanly. Document the restart threshold so your team knows it is a mitigation, not a solution, and set a deadline for the actual fix.


☕ If this saved you a production incident, consider buying us a coffee — it keeps the deep-dives coming.

FAQ

Why does a hosted service in ASP.NET Core cause memory leaks but a regular controller does not? Controllers are resolved per HTTP request within a scoped DI lifetime. The scope is created at the start of the request and disposed at the end, which releases all scoped and transient services. Hosted services are singletons — they live for the entire application lifetime with no automatic scope boundary. Any service they hold onto shares that lifetime, which is why direct injection of shorter-lived services produces memory that never returns to the GC.

Does using using var scope = factory.CreateScope() guarantee the scope is disposed? Yes. IServiceScope implements IDisposable, so wrapping CreateScope() in a using block ensures disposal even if an exception is thrown inside the loop. This is the correct pattern — it mirrors how ASP.NET Core manages request scopes internally. Do not store the scope in a class field; create and dispose it within the same method.

Is it safe to call IServiceScopeFactory.CreateScope() concurrently from multiple threads? Yes. IServiceScopeFactory is thread-safe. Each call to CreateScope() returns an independent scope and its own set of resolved services. The scopes do not share state. What is not thread-safe is resolving the same DbContext instance across multiple threads — but that problem is prevented by the per-iteration scope pattern, since each concurrent operation gets its own DbContext.

Can I inject DbContext directly into a BackgroundService if I register it as singleton? Technically possible, but strongly inadvisable. A singleton DbContext is shared across all iterations, all threads, and the entire application lifetime. The change tracker accumulates entities indefinitely, queries return stale data, and concurrent iterations produce race conditions. DbContext is explicitly designed for short-lived use. Registering it as singleton to work around the DI lifetime constraint is trading a memory leak for correctness bugs.

How is IDbContextFactory<TContext> different from IServiceScopeFactory for background services? IDbContextFactory<TContext> creates DbContext instances on demand, giving you full control over their lifetime without scope semantics. It is registered as a singleton and safe to inject into a background service. The difference: with IServiceScopeFactory, the entire scope is disposed together — all services within it. With IDbContextFactory, you own the DbContext and must call Dispose() yourself. For background services that only need database access, either works; for services that also use other scoped services (repositories, unit of work), the scope factory is simpler.

Will scope validation in production catch this kind of memory leak? Scope validation (ValidateScopes = true) catches scoped services injected directly into singletons — it throws at startup rather than leaking silently. It does not catch transient services captured by singletons, which also leak. It also has no effect on already-running production deployments — it validates at startup only. Memory profiling and heap growth monitoring are the tools for catching leaks in live production environments.

What is the fastest way to confirm a memory leak is coming from a background service and not somewhere else? Temporarily disable the background service (set a feature flag or comment out AddHostedService) and observe whether memory growth stops. If memory stabilises, the background service is the source. Then re-enable it with the correct scope pattern. This binary test takes minutes and gives a high-confidence signal before investing time in profiler analysis.

More from this blog

C

Coding Droplets

203 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.