Skip to main content

.NET 9/10 Performance Deep Dive: Span<T>, Memory Pooling, AOT & GC Tuning

A production-grade tour of .NET 9/10 performance engineering — Span<T> and stack allocation, ArrayPool and object pooling, Native AOT compilation, Server GC tuning, and a repeatable BenchmarkDotNet methodology for proving every claim.

· 18 min read · Advanced

Key takeaways

  • Cut allocations with Span<T>, Memory<T>, and stackalloc instead of defensive array copies.
  • Reuse buffers safely with ArrayPool<T> and object pooling without leaking rented memory.
  • Publish with Native AOT for sub-50ms cold starts and a smaller container footprint.
  • Tune Server GC, GC regions, and DATAS to trade memory for throughput deliberately, not by accident.
  • Build a repeatable BenchmarkDotNet harness so every optimization is backed by numbers, not intuition.

Most "make .NET faster" advice stops at "use Span<T>" and moves on. That's not wrong, but it skips the part that actually matters in production: knowing which of the dozen levers .NET 9 and 10 give you is worth pulling for your workload, and proving it with a benchmark instead of a hunch. This guide walks through the ones that consistently pay off in real services — request pipelines processing thousands of payloads a second, batch jobs chewing through millions of rows, and containers where cold-start time is a cost line item, not a curiosity.

None of this is about micro-optimizing a loop that runs twice at startup. It's about the hot paths: JSON (de)serialization, string parsing, buffer manipulation, and allocation patterns that run millions of times a day and show up directly in your cloud bill and your P99 latency.

1. Span<T>, Memory<T>, and Killing Defensive Copies

Every time you call .Substring(), .Split(), or ToArray() on a string or array just to inspect a slice of it, you allocate a brand-new heap object to hold data that already exists somewhere in memory. Span<T> is a ref struct that points at that existing memory — stack, heap, or unmanaged — without copying it. It can't be boxed, can't live on the heap itself, and can't cross an async boundary, which is exactly what makes it safe to hand out slices of a buffer without the caller accidentally holding a dangling reference past its lifetime.

// Before: allocates a new string for every field on every parse
public static (string Code, string Amount) ParseLegacy(string line)
{
    var parts = line.Split(',');           // allocates a string[] + N strings
    return (parts[0].Trim(), parts[1].Trim());
}

// After: zero heap allocations, works directly over the input buffer
public static (ReadOnlySpan<char> Code, ReadOnlySpan<char> Amount) ParseFast(ReadOnlySpan<char> line)
{
    int comma = line.IndexOf(',');
    var code = line[..comma].Trim();
    var amount = line[(comma + 1)..].Trim();
    return (code, amount);
}

Run that over a 500,000-line CSV import and the difference isn't cosmetic — the Split-based version allocates on the order of a million short-lived strings that Gen0 has to sweep, while the Span version allocates nothing beyond the original buffer read. Memory<T> is the escape hatch when you do need to store a slice across an await or in a field: it's the heap-safe counterpart to Span<T>, convertible back to a Span with .Span when you're ready to read from it.

stackalloc for Small, Short-Lived Buffers

For buffers under roughly 1KB that never leave the current method, stackalloc avoids the heap entirely by carving space out of the stack frame. Combine it with Span<T> and you get bounds-checked safety on top of zero-allocation performance — the classic C-style stack buffer overflow risk simply doesn't apply here.

public static bool TryFormatOrderId(int orderId, Span<char> destination, out int written)
{
    Span<char> buffer = stackalloc char[16];
    if (!orderId.TryFormat(buffer, out int len))
    {
        written = 0;
        return false;
    }

    buffer[..len].CopyTo(destination);
    written = len;
    return true;
}

2. ArrayPool<T> and Object Pooling Without the Leaks

Span<T> stops you from copying data you already have. ArrayPool<T> stops you from allocating a new buffer every time you need scratch space — think a byte[] for reading a socket, or a char[] for building up a response. Instead of new byte[8192] on every request, you rent from a shared pool and return the buffer when you're done, letting the runtime reuse the same physical arrays across thousands of requests instead of asking the GC to collect and re-allocate them.

private static readonly ArrayPool<byte> Pool = ArrayPool<byte>.Shared;

public async Task<int> ReadPayloadAsync(Stream source, CancellationToken ct)
{
    byte[] buffer = Pool.Rent(8192);
    try
    {
        int total = 0;
        int read;
        while ((read = await source.ReadAsync(buffer.AsMemory(total), ct)) > 0)
        {
            total += read;
        }
        return total;
    }
    finally
    {
        // clearArray: true if the buffer might contain sensitive data (tokens, PII)
        Pool.Return(buffer, clearArray: false);
    }
}

The finally block isn't decoration — it's the entire contract. Forget to return a rented array and you haven't caused a crash, you've caused a slow leak: the pool keeps allocating fresh arrays because it never gets its buffers back, and six months later someone's paging you about steadily climbing memory in a service that "should be stateless." Wrap rent/return in a using-style helper or a try/finally every single time, with no exceptions.

Microsoft.Extensions.ObjectPool applies the same idea to full objects — StringBuilder instances, custom DTOs you construct repeatedly — where the cost you're avoiding isn't just the allocation, it's the field initialization and any constructor work behind it.

3. Native AOT: Compiling Away the JIT and the Runtime Warm-Up

Native AOT publishing compiles your app straight to a self-contained native executable ahead of time — no JIT, no runtime warm-up, and no dependency on the shared .NET runtime being present on the host. For a serverless function or a container that needs to scale from zero, that's the difference between a 400-500ms cold start and one measured in single-digit to low double-digit milliseconds, plus a meaningfully smaller container image since the trimmed output only ships the code paths actually reachable from your app.

<!-- .csproj: enable Native AOT publishing -->
<PropertyGroup>
  <PublishAot>true</PublishAot>
  <InvariantGlobalization>true</InvariantGlobalization>
  <TieredPGO>true</TieredPGO>
</PropertyGroup>
# Publish a self-contained native binary for linux-x64
dotnet publish -c Release -r linux-x64 --self-contained /p:PublishAot=true

The trade-off is real, not theoretical: no runtime reflection-based serialization (System.Text.Json needs its source-generated JsonSerializerContext instead of the reflection-based default), no runtime code generation via Reflection.Emit, and every dependency in your graph needs to be trim- and AOT-compatible or you'll find out at publish time via trimmer warnings — or worse, at runtime via a MissingMethodException that never showed up in Debug.

// Source-generated JSON context required for Native AOT
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(List<OrderDto>))]
internal partial class AppJsonContext : JsonSerializerContext { }

// Usage: no reflection involved at runtime
var json = JsonSerializer.Serialize(order, AppJsonContext.Default.OrderDto);
Native AOT is not a free performance switch you flip on an existing app. It's an architectural constraint you design toward — validate every third-party dependency for trim compatibility before you commit a team to it.

4. Garbage Collector Tuning: Server GC, GC Regions, and DATAS

The default Workstation GC is tuned for desktop apps with a single UI thread to protect from pauses. Server GC is tuned for throughput on multi-core server hardware: it runs one GC heap and one dedicated collector thread per core, and it trades some memory footprint for dramatically better throughput under concurrent load. For an ASP.NET Core API running on more than one or two cores, Server GC is very close to the default choice that isn't a choice — but it's still worth setting explicitly since some hosting models default differently.

<!-- .csproj or runtimeconfig.json -->
<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
  <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>

.NET 8 introduced GC Regions, which replaced the old fixed-size segment model with variable-sized regions the GC can return to the OS independently, instead of holding an entire multi-hundred-MB segment hostage because one live object still sits in it. .NET 9 built on that with DATAS (Dynamic Adaptation To Application Sizes), which actively right-sizes the heap to the application's real working set instead of over-committing memory the way Server GC historically did on idle or low-traffic services.

<!-- .NET 9+: enable Dynamic Adaptation To Application Sizes -->
<PropertyGroup>
  <GCgen0size>0x4000000</GCgen0size>
  <DATAS>true</DATAS>
</PropertyGroup>

5. Proving It: A Repeatable BenchmarkDotNet Harness

None of the above is worth shipping on faith. Stopwatch-in-a-loop benchmarking lies to you constantly — JIT warm-up, tiered compilation kicking in mid-run, and GC pauses landing on an arbitrary iteration all skew the numbers. BenchmarkDotNet exists specifically to control for those variables, and MemoryDiagnoser turns on allocation tracking so you can see the Gen0/1/2 counts and bytes allocated per operation side by side with raw throughput.

[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net90)]
public class ParsingBenchmarks
{
    private const string Line = "ORD-88213, 1204.50, PAID";

    [Benchmark(Baseline = true)]
    public (string, string) SplitBased() => ParseLegacy(Line);

    [Benchmark]
    public (string, string) SpanBased()
    {
        var (code, amount) = ParseFast(Line);
        return (code.ToString(), amount.ToString());
    }
}

// dotnet run -c Release --project Benchmarks
// |        Method |     Mean |   Allocated |
// |--------------- |---------:|------------:|
// |    SplitBased  | 142.3 ns |       112 B |
// |     SpanBased  |  38.7 ns |        64 B |

Always run BenchmarkDotNet in Release configuration, never under a debugger, and never on a laptop actively running other CPU-hungry processes — thermal throttling and background scheduler noise are enough to flip which of two implementations looks faster. Once you have a baseline, keep the benchmark project in the repo permanently: it turns "I think this is faster" into a number you can paste into a PR description and re-run the moment someone questions whether a later change regressed it.

Summary & Key Takeaway

Reach for Span<T> and ArrayPool<T> first — they remove allocation pressure at the source, and they work in every deployment model. Reach for Native AOT when cold-start time or image size is an explicit requirement, and budget real time for trim-compatibility work before you commit to it. Leave GC tuning for last, and only after dotnet-counters or dotnet-trace has actually shown you a GC-bound workload — tuning a collector you haven't profiled is how teams end up chasing a problem that was never there. Whatever you change, let BenchmarkDotNet be the thing that decides if it worked, not intuition.

← All guides