Reducing allocations in hot paths
GC pauses are latency spikes. Every object allocated on the heap is a potential pause later when the garbage collector needs to reclaim it. If you're building high-throughput services—trading platforms, real-time APIs, ride-hailing backends—those microseconds add up fast. Reducing allocations in hot paths isn't about premature optimization; it's about understanding where your code spends 90% of its time and cutting unnecessary heap pressure there.
I've seen trading engines stall for 50ms on a single GC pause, watched ride-hailing dispatch miss time-critical assignments, and debugged e-commerce checkouts that timeout under load because someone was allocating a new collection in a loop that runs 100,000 times per second. The fix isn't always complex—it's methodical. Profile first, measure second, optimize third.
Find the hot path first — Profile with dotnet-counters and dotnet-trace
You cannot optimize what you don't measure. Too many developers guess where allocations happen, then spend days chasing the wrong code. I start every allocation investigation with dotnet-counters and dotnet-trace.
dotnet-counters gives you real-time GC statistics. Run this against your production app or a load test:
dotnet-counters monitor --processId 1234 System.RuntimeWatch Allocation Rate and GC Pause Duration. If allocation rate is 500+ MB/sec and GC pauses spike above 10ms under normal load, you have a problem worth investigating.
Then use dotnet-trace to capture what's actually allocating:
dotnet-trace collect --processId 1234 --duration 00:00:05 --format speedscopeOpen the output in Speedscope or Visual Studio's profiler. Look for allocation hotspots. I once traced a CRM backend and found that a single validation method running 50,000 times per day was allocating 2GB because it was creating intermediate LINQ enumerables. The code ran fine; the allocation profile was catastrophic.
The Pareto principle holds: 80% of allocations usually come from 20% of your code. Find that 20%, measure it, then decide if it matters for your latency budget. If your API's p99 is 200ms and you shave 5ms from a rarely-called path, you've wasted effort.
Pool buffers — ArrayPool<T>.Shared.Rent instead of new byte[]
Temporary buffers—parsing, encoding, streaming—are some of the easiest allocations to eliminate. Instead of allocating a new byte[], rent from the shared pool:
// Before: allocates every call
public byte[] EncodeMessage(string message)
{
byte[] buffer = new byte[1024];
Encoding.UTF8.GetBytes(message, 0, message.Length, buffer, 0);
return buffer;
}
// After: rents from pool, caller must return
public int EncodeMessage(string message, byte[] buffer)
{
return Encoding.UTF8.GetBytes(message, 0, message.Length, buffer, 0);
}
// Caller-side usage
byte[] buffer = ArrayPool<byte>.Shared.Rent(1024);
try
{
int written = EncodeMessage("Hello", buffer);
// Use buffer[0..written]
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}This pattern is crucial in streaming scenarios. I worked on a travel platform that processed booking confirmations in real-time. Each confirmation involved parsing JSON, validating data, and serializing responses. By pooling buffers in the JSON pipeline, we cut allocation rate by 40% and p99 latency by 20ms.
One caveat: renting larger buffers than needed is fine—pools reuse them. But don't rent unnecessarily large buffers or forget to return them. A leaked pool buffer isn't a leak in the traditional sense, but it's wasted capacity.
Avoid LINQ in tight loops — Closures and enumerators allocate
LINQ is beautiful until it runs 100,000 times per second. Each .Where(), .Select(), or .OrderBy() allocates an enumerator and potentially a closure if you're using local variables. Look at this real scenario from a SaaS metrics dashboard:
// Hot path: called per event, thousands per second
public decimal CalculateAverage(List<Event> events, string type)
{
// Allocates: enumerator + closure capturing 'type'
return events.Where(e => e.Type == type).Average(e => e.Value);
}
// Better: explicit loop, zero allocations
public decimal CalculateAverage(List<Event> events, string type)
{
decimal sum = 0;
int count = 0;
for (int i = 0; i < events.Count; i++)
{
if (events[i].Type == type)
{
sum += events[i].Value;
count++;
}
}
return count > 0 ? sum / count : 0;
}The loop version is faster and allocates nothing. Yes, it's less expressive. But if this method is in the critical path, expressiveness is secondary.
That said, don't dogmatically avoid LINQ everywhere. If a method is called a handful of times per request, use LINQ. Clarity matters. The rule is: profile first, then eliminate LINQ only where allocation rate actually hurts latency.
Use structs carefully — Small readonly structs avoid heap allocations; large structs copy expensively
Structs are value types—allocated on the stack, not the heap. They're appealing for reducing GC pressure, but they have a cost: copying. A 1KB struct passed by value gets copied on every parameter pass. On the flip side, a small struct that wraps an ID or a timestamp avoids a heap allocation and its eventual collection.
Use small readonly structs for domain values:
// Small readonly struct: stack-allocated, no GC pressure
public readonly struct OrderId
{
private readonly long value;
public OrderId(long value) => this.value = value;
public override string ToString() => value.ToString();
}
// Larger struct: copying becomes expensive
public struct OrderData
{
public OrderId Id;
public string CustomerName;
public string Address;
public List<OrderLineItem> Items;
public DateTime CreatedAt;
// ... more fields
}
// When returned or passed, OrderData copies entirely—expensive
public OrderData GetOrder(OrderId id)
{
return new OrderData { Id = id, CustomerName = "...", /* ... */ };
}I've debugged hot paths where a struct larger than 128 bytes was being copied millions of times. Switching to a reference type (class) with explicit allocations at boundaries actually reduced overall allocations because fewer copies occurred.
Guideline: if your struct is smaller than 16 bytes and immutable, stack allocation wins. If it's larger than 64 bytes, reconsider.
Source-generated JSON — System.Text.Json source generators remove reflection and cut startup and allocations
Reflection-based serialization allocates type metadata, method info, and temporary buffers. System.Text.Json's source generators—available since .NET 5—generate serialization code at compile time. Zero reflection, minimal allocations.
Enable it with a context class:
// Define your serialization context
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(List<OrderDto>))]
public partial class AppJsonSerializerContext : JsonSerializerContext { }
// Use it
var options = AppJsonSerializerContext.Default.Options;
string json = JsonSerializer.Serialize(order, options);
OrderDto dto = JsonSerializer.Deserialize<OrderDto>(jsonString, options);On a CRM backend I worked on, source generators cut JSON deserialization allocations by 60% and improved throughput by 15% under load. The startup cost of reflection disappeared.
If you're still using Newtonsoft.Json for new projects, consider migrating. System.Text.Json is now mature, and the allocation difference is significant at scale.
Common mistakes
- Not measuring. You'll optimize the wrong thing. Always profile first.
- Premature optimization of cold paths. That method called once per hour doesn't need optimization. Focus on the 5% that runs constantly.
- Forgetting to return pooled buffers. Set up try-finally guards every time you rent from ArrayPool.
- Using large structs by value. If your struct is 200 bytes and you pass it around, you're copying 200 bytes repeatedly. Use a class or take by reference (though ref has its own gotchas in async code).
- Replacing all LINQ with loops instantly. Code maintainability matters. Optimize hot paths, not everything.
- Assuming source-generated JSON is always faster. It's faster and allocates less, but if you're serializing once at startup, the improvement is invisible. Still do it for consistency.
Key takeaways
- Profile with
dotnet-countersanddotnet-tracebefore optimizing. The 20% of code causing 80% of allocations is usually invisible without measurement. - Use
ArrayPool<T>.Sharedfor temporary buffers and always return them in a finally block. - Avoid LINQ (and its enumerators and closures) only in tight loops that run thousands of times per second. Elsewhere, prioritize clarity.
- Small readonly structs (<16 bytes) are excellent for domain values; large structs copy expensively and may defeat the purpose.
- Use System.Text.Json source generators for all serialization. Compile-time code generation beats runtime reflection.
- Reducing allocations in hot paths is a discipline: measure, identify, optimize one thing at a time, measure again. GC pauses are latency spikes, and every microsecond saved compounds under load.






Comments
Loading comments…