.NET 10 Memory & GC Optimization — Span<T>, ArrayPool, DATAS and Zero-Allocation Patterns

Posted on: 4/26/2026 5:15:58 AM

Have you ever seen your .NET application consuming 4GB of RAM in production while only handling a few thousand requests per second? Or GC pause times suddenly spiking to 200ms, pushing P99 latency beyond SLA thresholds? The root cause is usually not a memory leak — it's unoptimized allocation patterns. .NET 10 introduces major Garbage Collection changes with DATAS, alongside a mature zero-allocation API ecosystem that can reduce memory allocations by up to 90% in hot paths without writing any unsafe code.

78x Performance with ArrayPool vs new byte[]
0 B Allocation with Span<T> parsing
75% Fewer Gen2 GC collections
~4ms Average GC pause time (.NET 10)

Garbage Collection in .NET 10 — DATAS Changes the Game

.NET 10 introduces DATAS (Dynamic Adaptation To Application Sizes) — enabled by default for Server GC. This isn't a minor feature but a fundamental shift in how the GC manages the heap.

How Does DATAS Work?

Before .NET 10, Server GC created one heap per CPU core. On a 28-core machine, you'd have 28 heaps — each with its own budget, pushing the total memory footprint to multiple GB even when the application only needed a few hundred MB. DATAS completely changes this: instead of scaling with core count, heap size dynamically adjusts based on Live Data Size (LDS) — the actual amount of live data in the heap.

graph LR
    subgraph Before[".NET 9 Server GC"]
        direction TB
        C1["Core 1
Heap: 512MB"] --- C2["Core 2
Heap: 512MB"] C2 --- C3["Core 3
Heap: 512MB"] C3 --- C4["...
Core N"] C4 --- T1["Total: N × 512MB"] end subgraph After[".NET 10 DATAS"] direction TB D1["Heap scales
with LDS"] --- D2["Budget computed
via formula"] D2 --- D3["Auto-shrinks
when idle"] D3 --- T2["Total: ~LDS × factor"] end style Before fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style After fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style T1 fill:#ff9800,stroke:#fff,color:#fff style T2 fill:#4CAF50,stroke:#fff,color:#fff

Figure 1: Heap sizing comparison between traditional Server GC and DATAS

DATAS maintains a target called Throughput Cost Percentage (TCP) — defaulting to 2%, meaning GC pause time should account for at most 2% of total execution time. The Budget Computed via DATAS (BCD) formula:

m = (20 - conserve_memory) × 1000 / sqrt(LDS)
m = min(max_m, m)    // max_m default: 10
m = max(min_m, m)    // min_m default: 0.1
BCD = LDS × m

Real-World Results from Microsoft

A Microsoft customer running a production workload: without DATAS, gen0 budget was 4.22GB with 1.2% pause time. With DATAS, gen0 budget dropped to 1.64GB but pause time increased to 2.1%. After increasing GCDGen0GrowthPercent by 2.6x, throughput recovered to previous levels while still significantly saving memory.

Configuring DATAS for Production

DATAS can be tuned via parameters in runtimeconfig.json or environment variables:

ParameterDefaultPurpose
GCDynamicAdaptationMode1 (on)Enable/disable DATAS. Set to 0 to disable
GCDTargetTCP2%Target pause time % — increase to prioritize memory, decrease for throughput
GCDGen0GrowthPercent100Multiplier for BCD. Set 260 = 2.6x budget
GCDGen0GrowthMinFactor0.1Floor for factor m — increase if you need a larger minimum budget
GCHeapCountautoIf set manually, DATAS is automatically disabled
GCHeapHardLimitunsetHard cap for heap size — containers should set based on cgroup limit
{
  "runtimeOptions": {
    "configProperties": {
      "System.GC.Server": true,
      "System.GC.DynamicAdaptationMode": 1,
      "System.GC.HeapHardLimit": 209715200
    }
  }
}

When NOT to Use DATAS

You should disable DATAS (GCDynamicAdaptationMode: 0) when: (1) Your app needs extremely fast startup — DATAS starts with fewer heaps, causing initial throughput regression. (2) Throughput-sensitive workloads that cannot tolerate any regression. (3) Dedicated machines with no plans to utilize freed memory. (4) Workloads with heavy temporary Large Object allocations causing constant gen2 GCs.

Escape Analysis — Automatic Stack Allocation in .NET 10

The JIT compiler in .NET 10 can detect objects that don't escape the method scope and automatically place them on the stack instead of the heap. This means zero GC pressure for local allocations without any code changes.

// .NET 9: heap-allocated — 7.7ns, 72B allocated
// .NET 10: escape analysis → stack — 3.9ns, 0B allocated
int[] ComputeLocal()
{
    var arr = new int[3]; // JIT detects arr doesn't escape
    arr[0] = 1;
    arr[1] = 2;
    arr[2] = arr[0] + arr[1];
    return null; // arr isn't returned → stack-allocated
}

// Delegates are also optimized
// .NET 9: 18,983ns, 88B → .NET 10: 6,292ns, 24B
void ProcessWithDelegate()
{
    int factor = 42;
    Func<int, int> multiply = x => x * factor;
    // Closure doesn't escape → DisplayClass is stack-allocated
    Console.WriteLine(multiply(10));
}

Escape Analysis Works Automatically

You don't need to change a single line of code. Just upgrade to .NET 10 and the JIT will analyze and optimize automatically. However, understanding this mechanism helps you write escape-analysis-friendly code — for example, avoiding unnecessary object returns or storing references in fields when they're only used locally.

Span<T> and Memory<T> — Zero-Allocation Data Processing

Span<T> is a ref struct — a lightweight view over contiguous memory that creates no allocations or data copies. It's the most important tool for eliminating allocations in hot paths.

graph TB
    subgraph Tools["Choose the Right Tool"]
        S["Span<T>
Sync, stack-only
Zero-alloc slicing"] M["Memory<T>
Async-compatible
Heap-safe wrapper"] A["ArrayPool<T>
Buffer reuse ≥ 8KB
Prevents LOH fragmentation"] ST["stackalloc
Ultra-fast < 8KB
Auto-freed"] P["Pipelines
Streaming I/O
Backpressure"] end S -->|".Span"| M M -->|"MemoryPool"| A ST -->|"implicit"| S A -->|"auto-pooled"| P style S fill:#e94560,stroke:#fff,color:#fff style M fill:#4CAF50,stroke:#fff,color:#fff style A fill:#2196F3,stroke:#fff,color:#fff style ST fill:#ff9800,stroke:#fff,color:#fff style P fill:#9C27B0,stroke:#fff,color:#fff

Figure 2: Zero-allocation API selection map in .NET

String Parsing with Span — Eliminating Substring and Split

Every call to string.Substring() or string.Split() creates a new string on the heap. In hot paths processing millions of requests, this becomes a massive allocation source. ReadOnlySpan<char> lets you slice strings without any allocations:

// ❌ Traditional: each Substring creates a new heap string
string ParseTraditional(string line)
{
    var parts = line.Split(',');         // string[] allocation
    var id = parts[0];                   // string allocation
    var name = parts[1].Trim();          // string allocation
    return $"{id}:{name}";              // string allocation
}

// ✅ Zero-allocation with Span
static (int id, ReadOnlySpan<char> name) ParseWithSpan(ReadOnlySpan<char> line)
{
    int comma = line.IndexOf(',');
    var idSpan = line[..comma];
    int id = int.Parse(idSpan);          // 0 allocation
    var name = line[(comma + 1)..].Trim();  // 0 allocation, slices original
    return (id, name);
}

Memory<T> for Async Workflows

Span<T> is a ref struct and cannot be used across await boundaries. For async scenarios, use Memory<T>:

// Memory<T> — safe across await boundaries
async Task<int> ReadAndProcessAsync(Stream stream)
{
    using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(4096);
    Memory<byte> buffer = owner.Memory;

    int bytesRead = await stream.ReadAsync(buffer);

    // Convert to Span for synchronous processing
    ProcessData(buffer.Span[..bytesRead]);

    return bytesRead;
    // owner.Dispose() automatically returns buffer to pool
}

ArrayPool<T> — Buffer Reuse, Eliminating LOH Fragmentation

Any allocation ≥ 85KB goes to the Large Object Heap (LOH). The LOH isn't compacted during normal GC, leading to fragmentation — the application uses increasingly more memory even though actual data doesn't grow. ArrayPool<T> solves this problem completely.

graph TB
    subgraph Without["Without Pool"]
        R1["Request 1"] --> A1["new byte[64KB]"]
        R2["Request 2"] --> A2["new byte[64KB]"]
        R3["Request 3"] --> A3["new byte[64KB]"]
        A1 --> GC["GC Pressure ↑"]
        A2 --> GC
        A3 --> GC
        GC --> F["LOH Fragmentation"]
    end
    subgraph With["With ArrayPool"]
        R4["Request 1"] --> P["ArrayPool.Rent()"]
        R5["Request 2"] --> P
        R6["Request 3"] --> P
        P --> B["Reuse same buffer"]
        B --> RET["Pool.Return()"]
        RET --> P
    end
    style Without fill:#f8f9fa,stroke:#ff9800,color:#2c3e50
    style With fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
    style GC fill:#ff9800,stroke:#fff,color:#fff
    style F fill:#e94560,stroke:#fff,color:#fff
    style B fill:#4CAF50,stroke:#fff,color:#fff

Figure 3: ArrayPool eliminates repeated allocations and LOH fragmentation

// ✅ Standard ArrayPool pattern
public async Task ProcessUploadAsync(Stream uploadStream)
{
    byte[] buffer = ArrayPool<byte>.Shared.Rent(81920); // 80KB
    try
    {
        int bytesRead;
        while ((bytesRead = await uploadStream.ReadAsync(
            buffer.AsMemory(0, buffer.Length))) > 0)
        {
            await ProcessChunkAsync(buffer.AsMemory(0, bytesRead));
        }
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
    }
}

// ❌ NEVER: forget Return in finally
// ❌ NEVER: Return an array not rented from the pool
// ❌ NEVER: use the buffer after Return

clearArray: true — Mandatory for Sensitive Data

When handling credentials, PII, or financial data, always use clearArray: true when returning buffers to the pool. Otherwise, the next request renting the same buffer could read the previous request's data — a serious information leakage vulnerability in multi-tenant systems.

Benchmark: ArrayPool vs new byte[]

MethodTimeAllocationGC Gen0
new byte[64KB]3,450 ns65,536 B15.2/1000 ops
ArrayPool.Rent(64KB)44 ns0 B0/1000 ops
stackalloc 4KB~0 ns0 B0/1000 ops

stackalloc — Ultra-Fast Temporary Memory on the Stack

stackalloc allocates memory directly on the stack — zero GC overhead, automatically freed when the method exits. However, stack size is limited (typically 1-4MB, containers may only have 512KB), so only use it for small buffers under 8KB.

// Number formatting without temporary string creation
void LogMetric(int value, ILogger logger)
{
    Span<char> buffer = stackalloc char[16];
    if (value.TryFormat(buffer, out int written))
    {
        logger.LogInformation("Metric: {Value}", buffer[..written].ToString());
    }
}

// Safe pattern: fallback when size is too large
void ProcessData(int size)
{
    Span<byte> buffer = size <= 1024
        ? stackalloc byte[size]
        : new byte[size]; // Fallback to heap if too large for stack

    FillBuffer(buffer);
}

Caution with stackalloc in Loops

Each loop iteration does NOT free the stackalloc. If you stackalloc in a loop 1000 times, each time 4KB = 4MB stack consumed → StackOverflowException. Always stackalloc outside the loop or use ArrayPool for such cases.

System.IO.Pipelines — High-Performance Streaming I/O

Pipelines solve a classic problem: every stream read creates a new byte[8192] → GC pressure scales linearly with throughput. Pipelines automatically pool buffers, support backpressure, and enable zero-copy processing.

// Zero-allocation JSON log ingestion with Pipelines + Utf8JsonReader
[HttpPost("/api/logs/ingest")]
public async Task<IActionResult> IngestLogsAsync()
{
    var reader = Request.BodyReader;

    while (true)
    {
        ReadResult result = await reader.ReadAsync();
        ReadOnlySequence<byte> buffer = result.Buffer;

        // Process each message in the buffer
        while (TryParseLogEntry(ref buffer, out var entry))
        {
            await _logService.StoreAsync(entry);
        }

        reader.AdvanceTo(buffer.Start, buffer.End);
        if (result.IsCompleted) break;
    }

    return Ok();
}

private bool TryParseLogEntry(
    ref ReadOnlySequence<byte> buffer, out LogEntry entry)
{
    var jsonReader = new Utf8JsonReader(buffer, isFinalBlock: false, default);
    // Utf8JsonReader reads directly from buffer — zero copy
    // ...
}

Benchmark: Comparing 3 JSON Ingestion Methods

MethodAllocation/RequestThroughputP99 Latency
ASP.NET model binding~35 KB5,000 req/s120 ms
ArrayPool optimization~4 KB15,000 req/s40 ms
Pipelines + Utf8JsonReader~100 B52,000 req/s7 ms

When to Use Pipelines?

Pipelines are best suited for: custom network servers (TCP/UDP), custom protocol parsing, streaming data ingestion (logs, metrics, events), and file processing pipelines. For typical REST APIs, ArrayPool + Span is usually sufficient — Pipelines add complexity that's only worthwhile when throughput requirements are truly high.

Object Pooling with ObjectPool<T>

Beyond buffers, complex objects can also be pooled. ASP.NET Core provides ObjectPool<T> with the IResettable interface (new in .NET 10) for automatic state reset when returning to the pool:

// Register pool in DI
builder.Services.AddSingleton<ObjectPool<StringBuilder>>(sp =>
{
    var policy = new StringBuilderPooledObjectPolicy
    {
        InitialCapacity = 256,
        MaximumRetainedCapacity = 4096
    };
    return new DefaultObjectPool<StringBuilder>(policy, maximumRetained: 64);
});

// Usage in controller/service
public string BuildReport(IEnumerable<MetricPoint> metrics)
{
    var sb = _pool.Get();
    try
    {
        foreach (var m in metrics)
        {
            sb.Append(m.Name).Append(": ").AppendLine(m.Value.ToString());
        }
        return sb.ToString();
    }
    finally
    {
        _pool.Return(sb); // StringBuilder.Clear() automatic via policy
    }
}

Monitoring GC in Production

Optimizing without measuring is flying blind. .NET 10 provides several tools for observing real-time GC behavior:

dotnet-counters — Real-time GC Metrics

# Monitor GC metrics in real-time
dotnet-counters monitor --process-id <PID> \
  --counters System.Runtime[gc-heap-size,alloc-rate,gen-0-gc-count,gen-1-gc-count,gen-2-gc-count,gc-pause-time]

# Export for Prometheus/Grafana
dotnet-counters collect --process-id <PID> \
  --format json --output gc-metrics.json

DATAS Diagnostic Events

DATAS publishes SizeAdaptationTuning events with detailed information:

Event FieldMeaningWarning Threshold
TotalSOHStableSizeApproximate Live Data SizeContinuous increase → memory leak
TcpToConsiderActual Throughput Cost Percentage> 5% → GC is too expensive
BCDBudget Computed via DATASToo small → increase GrowthPercent
// Programmatic GC monitoring in your app
var listener = new EventListener();
listener.EnableEvents(
    EventSource.GetEventSource("Microsoft-Windows-DotNETRuntime"),
    EventLevel.Informational,
    (EventKeywords)0x1); // GC keyword

// Or use .NET Metrics API
var meter = new Meter("App.GC.Monitoring");
var gcPauseHistogram = meter.CreateHistogram<double>("gc.pause.duration");

// Push to OpenTelemetry → Grafana dashboard

Memory Optimization Checklist for Production

Step 1: Measure Baseline
Use dotnet-counters or Application Insights to record current alloc-rate, GC pause time, and heap size. Don't optimize without data.
Step 2: Identify Hot Paths
Run a profiler (dotMemory, VS Diagnostic Tools) to find which methods allocate the most. Usually JSON serialization, string processing, and buffer I/O.
Step 3: Apply Span<T> for String Parsing
Replace Substring(), Split(), string.Format() with ReadOnlySpan<char> and string.Create() in hot paths.
Step 4: ArrayPool for Buffer I/O
Every new byte[N] with N ≥ 1KB in the request pipeline should switch to ArrayPool<byte>.Shared.Rent(N). Always Return in finally.
Step 5: Tune DATAS
Monitor TCP via SizeAdaptationTuning events. If > 3%, increase GCDGen0GrowthPercent. If memory is still high, decrease GCDTargetTCP.
Step 6: Container-Specific Tuning
Set GCHeapHardLimit = 75% of container memory limit. Let DATAS self-adjust within that range. Monitor OOMKilled events.

Common Pitfalls to Avoid

PitfallConsequenceSolution
stackalloc in loopsStackOverflowExceptionAllocate outside the loop or use ArrayPool
Span across awaitCompiler error (fortunately!)Use Memory<T> for async code
Forgetting ArrayPool ReturnPool exhaustion, memory leakAlways use try/finally
Boxing value typesHidden heap allocationUse generic constraints or interfaces
Capturing variables in lambdasCreates DisplayClass on heapUse static lambdas or pass state via parameter
ToString() on SpanCreates new string, breaks zero-allocKeep Span as long as possible before ToString

Case Study: 54.7% Memory Reduction in Production

A log processing system handling 50,000 req/s applied these techniques with the following results:

54.7% Daily memory usage reduction
75% Fewer Gen2 GC collections
+22% Throughput increase
7ms P99 latency (from 120ms)

Key changes: switched JSON parsing to Utf8JsonReader + Pipelines, replaced string.Split() with Span<char> slicing, applied ArrayPool for all I/O buffers, and enabled DATAS with GCDGen0GrowthPercent: 200.

Summary

Memory optimization in .NET 10 isn't meaningless micro-optimization — at tens of thousands of requests per second, every KB of allocation multiplies into GB of heap pressure. DATAS automates most GC tuning, escape analysis reduces allocations without code changes, and the Span<T> / ArrayPool / Pipelines toolkit gives you precise control over every byte allocated.

The golden rule: measure first, optimize hot paths, let DATAS handle the rest.

References