The allocation that hurts is often not the large object you notice in review. It is the tiny allocation inside a loop that runs for every request, row, chunk, token, or message.
Watch hot paths for Split(), Substring(), ToArray(), accidental LINQ chains, repeated formatting, closure captures, boxing, unnecessary materialization, and converting between strings, bytes, and streams more often than needed. Each call can look harmless in isolation. Under load, it can become GC pressure.
Closure captures are a good example because the allocation is not visible in the source:
public sealed record Item(int Id);
public static bool HasItem(IEnumerable<Item> items, int targetId)
{
return items.Any(item => item.Id == targetId);
}
public static bool HasItemHotPath(IReadOnlyList<Item> items, int targetId)
{
for (var i = 0; i < items.Count; i++)
{
if (items[i].Id == targetId)
{
return true;
}
}
return false;
}
The first version is readable and fine in normal application code. In a hot path, the captured targetId means the compiler needs extra state for the predicate. The second version is dull, but it removes that hidden capture and makes the allocation story easier to inspect.
Do not rewrite everything into allocation-free code. That makes normal application code harder to read and easier to break. Start with measured hotspots, then remove copies that do not need to exist.
For parsers and data pipelines, Span<T>, Memory<T>, pooled buffers, streaming APIs, and projection can help when the data can be processed without creating intermediate objects. For request handlers, the bigger win may be avoiding materializing huge payloads or database results. If dotnet-counters shows high allocation rate or constant Gen 0 collections, inspect these small patterns before reaching for a bigger rewrite.
The practical rule: if the code runs once, keep it clear. If it runs millions of times, check what it allocates before assuming the GC cost is acceptable.