The Fasting Developer: Memory Discipline and GC Awareness in .NET
"True strength is not the capacity to consume endlessly, but the wisdom to need less."
The Illusion of Infinite Memory
There is a quiet assumption baked into most .NET codebases: the Garbage Collector will always clean up. And technically, it will. But the cost of that cleanup — paid in latency, in CPU time, in unpredictable pauses — is what most developers never stop to measure.
Picture the managed heap as three holding areas with very different eviction rates. Gen 0 turns over constantly — it's cheap to sweep and does most of the work. Gen 1 exists mainly as a waiting room: objects land there after outliving one sweep, without yet earning a permanent spot. Gen 2 is where things go to stay, and clearing it out is the most expensive operation the collector performs. The failure mode worth internalizing: anything still referenced when a sweep runs doesn't get collected — it gets bumped up a tier. So an object you meant to be temporary, if it happens to be alive at the wrong moment, can quietly work its way into Gen 1 and then Gen 2, where it's now costing you every time a full collection runs.
Under sustained traffic, this is often what latency spikes look like on your dashboards. Not a bug. Not network jitter. Your own allocations, quietly returning to haunt you.
The discipline begins here: design code so short-lived objects never survive their first collection.
Find Your Allocations First — Don't Guess
Developers optimize code they think is slow. The GC taxes code that actually allocates. These are rarely the same place.
dotnet-trace (built into the .NET SDK) collects allocation traces from any running process:
dotnet-trace collect --process-id <PID> \
--providers Microsoft-DotNETRuntime:0x1:5 \
-o trace.nettrace
Open the output in PerfView (Windows) or convert it for SpeedScope with --format Speedscope. The resulting flame graph shows exactly which call paths are driving heap pressure.
BenchmarkDotNet with [MemoryDiagnoser] gives you per-method allocation counts before any code ships:
[MemoryDiagnoser]
public class ParserBenchmarks
{
private const string Token =
"TXT-2026-99482";
[Benchmark(Baseline = true)]
public int WithStringSplit() =>
ParseWithSplit(Token);
[Benchmark]
public int WithSpan() =>
ParseWithSpan(Token);
}
The allocated bytes column is humbling the first time you see it. It is also the first number that matters when reducing GC pressure.
The Practices
1. Value Types for Short-Lived Data
Every class is a heap allocation the GC must eventually collect. Structs, by contrast, avoid an additional heap allocation of their own and often live on the stack when used as local values.
The rule of thumb: structs work well when they are small (≤16 bytes), immutable, and short-lived. Use readonly struct to enforce this and prevent defensive copies:
// Heap — tracked, collected
public class RequestContext
{
public string UserId;
public DateTime Timestamp;
}
// Local value type — typically stack allocated
public readonly struct RequestContext
{
public readonly string UserId;
public readonly DateTime Timestamp;
}
One caveat: value types are copied on assignment. Use in parameters to pass large structs by reference without mutation risk.
2. Span<T> and stackalloc — Zero-Allocation Reads and Writes
ReadOnlySpan<T> is a stack-only window into existing memory. It does not allocate. It points. Combined with stackalloc, you can also create small fixed-size buffers directly on the stack, bypassing the heap entirely.
Reading without allocation — parsing TXT-2026-99482:
// string.Split: allocates a string[]
// and multiple substrings
public static int ParseWithSplit(string raw) =>
int.Parse(raw.Split('-')[2]);
// Span: zero allocations,
// operates on existing string memory
public static int ParseWithSpan(string raw)
{
ReadOnlySpan<char> span = raw.AsSpan();
int last = span.LastIndexOf('-');
return last == -1 ? -1
: int.TryParse(span.Slice(last + 1),
out int id) ? id : -1;
}
BenchmarkDotNet typically shows the split version at 72–96 bytes per call, the span version at 0 bytes.
Writing without allocation — stackalloc for small, known-size buffers:
public static string FormatCorrelationId
(int tenantId, int requestId)
{
// Buffer lives on the stack
// no heap involvement
Span<char> buffer = stackalloc char[32];
bool success = tenantId.TryFormat
(buffer, out int written)
&& ':'.TryFormat
(buffer.Slice(written), out _)
// simplified
&& requestId.TryFormat
(buffer.Slice(written + 1), out int w2);
return success ? buffer.Slice
(0, written + 1 + w2).
ToString() : string.Empty;
}
Keep stackalloc buffers small (under 1KB is a practical ceiling — the thread stack is ~1MB and shared with the call chain). For anything larger, use ArrayPool.
Span<T> is a ref struct — it cannot cross async/await boundaries or be stored as a field. For async code paths, use Memory<T>, which is the heap-safe equivalent.
3. string.Create — Building Strings Without Intermediates
Span<T> covers reading. string.Create covers the other side: constructing new strings without StringBuilder or concatenation allocations.
string.Create allocates the final string exactly once, then hands you a writable Span to fill it in directly. No intermediate buffer, no extra copy:
// Allocates: "TXT-" string, year string,
// "-" string, id string, final concat
public static string BuildToken
(int year, int id) =>
$"TXT-{year}-{id}";
// Allocates: the final string only
public static string BuildToken
(int year, int id) =>
string.Create(20, (year, id),
static (span, state) =>
{
"TXT-".AsSpan().CopyTo(span);
state.year.TryFormat
(span.Slice(4), out int w1);
span[4 + w1] = '-';
state.id.TryFormat
(span.Slice(5 + w1), out _);
});
The static lambda is intentional — it prevents closure captures, which themselves cause hidden allocations (more on that below). string.Create is the right tool whenever you are building a string from structured parts in a hot path.
4. SearchValues<T> — Vectorized Character Search (.NET 8+)
Once you've eliminated unnecessary allocations, CPU efficiency becomes the next bottleneck.
If your hot path scans strings for specific characters — delimiters, invalid characters, token boundaries — the standard IndexOfAny(char[]) rebuilds its lookup on every call. In .NET 8, SearchValues pre-computes a vectorized lookup table once, then reuses it across all searches:
// Old — rebuilds lookup on every call
int idx = span.IndexOfAny
(new[] { ',', ';', '|' });
// .NET 8 — compute once,
// search many times at SIMD speed
private static readonly
SearchValues<char> _delimiters =
SearchValues.Create(",;|");
public static int FindDelimiter
(ReadOnlySpan input) =>
input.IndexOfAny(_delimiters);
Declare SearchValues<T> as a static readonly field so the pre-computation happens once at startup. The search itself uses SIMD instructions where available — on large inputs the throughput difference is substantial. It pairs naturally with Span<T> since IndexOfAny accepts ReadOnlySpan<char> directly.
5. ArrayPool<T> for Buffer Reuse
When you need a real buffer — to read a stream, decode a payload, batch-write data — the reflexive answer is new byte[4096]. This allocates on every call, immediately feeds the GC, and increases GC pressure under load.
ArrayPool<T>.Shared lets you lease a pre-allocated buffer and return it when done:
private static readonly
ArrayPool<byte> _pool = ArrayPool<byte>.Shared;
public async Task ProcessRequestAsync
(Stream body)
{
byte[] buffer = _pool.Rent(4096);
try
{
int bytesRead = await
body.ReadAsync
(buffer.AsMemory(0, 4096));
ProcessPayload
(buffer.AsSpan(0, bytesRead));
}
finally
{
_pool.Return(buffer,
clearArray: true);
// Always return, even on exceptions
}
}
Two habits pay for themselves here. First, put the return call in a finally block — skip it and you're not leaking memory exactly, but you are quietly starving the pool, forcing it to keep minting fresh arrays instead of reusing what's already warm. Second, don't trust what's inside a freshly rented array. It's recycled, not reset, so whatever the last borrower wrote might still be sitting in there. If stale bytes could leak something sensitive or corrupt your logic, either wipe it yourself or pass clearArray: true on the way back in.
6. Closure and ValueTask — The Hidden Allocators
Lambda expressions that capture outer variables cause the compiler to generate a hidden class holding that state. Every invocation allocates an instance of it:
// 'threshold' is captured
// hidden class allocated on every call
var filtered = items.Where
(x => x > threshold).ToList();
In a middleware pipeline at high throughput, this is a steady drizzle of allocations. The fix: use explicit loops in the hottest paths, or cache non-capturing delegates as static fields.
Similarly, Task-returning methods allocate a Task object even when the result is available synchronously. ValueTask avoids this on the fast path:
public ValueTask<int>
GetCachedValueAsync(string key)
{
if (_cache.TryGetValue
(key, out int value))
return ValueTask.FromResult(value);
// No allocation
return new ValueTask<int>
(FetchFromSourceAsync(key));
// Allocates only when needed
}
ValueTask should be reserved for performance-critical APIs because it has usage constraints and can be misused more easily than Task.
The Stillness After Optimization
When these disciplines are applied consistently, the change in runtime behavior is visible and measurable. Gen 0 collection frequency drops. Gen 1 and Gen 2 collections become rare events rather than a constant background tax. Latency percentiles — particularly p99 and p999, where GC pauses live — compress. CPU utilization falls because the collector is no longer competing with your application for cycles.
This is what the spiritual framing calls sthirata — stillness. In engineering terms it is predictability: a system that behaves the same at 1,000 requests per second as it does at 100,000. That predictability is not just a performance characteristic. It is a design quality, reflecting a developer who has made conscious choices rather than accepting defaults.
The GC will always be there. Whether it runs quietly in the background or constantly interrupts your application is largely your decision.
Just as fasting is not about deprivation but about avoiding unnecessary consumption, efficient software is not about using less memory at all costs. It is about allocating only what the application genuinely needs.
Profile. Optimize with evidence. Measure throughout.
That’s all for now. May your intention be clear and your mind be still. With this quiet wish, I rest my pen and return to the silence.