Allocating a fresh array or object on every call to a hot path is fine for small, occasional use, but it stops being fine when the path runs thousands of times a second and each allocation lives for only a few milliseconds before becoming garbage. The collector keeps up, but it pays for the work, and that work shows up as latency spikes and CPU spent on Gen-0 collections. This lesson covers pooling: renting objects from a shared cache instead of allocating fresh ones, returning them when you're done, and the contract you have to honor for the pattern to be safe. The two pools that show up in modern .NET code are ArrayPool<T> for buffers and the Microsoft.Extensions.ObjectPool family for reusable reference-type instances.
A short-lived buffer is the textbook case. Consider code that formats a receipt for a customer and writes it to disk. Each call needs a temporary byte buffer, maybe four kilobytes, to hold the formatted bytes before flushing. The straightforward version allocates a new array every time:
The buffer is alive for a few microseconds. The collector eventually sweeps it from Gen-0, which is cheap per allocation but not free. Run WriteReceipt ten thousand times a second under a peak shopping load and the Gen-0 collections pile up. Each collection pauses some thread, scans the young generation, and copies survivors. The pause is short, but the throughput cost is real.
Every new byte[4096] is a heap allocation. Thousands per second push Gen-0 collection frequency up, and Gen-0 pauses, while typically sub-millisecond, still cost CPU and add tail-latency jitter on hot code paths.
Pooling flips the model. Instead of allocating, you ask a shared pool for a buffer that's already alive. The pool hands you one from its cache or, if the cache is empty, allocates a new one once and remembers it for next time. When you're done, you return the buffer to the pool, where it sits ready for the next caller. The buffer's lifetime is now controlled by you, not by the collector, and most of the time no allocation happens at all.
The picture shows the steady state. Buffers move between two buckets inside the pool: available (idle, ready to lend out) and rented (currently in use by some caller). A Rent call pulls from the available bucket if it can, falls back to the heap on a miss, and moves the buffer into the rented bucket. A Return call moves it back. Under sustained load, the available bucket stays full enough that almost every Rent is a hit, and the heap is touched only during the warm-up.
ArrayPool<T> lives in System.Buffers. The default instance, ArrayPool<T>.Shared, is a process-wide pool that's already configured and ready to use. You don't construct it, you just call its members.
Two things deserve attention. The Rent(4096) call asks for a buffer of at least 4096 elements, and the pool may return a larger one. The pool is organized into power-of-two buckets, so a request for 4096 might come back with exactly 4096 or with something like 8192. Code that uses buffer.Length as the "amount of useful data" will be wrong; the correct pattern is to track the count separately (the written variable above), or wrap the rented array in buffer.AsSpan(0, written) and pass the slice around. The pool guarantees Length >= requested, never the other way.
The second is the try/finally. The Return call has to happen on every path out of the method, including the exception path. Forgetting to return a rented buffer doesn't crash the program, but it does mean the pool now has one fewer buffer in its available bucket. Do this repeatedly and the pool drains, and every future Rent falls through to a fresh heap allocation, which is the behavior pooling was meant to avoid.
A leaked rented buffer behaves like a memory leak from the pool's perspective. The buffer is still reachable from the pool's internal state in some configurations, but in ArrayPool<T>.Shared it's forgotten, and the pool slowly degrades to allocate-on-every-rent.
The contract for every rented buffer is the same three-step dance: rent, use, return. The use step can be arbitrarily complex (write into it, read from it, slice it, pass a span to another method), but rent and return must bracket every code path through the method.
The diagram makes the failure mode explicit. If Use throws and there's no try/finally, the buffer never makes it back to the pool. The exception is not what's wrong; the missing return is. Wrap every rent in a try/finally, and the finally executes whether the body completes normally or unwinds through an exception.
Here's a slightly larger example that reads a product catalog file in chunks, using a rented buffer as scratch space.
The buffer length is whatever the pool decides to hand back (at least 1024). The loop uses bytesRead as the count of valid bytes, never buffer.Length. The try/finally guarantees the return even if the file is missing or the read throws partway through.
Return has a second parameter, clearArray, that defaults to false. When false, the pool keeps the buffer's contents as-is and just marks it available. The next caller to rent it will see whatever bytes you left behind. That's fine for byte[] or any numeric T, because nobody cares about stale numbers in a buffer they're about to overwrite. It is not fine when T is a reference type, because the buffer holds references to objects that are now kept alive by the pool, even though your code is done with them.
Without clearArray: true, the pool keeps a reference to the two Product instances through the recycled array. They can't be collected until the pool overwrites those slots, which might not happen for a long time, or never. For short-lived objects that's a small leak; for large objects (a byte[] holding a megabyte of receipt data, wrapped in a class), the leak is meaningful.
Forgetting clearArray: true for arrays of references can pin objects indefinitely in the pool's internal storage. Use true whenever T is a reference type or a struct that contains references. For pure value types (byte, int, double), false is fine and skips the extra zero-out work.
A second reason to clear matters in security-sensitive code: a buffer that held a customer's address or a coupon code shouldn't be readable by the next caller who happens to rent the same buffer back. clearArray: true zeroes the slots before returning the buffer to the available bucket.
ArrayPool<T>.Shared is sized for general use. For workloads with unusual size profiles or rent-heavy hot paths, you can create your own pool with ArrayPool<T>.Create(maxArrayLength, maxArraysPerBucket).
The two knobs control the pool's footprint. maxArrayLength caps the largest array the pool will manage; requests above that limit fall through to a fresh heap allocation every time and are not pooled. maxArraysPerBucket caps how many idle arrays of each size the pool will keep around; once a bucket is full, Return for that size drops the buffer on the floor (it becomes regular garbage). Bigger numbers waste more memory at idle, smaller numbers miss more often under load.
Most code should keep using ArrayPool<T>.Shared. Use Create only when you have a specific reason: a measured benchmark showing the shared pool is contended, a workload with a large but predictable buffer size that the shared pool doesn't cache, or a desire to isolate a subsystem's pool usage from the rest of the process. Premature customization is the usual mistake.
A custom pool that caches large arrays (maxArrayLength = 16 MB, maxArraysPerBucket = 64) can hold a lot of memory at steady state. That memory is alive even when the pool is idle, because the point is to keep the arrays around for the next caller.
There's another way to avoid heap allocation for short-lived buffers. stackalloc carves bytes off the current method's stack frame and gives you a Span<T> over them. The buffer is gone the moment the method returns, with no GC involvement and no return call needed. ArrayPool, by contrast, hands out a heap-allocated array that's reused across calls.
| Aspect | stackalloc | ArrayPool<T> |
|---|---|---|
| Storage location | Stack frame of the current method | GC heap (kept alive by the pool) |
| Lifetime | Until the method returns | Until you call Return |
| Size limits | Small (typically under ~1 KB; larger risks stack overflow) | Up to 2 GB (one array's max size) |
| Size known at call site? | Must be small and bounded at runtime | Can grow up to the requested size at runtime |
| Setup cost | Free (just a stack pointer adjustment) | One indirection through the pool plus a possible allocation on miss |
| Cleanup cost | Free (frame pops) | Must call Return, in try/finally |
Works for reference-type T? | No, T must be unmanaged | Yes, any T |
The rule of thumb is small-and-fixed versus large-or-variable. A method that formats a six-character coupon code into a buffer should stackalloc 16 bytes and move on. A method that copies an unknown number of bytes from a stream until end-of-file should rent from ArrayPool<byte>.Shared. The same method that does both, parse a short header on the stack and copy a large body through a rented buffer, is also a perfectly normal shape.
The deeper reason for the split is reach: anything stackalloc'd cannot outlive the current stack frame, so you can't return it, store it in a field, or hand it to an async continuation. A rented array can, because it's on the heap. If the buffer's lifetime ever escapes the immediate call, ArrayPool is the only option.
ArrayPool<T> fits when the thing you're recycling is an array. For non-array reusable objects, especially objects with internal buffers of their own, the Microsoft.Extensions.ObjectPool package solves the same problem at the object level. It ships in the NuGet package of the same name and is part of the standard ASP.NET Core stack.
The canonical example is StringBuilder. Building up cart totals across a batch of orders means allocating a new StringBuilder per call, each one with its own internal char[]. Pooling reuses the same StringBuilder instance (and its internal buffer) across calls.
The shape is identical to ArrayPool<T>: Get to rent, do work, Return in a finally. The pool takes care of resetting the StringBuilder between uses, because the StringBuilderPooledObjectPolicy clears the builder on return. If you tried to skip the reset, the next caller would see leftover text from the previous one.
A pool needs to know two things about whatever it's caching: how to create a fresh instance when the pool is empty, and how to reset an instance when it's returned. For StringBuilder and a few other common types the package ships with built-in policies. For your own types, implement IPooledObjectPolicy<T>.
Consider a ReceiptBuilder class that holds an internal list of lines and a header string. Every export-receipt call needs one. Allocating fresh ones under load is the kind of thing pooling fixes.
Three points. Create runs only when the pool needs a new instance, which is rare in steady state. Return runs on every return call and is where state reset belongs; forgetting to clear Lines would leak the previous receipt's lines into the next caller's output. The boolean return value from Return lets you reject an instance that's grown unhealthy, for example a ReceiptBuilder whose internal list has expanded to thousands of slots; returning false drops the instance and lets the GC collect it, so the pool isn't holding onto an outsized object forever.
A pool that always returns true will keep its largest-ever instances forever. If a single bad call inflates an internal buffer to 100 MB, every subsequent caller inherits that buffer. Reject inflated instances in Return to bound the pool's memory.
ArrayPool<T> hands out raw T[] arrays. When you'd rather work with Memory<T> (because the consuming code is shaped around Memory<T> and Span<T> rather than arrays), MemoryPool<T>.Shared is the equivalent. The rented thing is wrapped in an IMemoryOwner<T> that the caller disposes when finished.
The using declaration takes the place of the try/finally. IMemoryOwner<T> implements IDisposable, so the dispose call returns the underlying buffer to the pool. The two patterns (ArrayPool with explicit Return, MemoryPool with using and dispose) are equivalent in spirit, and which one you use is mostly about whether the surrounding code wants to pass an array or a Memory<T> around. APIs that take ReadOnlyMemory<byte> are best fed from MemoryPool<byte>; APIs that take byte[] are best fed from ArrayPool<byte>.
Note that MemoryPool<T>.Shared may return a buffer longer than requested, the same as ArrayPool<T>.Shared does. The owner.Memory.Length value is at least the requested length and may be more, so the same "track the actual count separately" rule applies.
Pooling isn't free. The rent and return calls have their own overhead, the pool's internal data structures take memory, and the contract around clearing and returning adds discipline that simple new doesn't need. The win has to outweigh that overhead, which it does in some scenarios and doesn't in others.
| Scenario | Pool? | Why |
|---|---|---|
| Hot path renting 1 KB+ buffers thousands of times per second | Yes | Allocation pressure and Gen-0 churn dominate; pool wins |
| A method called once at startup that needs a 100 KB buffer | No | One allocation in the lifetime of the process; not worth the ceremony |
StringBuilder reused across many short formatting calls | Yes | Each fresh StringBuilder allocates its own internal char[] |
Tiny objects with no internal buffers (a Point with two ints) | No | The object itself is small; pooling overhead dominates |
| Objects that hold disposable resources (file handles, sockets) | Usually no | Lifetime gets tangled; the resource needs ownership semantics, not pooling |
| Objects with finalizers | No | Finalization rules and pooling interact badly; just allocate |
| Large arrays (1 MB+) in a streaming pipeline | Yes | Heap pressure from large objects pins them in Gen-2; pool keeps a fixed set alive |
Async work that holds the rented buffer across an await | Yes, with care | Make sure the buffer is returned on every continuation path, not just the success one |
The pattern is that pooling helps when the object is non-trivial to allocate and reasonably long-lived during use but immediately reusable afterward. It hurts when the object is so cheap that pooling overhead exceeds the saving, or when the object's lifetime is genuinely tied to a resource that needs explicit ownership rather than reuse.
ASP.NET Core uses pooling extensively in its request pipeline. byte[] buffers for reading request bodies, StringBuilder for header building, and pooled PipeReader/PipeWriter structures are part of why a modern .NET web server allocates very little per request. Most of that machinery is invisible to application code; you just write the controller and the framework handles the pooling underneath.
Common mistakes in pool-using code:
The first is forgetting to return. Code paths through early returns, exceptions, and async continuations all need to hit the Return call. The fix is a try/finally (or a using block with IMemoryOwner<T>) around every rent. If a method has multiple return statements, every one of them has to be inside the try.
The second is returning twice. After a Return call, the buffer no longer belongs to the caller. Any further use (read, write, return again) is a bug. Returning twice corrupts the pool's internal state in some implementations and silently puts the same buffer into the available bucket twice, so two later callers can rent the same instance and stomp on each other. The fix is to null out the local reference after returning, or use a small helper that does the return for you and prevents reuse.
The third is holding a rented buffer across an `await` and forgetting the failure path. Async code makes the try/finally story a little harder to get right because exceptions inside the awaited operation propagate out of the await, not out of the synchronous part. The finally still runs (because the state machine compiles to a try/finally around the user code), but only if the rent happens inside the try in the first place. Move the rent inside the try, not before it.
The fourth is assuming `Length` equals what you asked for. Already covered, but worth restating: Rent(1000) may return an array of length 4096, and code that loops over buffer.Length will process garbage in the unused tail. Track the valid count separately.
The fifth is pooling things that hold resources. Pooling a class that owns a file handle, a socket, or a database connection blurs the line between "object is reused" and "resource is leaked". Disposable resources have their own lifetime story, and tangling that story with pooling almost always ends in resource leaks. The rule is to pool data-shaped objects (buffers, builders, plain containers) and to manage resources separately.
Returning a buffer twice can produce double-rent scenarios where two callers receive the same instance and concurrently mutate it. The bug shows up as corrupted output and is hard to diagnose because there's no exception; just wrong data.
A complete example brings the pieces into one shape: a batch product exporter that uses ArrayPool<byte> for the streaming buffer and ObjectPool<StringBuilder> for the per-row formatting.
The shape repeats in both pools: rent, use inside a try, return in finally. The byte buffer is rented once per call and reused across every row, which is the point. The StringBuilder is rented once per row, but each rent is almost always a hit on the pool's idle bucket, so the builder's internal char[] doesn't reallocate either. Under load this exporter allocates almost nothing per row, which is the kind of profile that makes pooling worth the small ceremony.
10 quizzes