Every allocation in .NET has a cost. The object itself is cheap to create, but the downstream pressure on the garbage collector adds up — particularly in hot paths handling thousands of requests per second. Object pooling sidesteps this by reusing instances rather than discarding them.
The Problem: Allocation-Heavy Code
Consider a web application that builds a StringBuilder for every request to compose a response body. Each request allocates a new StringBuilder, uses it briefly, then abandons it for the GC to clean up.
// Naive approach — allocates on every call
public string BuildResponse(IEnumerable<string> lines)
{
var sb = new StringBuilder();
foreach (var line in lines)
{
sb.AppendLine(line);
}
return sb.ToString();
}
Under load, this creates thousands of short-lived objects per second. Gen 0 collections become frequent, and if the StringBuilder grows large enough, it may land on the Large Object Heap.
Enter ObjectPool<T>
The Microsoft.Extensions.ObjectPool package provides a thread-safe pool implementation. ASP.NET Core uses it internally — you can use it in your own code too.
First, install the package:
dotnet add package Microsoft.Extensions.ObjectPool
Then register it in your DI container:
builder.Services.AddSingleton<ObjectPoolProvider, DefaultObjectPoolProvider>();
builder.Services.AddSingleton(sp =>
{
var provider = sp.GetRequiredService<ObjectPoolProvider>();
return provider.Create(new StringBuilderPooledObjectPolicy());
});
Now inject and use the pool:
public class ResponseBuilder
{
private readonly ObjectPool<StringBuilder> _pool;
public ResponseBuilder(ObjectPool<StringBuilder> pool)
{
_pool = pool;
}
public string BuildResponse(IEnumerable<string> lines)
{
var sb = _pool.Get();
try
{
foreach (var line in lines)
{
sb.AppendLine(line);
}
return sb.ToString();
}
finally
{
_pool.Return(sb);
}
}
}
The Get() call retrieves a pooled instance (or creates one if the pool is empty). Return() hands it back for reuse. The built-in StringBuilderPooledObjectPolicy clears the builder on return so the next consumer gets a clean instance.
Writing a Custom Policy
For your own types, implement IPooledObjectPolicy<T>:
public class ExpensiveBufferPolicy : IPooledObjectPolicy<ExpensiveBuffer>
{
public ExpensiveBuffer Create()
{
return new ExpensiveBuffer(bufferSize: 4096);
}
public bool Return(ExpensiveBuffer obj)
{
if (obj.IsCorrupted)
return false; // discard this instance
obj.Reset();
return true;
}
}
Returning false from Return tells the pool to discard the object rather than reuse it. This is essential for objects that can enter an invalid state.
DefaultObjectPool Internals
The DefaultObjectPool<T> keeps one item in a dedicated field and the rest in an array. The dedicated field uses a lock-free Interlocked.CompareExchange, making the single-item fast path extremely cheap. The array-backed storage uses Monitor locks, which is still fast but slightly more contended.
The default maximum pool size equals Environment.ProcessorCount * 2. This is a sensible default — large enough to serve concurrent requests without hoarding memory.
When to Pool
Object pooling is not universally beneficial. It adds complexity and can mask memory leaks if objects are never returned. Use it when:
- The object is expensive to create (large buffers, compiled regexes, database connections).
- The object is allocated frequently in a hot path.
- Profiling confirms allocation pressure is a bottleneck.
Do not pool cheap, small objects. The overhead of the pool itself (thread synchronisation, policy checks) can exceed the cost of allocation.
Measuring the Impact
Use dotnet-counters to observe GC metrics before and after pooling:
dotnet-counters monitor --process-id <pid> System.Runtime
Watch for reductions in Gen 0 Collections and Allocation Rate. If pooling is working, both should drop noticeably under load.
Summary
ObjectPool<T> is a lightweight, production-ready tool for reusing objects in .NET. It is already used inside ASP.NET Core for StringBuilder instances, Razor view buffers, and more. When profiling shows allocation pressure in a hot path, reach for pooling before reaching for more exotic optimisations.