.NET 10 Memory & GC Optimization — Span<T>, ArrayPool, DATAS and Zero-Allocation Patterns
Posted on: 4/26/2026 5:15:58 AM
Table of contents
- Garbage Collection in .NET 10 — DATAS Changes the Game
- Escape Analysis — Automatic Stack Allocation in .NET 10
- Span<T> and Memory<T> — Zero-Allocation Data Processing
- ArrayPool<T> — Buffer Reuse, Eliminating LOH Fragmentation
- stackalloc — Ultra-Fast Temporary Memory on the Stack
- System.IO.Pipelines — High-Performance Streaming I/O
- Object Pooling with ObjectPool<T>
- Monitoring GC in Production
- Memory Optimization Checklist for Production
- Common Pitfalls to Avoid
- Case Study: 54.7% Memory Reduction in Production
- Summary
- References
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.
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:
| Parameter | Default | Purpose |
|---|---|---|
| GCDynamicAdaptationMode | 1 (on) | Enable/disable DATAS. Set to 0 to disable |
| GCDTargetTCP | 2% | Target pause time % — increase to prioritize memory, decrease for throughput |
| GCDGen0GrowthPercent | 100 | Multiplier for BCD. Set 260 = 2.6x budget |
| GCDGen0GrowthMinFactor | 0.1 | Floor for factor m — increase if you need a larger minimum budget |
| GCHeapCount | auto | If set manually, DATAS is automatically disabled |
| GCHeapHardLimit | unset | Hard 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[]
| Method | Time | Allocation | GC Gen0 |
|---|---|---|---|
| new byte[64KB] | 3,450 ns | 65,536 B | 15.2/1000 ops |
| ArrayPool.Rent(64KB) | 44 ns | 0 B | 0/1000 ops |
| stackalloc 4KB | ~0 ns | 0 B | 0/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
| Method | Allocation/Request | Throughput | P99 Latency |
|---|---|---|---|
| ASP.NET model binding | ~35 KB | 5,000 req/s | 120 ms |
| ArrayPool optimization | ~4 KB | 15,000 req/s | 40 ms |
| Pipelines + Utf8JsonReader | ~100 B | 52,000 req/s | 7 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 Field | Meaning | Warning Threshold |
|---|---|---|
| TotalSOHStableSize | Approximate Live Data Size | Continuous increase → memory leak |
| TcpToConsider | Actual Throughput Cost Percentage | > 5% → GC is too expensive |
| BCD | Budget Computed via DATAS | Too 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
dotnet-counters or Application Insights to record current alloc-rate, GC pause time, and heap size. Don't optimize without data.Substring(), Split(), string.Format() with ReadOnlySpan<char> and string.Create() in hot paths.new byte[N] with N ≥ 1KB in the request pipeline should switch to ArrayPool<byte>.Shared.Rent(N). Always Return in finally.GCDGen0GrowthPercent. If memory is still high, decrease GCDTargetTCP.GCHeapHardLimit = 75% of container memory limit. Let DATAS self-adjust within that range. Monitor OOMKilled events.Common Pitfalls to Avoid
| Pitfall | Consequence | Solution |
|---|---|---|
| stackalloc in loops | StackOverflowException | Allocate outside the loop or use ArrayPool |
| Span across await | Compiler error (fortunately!) | Use Memory<T> for async code |
| Forgetting ArrayPool Return | Pool exhaustion, memory leak | Always use try/finally |
| Boxing value types | Hidden heap allocation | Use generic constraints or interfaces |
| Capturing variables in lambdas | Creates DisplayClass on heap | Use static lambdas or pass state via parameter |
| ToString() on Span | Creates new string, breaks zero-alloc | Keep 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:
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
- Preparing for the .NET 10 GC (DATAS) — .NET Blog
- Garbage collector config settings — Microsoft Learn
- What .NET 10 GC Changes Mean for Developers — Roxeem
- Memory Management Masterclass in .NET — DevelopersVoice
- Memory Optimization With ArrayPool in C# — Code Maze
- Garbage Collector & Memory Compaction Improvements in .NET 10 — Steve Bang
Cloudflare R2 - Zero Egress Object Storage for Developers
Strangler Fig Pattern — Safe Legacy Modernization with YARP and .NET
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.