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.

Example.cs
// 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:

Example.cs
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:

ResponseBuilder.cs
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>:

ExpensiveBufferPolicy.cs
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:

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.