Debugging ArrayPool<T> misuse: lifetime bugs, length assumptions, clearing, diagnostics and safe patterns.
Pooling bugs are lifetime bugs: the pool gives a returned array to the next renter, so any code still using it reads or writes someone else's data. The usual causes are use after return (returning in finally while an async continuation, a queued Memory<T> or a lazy enumerable still uses the array), double return (two renters then share one array), treating array.Length as the data length (Rent returns an array at least as large as requested, with old contents), not clearing buffers that held secrets (Return(array, clearArray: true)), and returning arrays not rented from that pool. To debug: reproduce under concurrency, add canaries or a poisoning wrapper pool in tests that fills returned arrays with a marker and detects double returns, trace System.Buffers.ArrayPoolEventSource events, and review every rent for a single owner and a single return in finally.
Intermittent corruption plus another customer's data in a response is the signature of ArrayPool misuse: a buffer was used after being returned (or returned twice), so two requests shared the same array. I'd treat it as a security incident first (data leak), then debug systematically: identify every rent/return path, look for the five classic mistakes, and use tooling and tests that make these bugs deterministic.
new byte[] allocations (feature flag) — slower but correct — while you find the bug.Use-after-return — the most common cause of cross-request leaks:
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
int n = Serialize(order, buffer);
ArrayPool<byte>.Shared.Return(buffer);
await response.Body.WriteAsync(buffer.AsMemory(0, n), ct); // BUG: buffer may now be rented by another request
Also: returning a buffer while a Memory<byte> slice is still queued to a channel/background task, or captured in a lambda.
Double return:
try { ... ArrayPool<byte>.Shared.Return(buffer); }
finally { ArrayPool<byte>.Shared.Return(buffer); } // BUG: same array in the pool twice -> two renters get it simultaneously
ArrayPool<T>.Shared doesn't detect double returns; two later Rent calls can receive the same array.
Assuming the rented length:
var buffer = ArrayPool<byte>.Shared.Rent(1000); // may return 1024 (or larger)
stream.Write(buffer); // BUG: writes 1024 bytes, including stale data from previous renters
var hash = SHA256.HashData(buffer); // BUG: hashes garbage tail
Rent(n) returns an array of at least n elements. Always track the used length and slice (buffer.AsSpan(0, length)).
Not clearing sensitive data:
Return(array) doesn't clear by default. If the array held tokens, card data or personal information, the next renter gets it — and if they read beyond what they wrote (previous mistake), it leaks. Use Return(array, clearArray: true) for sensitive data, or CryptographicOperations.ZeroMemory.
Returning foreign arrays:
Returning an array that wasn't rented from that pool (new byte[], an array from another pool, or a Rent result that was swapped) pollutes the pool; returning an array that's still referenced elsewhere is a use-after-return in disguise. The shared pool accepts arrays of any size (it may drop non-standard sizes), so it won't stop you.
ArrayPool, .Rent(, .Return(, MemoryPool, IMemoryOwner, and helper types (PooledBufferWriter, RecyclableMemoryStream). Map ownership: who rents, who returns, and whether anything outlives the return (async continuations, channels, Memory<T> stored in objects, JsonDocument).await completion; finally blocks that run when a task is merely started, not finished; fire-and-forget writes.ArrayPool<T>:
0xDD), so use-after-return produces obviously wrong data immediately.ArrayPool<T> instance passed in rather than Shared everywhere) so tests and staging can use it.Rent has exactly one Return in a finally in the same scope, or ownership is explicitly transferred via IMemoryOwner<T>.dotnet-dump) to inspect a buffer's contents and find which objects reference it (gcroot).IMemoryOwner<T>-style struct/class that nulls its array on dispose and throws on use-after-dispose.byte[] rented = ArrayPool<byte>.Shared.Rent(estimatedSize);
try
{
int length = Serialize(order, rented);
await response.Body.WriteAsync(rented.AsMemory(0, length), ct); // complete ALL use before returning
}
finally
{
ArrayPool<byte>.Shared.Return(rented, clearArray: containsPersonalData);
}
For handoff to other components (channels, background workers), pass IMemoryOwner<byte> and make the consumer dispose it.
[In my case] an API serialised responses into pooled buffers. A refactor moved Return into a finally that ran before an awaited WriteAsync completed — the method returned a Task from WriteAsync without awaiting it. Under load, another request rented the same buffer and overwrote it mid-write; [a few] responses contained another customer's order details. We disabled pooling via a flag within [an hour], reproduced it with a poisoning pool in a load test (corruption became 100% reproducible), fixed the await/ownership bug, and added a test pool that throws on double return in all integration tests. We also reported it through our privacy breach process.
The interviewer checks you can diagnose pooling lifetime bugs and design safe pooled code.
Green flags
Rent can return larger arrays with old contents.Red flags
array.Length as data length.ArrayPool<T>.Shared worksArrayPool<T>.Shared (SharedArrayPool<T>) keeps arrays in size buckets (powers of two from 16 up to ~1 GB). Rent(n) returns an array from the smallest bucket ≥ n, first from a thread-local cache, then per-core stacks; if none, it allocates a new array. Return(array) puts it back into the bucket matching its length (dropping arrays of unsupported sizes). There's no tracking of who rented what — for performance — so misuse isn't detected. The pool also trims buffers under memory pressure (Gen2 GC callbacks).
After a double return, the bucket contains the same array twice. Two subsequent Rent calls (often on different threads) get the same instance, both believing they own it exclusively — interleaved writes and cross-request data exposure follow.
finally around an un-awaited task is a bug.Memory<T> slices passed to async APIs or stored must not outlive the rental.PipeWriter, prefer GetMemory/Advance/FlushAsync — the pipe manages buffer ownership.public sealed class TrackingArrayPool<T> : ArrayPool<T>
{
private readonly ConcurrentDictionary<T[], string> _rented = new(ReferenceEqualityComparer.Instance);
public override T[] Rent(int minimumLength)
{
var array = new T[minimumLength + Random.Shared.Next(0, 64)]; // also tests length assumptions
_rented[array] = Environment.StackTrace;
return array;
}
public override void Return(T[] array, bool clearArray = false)
{
if (!_rented.TryRemove(array, out _)) throw new InvalidOperationException("Double return or foreign array");
Array.Fill(array, default!); // poison/clear so later use is visible
}
public int Outstanding => _rented.Count; // assert zero at test end (leaks)
}
RecyclableMemoryStream (Microsoft.IO) for pooled streams with built-in diagnostics.ArrayBufferWriter<T> / PooledBufferWriter patterns with clear disposal.System.IO.Pipelines for I/O.1000 bytes and print the returned array's length.Return(array, clearArray: true) and measure the cost for different sizes.using System.Buffers;
using System.Collections.Concurrent;
using System.Text;
// Test helper: poisons returned arrays and detects double or foreign returns
public sealed class PoisoningArrayPool : ArrayPool<byte>
{
private readonly ArrayPool<byte> _inner = ArrayPool<byte>.Create();
private readonly ConcurrentDictionary<byte[], bool> _rented = new(ReferenceEqualityComparer.Instance);
public override byte[] Rent(int minimumLength)
{
byte[] array = _inner.Rent(minimumLength);
_rented[array] = true;
return array;
}
public override void Return(byte[] array, bool clearArray = false)
{
if (!_rented.TryRemove(array, out _))
throw new InvalidOperationException("Array returned twice or not rented from this pool");
array.AsSpan().Fill(0xCD); // any later use shows 0xCD instead of plausible data
_inner.Return(array);
}
}
public static class ReportWriter
{
// BUGGY: three problems
public static async Task<string> RenderBuggyAsync(ArrayPool<byte> pool, string customer)
{
byte[] buffer = pool.Rent(256);
int length = Encoding.UTF8.GetBytes($"Report for {customer}", buffer);
Task<string> pending = Task.Run(() => Encoding.UTF8.GetString(buffer)); // 1: still using buffer after return
pool.Return(buffer); // returned too early
string text = await pending; // 2: decodes buffer.Length, not length
return text; // 3: no try/finally, no clearing
}
// FIXED: single owner, awaited before return, sliced to the valid length, cleared
public static async Task<string> RenderAsync(ArrayPool<byte> pool, string customer)
{
byte[] buffer = pool.Rent(Encoding.UTF8.GetMaxByteCount(customer.Length + 11));
try
{
int length = Encoding.UTF8.GetBytes($"Report for {customer}", buffer);
ReadOnlyMemory<byte> valid = buffer.AsMemory(0, length);
return await Task.Run(() => Encoding.UTF8.GetString(valid.Span));
}
finally
{
pool.Return(buffer, clearArray: true); // contains customer data
}
}
}
public static class Program
{
public static async Task Main()
{
var pool = new PoisoningArrayPool();
Console.WriteLine(await ReportWriter.RenderAsync(pool, "Mia")); // Report for Mia
string buggy = await ReportWriter.RenderBuggyAsync(pool, "Leo");
Console.WriteLine(buggy.Contains('�') || buggy.Length > 20
? "Bug detected: decoded poisoned or trailing bytes"
: buggy);
}
}
What happens: Return in finally while a write is still pending.
Why it is wrong: another request reuses the buffer mid-operation.
Do instead: await all operations before returning.
What happens: buffers returned in both a try and a finally, or in retry loops.
Why it is wrong: the same array is handed to two renters.
Do instead: single return in finally; null out the variable after returning.
What happens: writing/hashing buffer instead of buffer[..length].
Why it is wrong: includes stale data from previous renters.
Do instead: track and slice to the used length.
What happens: returning arrays containing personal or secret data.
Why it is wrong: later renters can read it.
Do instead: clearArray: true or CryptographicOperations.ZeroMemory.
What happens: returning new byte[] or arrays still referenced elsewhere.
Why it is wrong: pool pollution and hidden aliasing.
Do instead: return only what you rented and fully own.
What happens: passing Memory<T> slices to channels/background tasks then returning.
Why it is wrong: consumers read reused memory.
Do instead: transfer IMemoryOwner<T>; consumer disposes.
What happens: pooling small, infrequent buffers. Why it is wrong: complexity and risk without benefit. Do instead: pool measured hot paths only.
What happens: fixing quietly without assessment. Why it is wrong: may be a notifiable data breach. Do instead: follow incident and privacy processes.
ArrayPool trades GC cost for manual lifetime management, bringing C-style use-after-free bugs into C#. The senior response is to make ownership structural rather than relying on care:
| Technique | Effect |
|---|---|
Rent and return in one method with try/finally |
simple, reviewable |
Owner types (IMemoryOwner<T>, ref struct builders) |
ownership visible in types |
| Poisoning pool in tests | bugs fail fast and loudly |
clearArray: true or private pools for secrets |
limits data exposure |
| Pool only where profiling justifies it | less risk overall |
I would also treat a data-leak from pooled buffers as a security incident: notify, assess exposure, and add regression tests, not just a quiet fix.
First I measure: dotnet-counters for allocation rate and LOH size, then an allocation profiler to see which arrays dominate. Short-lived large arrays are the worst case, because anything of 85,000 bytes or more lands on the Large Object Heap, which is only collected in gen 2. The fixes are to reuse buffers with ArrayPool<T>.Shared (always return them, use only the requested length, clear sensitive data), use spans instead of copying slices, and stream data instead of building giant arrays. For speed I keep data in contiguous arrays of structs for cache locality and use the vectorised CopyTo/Fill/TensorPrimitives APIs instead of hand-written loops. Then I prove it with BenchmarkDotNet and compare production counters.
MemoryPool<T> rents buffers as IMemoryOwner<T>: an IDisposable object whose .Memory property is the buffer and whose Dispose() returns it. That makes ownership explicit: whoever holds the owner must dispose it, and you can transfer it (through a channel, a queue or a method) so the receiver disposes. MemoryPool<T>.Shared is backed by ArrayPool<T>.Shared. The memory may be larger than requested, so slice to the length you use. Use it over ArrayPool<T> when buffers cross components or async boundaries or when you want to swap the memory source. System.IO.Pipelines gets segments from a MemoryPool<byte>; Kestrel uses its own pinned block pool for sockets. A custom MemoryManager<T> can expose native or pinned memory as Memory<T>.
stackalloc reserves memory on the current thread's stack; assigned to a Span<T> (C# 7.2+) it needs no unsafe code, is freed automatically when the method returns and costs nothing for the GC. It only works for unmanaged element types (byte, char, int, unmanaged structs). The stack is small (commonly around 1 MB per thread on Windows, and other platforms differ), and a stack overflow cannot be caught: the process dies. So use it only for small, bounded sizes, with the hybrid pattern: stackalloc below a fixed threshold (for example 256 bytes to 1 KB), otherwise rent from ArrayPool<T> and return it in finally. Never stackalloc in a loop, because memory is only released when the method returns. [SkipLocalsInit] skips zeroing the buffer for speed, which is safe only if you write before you read.
How to tell it (STAR)
**Situation:** a customer of our [insurance portal] received a document containing another customer's details. **Task:** I led the technical investigation as part of the security incident. **Action:** I found a pooled buffer was returned while an un-awaited write still used it, proved it with a load test using a poisoning pool, fixed the ownership, cleared sensitive buffers, and added the poisoning pool to our test suite. **Result:** we confirmed the scope ([six] responses), notified affected customers, and have had no recurrence across [tens of millions] of responses since.
Last updated 30 Oct 2026.