.NET worker services are commonly used for background processing tasks such as consuming messages, synchronizing data, generating reports, and processing scheduled jobs.
Unlike a short-lived web request, a worker service may run continuously for days or months. Even a small memory management problem can become serious when the application handles thousands of operations.
For example, a worker may use only 200 MB of memory when it starts. After several days, its memory usage may increase to 1 GB or more. Eventually, the application may become slow, trigger garbage collections more frequently, or terminate because the container has reached its memory limit.
The difficult part is identifying why memory keeps increasing. Sometimes the application has a genuine memory leak. In other cases, it retains objects unnecessarily, caches too much data, or creates large temporary allocations.
This article explains how to identify and fix memory leaks in long-running .NET worker services using C#, .NET diagnostics, and practical monitoring techniques.
Understand Memory Leaks in .NET
.NET uses a managed memory system. The garbage collector automatically identifies and releases objects that are no longer reachable.
However, garbage collection does not mean that every unused object is immediately removed.
An object remains in memory as long as it is reachable through an active reference.
Consider this example:
public class OrderProcessor
{
private readonly List<Order> _processedOrders = new();
public void Process(Order order)
{
_processedOrders.Add(order);
// Process the order
}
}
The list keeps a reference to every processed order.
Even after an order has been processed, the garbage collector cannot remove it because the list still references it.
If the worker processes thousands of orders, the list continues growing.
This is a common example of a managed memory leak caused by an application retaining objects longer than necessary.
Other common causes include:
Static collections that grow continuously.
Event handlers that are never unsubscribed.
Long-lived services holding large object graphs.
Unbounded caches.
Undisposed streams and other unmanaged resources.
Background tasks that retain references.
Large temporary allocations during processing.
Monitor Memory Usage Before Investigating
Before changing code, establish whether memory usage is actually increasing.
A worker service should expose basic runtime information, including:
You can use the .NET runtime APIs to collect basic information.
using System.Diagnostics;
public class MemoryMonitor
{
private readonly ILogger<MemoryMonitor> _logger;
public MemoryMonitor(
ILogger<MemoryMonitor> logger)
{
_logger = logger;
}
public void LogMemoryUsage()
{
var process = Process.GetCurrentProcess();
var managedMemory = GC.GetTotalMemory(false);
_logger.LogInformation(
"Working Set: {WorkingSetMB} MB, " +
"Managed Memory: {ManagedMemoryMB} MB",
process.WorkingSet64 / 1024 / 1024,
managedMemory / 1024 / 1024);
}
}
The working set represents physical memory currently used by the process. Managed memory represents memory occupied by objects tracked by the garbage collector.
These values are different.
For example, working set memory may remain high even after the garbage collector releases objects because the runtime can retain memory for future allocations.
Do not diagnose a leak from a single memory reading. Monitor memory over time and compare it with the number of operations processed.
A steadily increasing managed heap after comparable workloads is a stronger indication of object retention.
Use dotnet-counters to Find Memory Growth
The .NET diagnostic tools provide useful information about running applications.
Install the tools if they are not already available:
dotnet tool install --global dotnet-counters
Find the running process:
dotnet-counters ps
Monitor runtime counters:
dotnet-counters monitor --process-id 12345
Replace 12345 with the actual process ID.
The output includes runtime metrics such as:
For example, suppose a worker processes 10,000 messages.
During the first few minutes, heap size remains around 250 MB. After several hours, it reaches 900 MB and continues increasing.
This suggests that some objects may be retained between processing operations.
However, allocation rate and heap size should be interpreted together. A high allocation rate can be normal for a busy worker if objects are short-lived and collected successfully.
Capture a Memory Dump
Runtime counters help identify memory growth, but they do not show exactly which objects are consuming memory.
For that, capture a memory dump.
Install the diagnostic tool:
dotnet tool install --global dotnet-dump
Find the application process and collect a dump:
dotnet-dump collect --process-id 12345
Open the dump:
dotnet-dump analyze <dump-file>
Inside the analysis console, use commands such as:
dumpheap -stat
This displays object types and their total memory usage.
To inspect instances of a particular type:
dumpheap -type Order
You can then inspect references to an object using:
gcroot <object-address>
This helps identify why an object is still reachable.
For example, the output may show that thousands of Order objects are referenced by a static collection inside a singleton service.
That gives you a clear starting point for finding the code responsible for retaining them.
Memory dumps can contain sensitive information such as customer records, access tokens, and application data. Store them securely and restrict access to authorized developers.
Fix Unbounded Collections
Let's return to the earlier example.
The worker stores every processed order in a list:
public class OrderProcessor
{
private readonly List<Order> _processedOrders = new();
public void Process(Order order)
{
_processedOrders.Add(order);
}
}
The collection grows indefinitely.
If the application only needs to track the latest processed orders, use a bounded collection instead.
public class OrderProcessor
{
private const int MaximumItems = 100;
private readonly Queue<Order> _recentOrders = new();
public void Process(Order order)
{
_recentOrders.Enqueue(order);
while (_recentOrders.Count > MaximumItems)
{
_recentOrders.Dequeue();
}
}
}
This limits the collection to the latest 100 orders.
However, the best solution may be to remove the collection entirely if the application does not need it.
For operational history, use a database or logging system rather than keeping an unlimited collection in application memory.
Avoid Event Handler Memory Leaks
Event subscriptions can also prevent objects from being collected.
Consider a service that subscribes to an event:
public class OrderNotificationService
{
public OrderNotificationService(
OrderEventPublisher publisher)
{
publisher.OrderCreated += OnOrderCreated;
}
private void OnOrderCreated(
object? sender,
EventArgs e)
{
// Handle notification
}
}
If the publisher is a singleton and the notification service is created repeatedly, the publisher may retain references to old service instances through its event handlers.
The objects cannot be collected while those references remain.
Unsubscribe when the service is no longer needed:
public class OrderNotificationService : IDisposable
{
private readonly OrderEventPublisher _publisher;
public OrderNotificationService(
OrderEventPublisher publisher)
{
_publisher = publisher;
_publisher.OrderCreated += OnOrderCreated;
}
private void OnOrderCreated(
object? sender,
EventArgs e)
{
// Handle notification
}
public void Dispose()
{
_publisher.OrderCreated -= OnOrderCreated;
}
}
In dependency-injection applications, also make sure that service lifetimes are correct.
A singleton should not retain references to scoped services beyond their intended lifetime.
Dispose Resources Properly
Managed memory is only one part of memory management.
Streams, database connections, file handles, and other resources may use unmanaged memory or operating system resources.
Use using or await using to ensure that disposable resources are released.
For example:
public async Task ProcessFileAsync(
string filePath,
CancellationToken cancellationToken)
{
await using var stream = new FileStream(
filePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 81920,
useAsync: true);
using var reader = new StreamReader(stream);
var content = await reader.ReadToEndAsync(
cancellationToken);
// Process the file content
}
For large files, avoid reading the entire file into memory when it is not necessary. Process the data in smaller chunks or line by line.
Also, follow the disposal rules of the libraries you use. For example, do not dispose an HTTP client obtained from IHttpClientFactory after every request. The factory manages the underlying handler lifecycle.
Avoid Creating Large Objects Unnecessarily
The Large Object Heap stores large managed objects, including arrays and strings that meet the runtime's large-object allocation threshold.
Repeatedly allocating large arrays can increase memory pressure.
Consider this example:
public byte[] ProcessLargeData()
{
var buffer = new byte[10 * 1024 * 1024];
// Process data
return buffer;
}
A worker that executes this method repeatedly can create significant allocation pressure.
When appropriate, use ArrayPool<T> to reuse buffers:
using System.Buffers;
public void ProcessData()
{
var buffer = ArrayPool<byte>.Shared.Rent(
1024 * 1024);
try
{
// Use the buffer for processing
}
finally
{
ArrayPool<byte>.Shared.Return(
buffer,
clearArray: true);
}
}
Returning the buffer allows another operation to reuse it.
Use clearArray: true when the buffer may contain sensitive data and clearing it is required by your security policy.
Do not return a buffer while another operation is still using it.
Review Background Task Lifetimes
Long-running workers often create additional asynchronous tasks.
An unsafe pattern is starting a task without tracking its lifetime:
public void StartProcessing()
{
Task.Run(async () =>
{
await ProcessOrdersAsync();
});
}
The application has no clear way to observe failures, coordinate cancellation, or wait for the task during shutdown.
Use the hosted service lifecycle instead.
public class OrderWorker : BackgroundService
{
private readonly ILogger<OrderWorker> _logger;
public OrderWorker(
ILogger<OrderWorker> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessOrdersAsync(stoppingToken);
}
catch (OperationCanceledException)
when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Order processing failed.");
}
await Task.Delay(
TimeSpan.FromSeconds(5),
stoppingToken);
}
}
private async Task ProcessOrdersAsync(
CancellationToken cancellationToken)
{
// Process pending orders
await Task.CompletedTask;
}
}
This provides a clear lifecycle for the background operation.
If a worker needs scoped services such as an EF Core DbContext, create a dependency-injection scope for each processing unit. Do not retain a scoped context in a singleton worker indefinitely.
Best Practices for Long-Running Workers
Monitor memory trends
Collect memory metrics continuously. Compare heap size, allocation rate, and working set against processing volume.
Keep collections bounded
Use limits for caches, queues, dictionaries, and in-memory history.
Review service lifetimes
Check whether singleton services retain references to scoped or transient objects.
Dispose resources
Use the correct disposal pattern for streams, database resources, and other disposable objects.
Process large data incrementally
Avoid loading entire files or large database result sets into memory when streaming or pagination is possible.
Test under sustained load
A worker that runs successfully for five minutes may still have a memory leak after several hours.
Avoid forcing garbage collection
Calling GC.Collect() repeatedly is not a reliable way to fix memory leaks. It can increase CPU usage and hide the actual object retention problem.
Common Mistakes to Avoid
One common mistake is treating high working set memory as proof of a memory leak. The .NET runtime may retain memory for reuse even after objects are collected.
Another mistake is looking only at allocation rate. High allocations do not necessarily indicate a leak if objects have short lifetimes.
Developers also sometimes increase container memory limits instead of investigating continuously growing object graphs. This may delay the failure without fixing the underlying problem.
Finally, avoid capturing memory dumps without considering security. Dumps may contain sensitive application data and should be handled like production data.
Summary
Memory leaks in long-running .NET worker services usually occur because objects remain reachable longer than necessary or resources are not released correctly.
Start by monitoring runtime counters and memory trends. Capture memory dumps when needed, inspect object types, and use reference analysis to find what keeps objects alive.
Fix unbounded collections, event subscriptions, incorrect service lifetimes, and unnecessary large allocations. Combine these changes with sustained-load testing and production monitoring.
A worker service should maintain predictable memory usage as its workload grows. The goal is not to force the garbage collector to run more often, but to ensure that the application releases objects and resources when they are no longer needed.
Join the conversation! Your thoughts help the community grow.