If you've ever profiled a hot path and seen allocation spikes from new byte[...], you're not alone.

I hit this while parsing lots of small payloads in a loop. The code was simple and readable, but GC pressure kept showing up in traces. ArrayPool<T> ended up being one of the easiest wins: less garbage, steadier throughput, and no scary complexity once you know the guardrails.

Why ArrayPool<T> helps

ArrayPool<T> lets you rent an array and return it when you're done, so buffers can be reused instead of constantly allocated.

using System;
using System.Buffers;

public static class BufferWorker
{
    public static int CountDigits(ReadOnlySpan<char> input)
    {
        char[] rented = ArrayPool<char>.Shared.Rent(input.Length);

        try
        {
            input.CopyTo(rented);

            int count = 0;
            for (int i = 0; i < input.Length; i++)
            {
                if (char.IsDigit(rented[i]))
                {
                    count++;
                }
            }

            return count;
        }
        finally
        {
            ArrayPool<char>.Shared.Return(rented, clearArray: false);
        }
    }
}

The key thing here is try/finally. If you rent, you must return — even when something throws.

Remember: rented arrays can be bigger than requested

This catches people all the time (including me the first time).

byte[] rented = ArrayPool<byte>.Shared.Rent(1000);
Console.WriteLine(rented.Length); // Could be 1024, 2048, etc.

So track your logical length separately and only process the portion you actually filled.

using System;
using System.Buffers;

public static class PacketSerializer
{
    public static byte[] Serialize(ReadOnlySpan<byte> payload)
    {
        byte[] rented = ArrayPool<byte>.Shared.Rent(payload.Length + 4);

        try
        {
            int written = 0;

            BitConverter.TryWriteBytes(rented.AsSpan(written, 4), payload.Length);
            written += 4;

            payload.CopyTo(rented.AsSpan(written));
            written += payload.Length;

            return rented.AsSpan(0, written).ToArray();
        }
        finally
        {
            ArrayPool<byte>.Shared.Return(rented, clearArray: false);
        }
    }
}

When should you clear before returning?

clearArray: false is faster, but previous contents stay in the array until overwritten. That's usually fine for non-sensitive data. For secrets (tokens, passwords, keys), clear first.

using System;
using System.Buffers;
using System.Security.Cryptography;

public static class SensitiveBufferExample
{
    public static void HashSecret(ReadOnlySpan<byte> secret)
    {
        byte[] rented = ArrayPool<byte>.Shared.Rent(secret.Length);

        try
        {
            secret.CopyTo(rented);
            Span<byte> hash = stackalloc byte[32];
            SHA256.HashData(rented.AsSpan(0, secret.Length), hash);
        }
        finally
        {
            Array.Clear(rented, 0, secret.Length);
            ArrayPool<byte>.Shared.Return(rented, clearArray: false);
        }
    }
}

I prefer explicit clearing of only the used segment, then returning with clearArray: false. It's clear (pun intended) and avoids clearing extra capacity you never touched.

Practical rules I use

  • Reach for ArrayPool<T> in hot paths where arrays are short-lived and frequent.
  • Always return in finally.
  • Never assume rented.Length == requestedLength.
  • Treat rented buffers as temporary workspace, not values to store.
  • Clear sensitive data before returning.

Final thought

ArrayPool<T> isn't a silver bullet, and I wouldn't sprinkle it everywhere. But in tight loops and high-throughput code, it's one of those small changes that can noticeably smooth out GC behavior.

If your profiler keeps pointing at temporary array allocations, this is a great tool to try next.