Most performance problems in C# aren't where you think they are. The slowest line of code is rarely the one that looks suspicious; it's the innocent-looking LINQ chain in a hot loop, or the string concatenation that allocates a few hundred megabytes over an afternoon. This chapter is about finding those costs with real measurements, then fixing them with the tools the runtime gives you: Span<T>, stackalloc, ArrayPool<T>, value types, and a handful of patterns that keep the garbage collector out of your way.
A web service that handles 50 requests a second does not care if a method takes 200 microseconds or 250. The user can't tell. Spending a day shaving 50 microseconds off that path is a day you didn't spend on something that mattered.
The same service at 50,000 requests a second is a different program. Now those 50 microseconds, multiplied by every request, are the difference between fitting on one box and renting four. An extra allocation per request fills Gen 0 in seconds and trips a garbage collection that pauses every thread.
That's the rule: optimize where load and frequency make it pay. Three places usually qualify.
Everything else gets the readable, idiomatic version. A .ToList() in a config loader you call once at startup costs nothing. The same .ToList() in a request handler is real money.
Before optimizing anything, measure. Your guess about what's slow is almost certainly wrong. Profilers consistently surprise the people who wrote the code. And after you change something, you need to know whether you helped, hurt, or did nothing. "It feels faster" is not a measurement.
The standard tool for measuring C# code in isolation is BenchmarkDotNet, a NuGet package that runs a method many times under controlled conditions and reports mean, median, allocation, and confidence intervals. A minimal benchmark looks like this:
Running this with dotnet run -c Release produces output like:
The Concat version is 130 times slower and allocates 400 times more memory. You wouldn't have guessed that from reading the code.
A few rules for benchmarking:
[Benchmark] methods. BenchmarkDotNet discovers them through reflection.[MemoryDiagnoser] to track allocations.Console.WriteLine or DateTime.Now inside the measured method; you'll measure those instead of what you care about.The .NET garbage collector is good at its job, which is precisely why developers stop thinking about it. That's usually fine. In hot paths, it's not.
Every time you write new, you allocate on the managed heap. The GC tracks every allocation and, when the heap fills up, walks the live objects to find what's no longer referenced. While that walk runs, the threads that allocated those objects are paused. On a busy server, a long GC pause means latency spikes and dropped requests.
The GC organizes the heap into generations to make those walks cheaper.
New allocations start in Gen 0. If they survive a Gen 0 collection (something still holds a reference), they get promoted to Gen 1. Survive a Gen 1 collection and they move to Gen 2. Anything 85,000 bytes or larger goes straight to the Large Object Heap (LOH).
The cost of collecting each generation differs by orders of magnitude:
| Generation | When it runs | Typical cost |
|---|---|---|
| Gen 0 | Often (every few MB allocated) | Microseconds |
| Gen 1 | Less often | Tens of microseconds |
| Gen 2 | Rarely (full heap walk) | Milliseconds to seconds |
| LOH | With Gen 2 by default | Expensive, fragments easily |
Short-lived objects are cheap, long-lived ones are expensive. A temporary list inside a method that returns quickly costs almost nothing, because the list never leaves Gen 0. A list stored in a static field and updated for hours is fine too, because it pays the survival cost once and stays settled in Gen 2.
The expensive case is the midlife crisis: an object that survives long enough to reach Gen 2, then becomes garbage shortly after. A common cause is caching that's too aggressive: you hold a reference "just in case," the object gets promoted, and then you discard the cache. Repeat that pattern at scale and Gen 2 collections start showing up in your traces.
A Gen 2 collection can stop the world for tens of milliseconds on a multi-gigabyte heap. If your service has a 99.9th-percentile latency target measured in milliseconds, Gen 2 collections are a problem.
The remedy is to allocate less, not to allocate "more carefully". The sections that follow are mostly about doing work without producing garbage in the first place.
Strings are a major source of accidental allocations in C# code. They're immutable, so every "modification" produces a new string. Code that treats strings like ordinary variables ends up with an allocation graph that looks like a sawtooth.
Common ways to build a string, in order of allocation cost:
| Approach | Best for | Allocations |
|---|---|---|
String interpolation $"..." | Single combination, known parts | One string per call |
string.Concat / + | A few fixed parts | One string per call |
StringBuilder | Building in a loop | One buffer, doubled when full |
string.Create | Known length, fill into buffer | One string, no intermediate |
Interpolation wins for "combine these few pieces once." Since C# 10, the compiler turns $"{name}: ${price:F2}" into a DefaultInterpolatedStringHandler call that avoids intermediate boxing. For one-off combinations, this is the standard choice.
Concatenation with `+` is fine for two or three strings. The compiler usually collapses several + operations into a single String.Concat call, which allocates once. The trap is the loop.
That loop allocates 10 intermediate strings (each += creates a new one) and 10 interpolated strings. The total work is O(n^2) because each concat copies the whole accumulated string. At n=10 it's invisible. At n=10,000 it's a measurable freeze.
StringBuilder is the answer for loops. It maintains an internal char[] buffer that doubles when full, so the total work is O(n) instead of O(n^2). The cost is one allocation for the buffer and one more when you call ToString().
A typical benchmark for these three approaches building a 1,000-item string looks like this:
Two orders of magnitude. StringBuilder doesn't copy its accumulated state on each append; it writes into an existing buffer, growing only when it runs out of room.
A few rules of thumb:
+. Both fine.StringBuilder. Pre-size with new StringBuilder(capacity: 1024) when you can estimate.string.Create (advanced, zero intermediate allocation).string.Join(",", items). Don't roll your own with a loop.Default StringBuilder capacity is 16 characters. If you'll write thousands, set the capacity up front to avoid repeated buffer doublings. Each doubling copies everything that's already there.
Span<T> and Memory<T>Span<T> is one of the most important additions to modern .NET. It lets you reference a slice of an array, string, or stack memory without copying or allocating. Code that used to call Substring (which allocates a new string) can use a ReadOnlySpan<char> instead and pay zero.
The mental model: a Span<T> is a pointer plus a length, stored on the stack. It can point into any contiguous block of memory: a managed array, a stackalloc block, or unmanaged memory.
The classic example: parsing an order ID out of a CSV line.
That works and reads cleanly. The problem: Split allocates a string[] plus four new string instances, one per field. Multiply by a million lines and you've created five million short-lived strings.
The Span-based version walks the line in place:
Same result, zero string allocations. The slices are views into the original string. int.Parse and decimal.Parse accept ReadOnlySpan<char> directly (since .NET 6), so you never materialize a string for the number.
A few rules Span<T> enforces:
Span<T> is a ref struct. It can't be stored in a field, captured by a lambda, used in an async method across an await, or boxed. The compiler refuses.IndexOutOfRangeException, just like an array.Span<T> is read-write; ReadOnlySpan<T> is read-only.Memory<T> is the heap-friendly cousin. It can be stored in fields, captured in lambdas, and passed across await. To actually read or write the elements, you call .Span to get a Span<T>. Most application code uses Span<T> for synchronous methods and reaches for Memory<T> only when the buffer crosses an async boundary.
Slicing a string into a ReadOnlySpan<char> is free (one stack write). Slicing into a string via Substring allocates. In a hot parsing loop, that difference is often 5-10x in throughput.
A common pattern: a parser that returns parsed values without holding any reference to the source buffer.
ParseTotal accepts any source: a string, a slice of a char[], a stackalloc char[256]. The caller decides where the data lives.
stackalloc for Small BuffersSometimes you need a temporary buffer for a few hundred bytes, then you throw it away. Allocating a byte[] on the heap works but pays the GC tax. stackalloc allocates on the stack instead, which is free and cleaned up when the method returns.
The shape of a stackalloc:
The stackalloc char[32] reserves 32 chars (64 bytes) on the stack. No heap allocation, no GC bookkeeping. WriteOrderLabel writes into the span, returns the number of chars written, and the caller prints just that slice.
The rules:
stackalloc a megabyte; you'll overflow the stack. Stay under a few KB.stackalloc lives until the method returns. You can pass it as a Span<T> but can't return it or store it in a field.Span<int> buf = stackalloc int[10]; is safe. The bare stackalloc int[10] form requires unsafe and is rarely the standard choice.A common pattern combines stackalloc with a size check, falling back to a heap array for larger requests:
For small inputs the buffer is on the stack and costs nothing. For large inputs it falls back to a heap allocation. The method shape is the same either way.
stackalloc has zero allocation cost, but stack space is shared with method call frames. A deep recursion that stackallocs 1 KB per call can blow the stack at modest depths. Keep the size small and don't stackalloc in recursive methods.
ArrayPool<T> and MemoryPool<T>stackalloc handles small, short-lived buffers. For larger ones, especially across await boundaries, use `ArrayPool<T>`. Instead of allocating a fresh T[] each time, you rent one from a shared pool, use it, and return it. The pool keeps a set of pre-allocated arrays per size bucket.
Three things worth highlighting.
First, `Rent` may return a larger array than requested. Pools work in size buckets (16, 32, 64, ..., up to ~1 MB), so a request for 4,000 bytes may give back a 4,096-byte array. Track the logical length separately if it matters.
Second, `Return` is not automatic. If you forget, the pool allocates a new one next time. There's no leak, just a missed reuse. The try/finally pattern is the safe shape.
Third, the buffer's contents are undefined when rented. The pool doesn't clear it. Either pass clearArray: true to Return or clear the slice you wrote.
The buffer is rented once, reused across many operations, and returned at the end. A file with a million lines doesn't allocate a single new buffer for the reading itself.
MemoryPool<T> is similar but returns an IMemoryOwner<T> that wraps the pooled buffer. Disposing the owner returns it to the pool. It composes better with Memory<T> and async code at the cost of one wrapper allocation per rent. For synchronous tight loops, ArrayPool<T> is usually better.
Renting from ArrayPool<T>.Shared is roughly the cost of a few interlocked operations, plus the cost of clearing if you ask for it. That's much cheaper than a fresh allocation for buffers larger than ~1 KB, and it's free of GC pressure.
Whether to use a struct or a class is a performance choice as much as a design one. They differ in allocation behavior, copy semantics, and cache layout.
A class is a reference type. Each instance lives on the heap; variables hold a reference. Copying a variable copies the reference, not the object. The GC tracks it.
A struct is a value type. It lives wherever you put it: on the stack as a local, inline in an array, inline as a field. Copying copies the data. The GC doesn't track it.
When does a struct help?
OrderLine[] of 1 million structs hits the CPU cache predictably. The same data as classes forces a pointer dereference per element, with objects scattered across the heap.When does a struct hurt?
ref/in) copies the whole struct. A 200-byte struct passed to 10 methods copies 2 KB of data, much more than copying an 8-byte reference.readonly (covered below).object or to an interface allocates a copy on the heap. Here we'll quantify the cost.Here's a benchmark shape that shows the trade-off. We're aggregating prices from a million records, once stored as structs and once as classes.
Typical output:
The struct version is roughly twice as fast because the data is contiguous in memory; the class version follows a pointer per element. The setup cost differs too: _classes allocated a million heap objects, while _structs allocated one big array.
The picture flips for the same struct passed by value through many methods. A 200-byte struct passed as a parameter and assigned to a local is 400 bytes copied. Do that in a loop and you're moving more data than the class would have referenced.
A rough decision table:
| Scenario | Prefer |
|---|---|
| Small (<=16 bytes), short-lived, lots of them | struct |
| Large, passed across many methods | class |
| Stored in collections, iterated densely | struct (with care) |
| Has identity (this object vs that object) | class |
| Logically a value (Money, Point, DateRange) | struct |
| Mutable state, lifecycle, references to other things | class |
When you do choose a struct, make it `readonly` and pass it by `in` in hot paths. That's the next section.
readonly struct and in ParametersA plain struct has a hidden cost: every method call copies the whole struct defensively, in case the method mutates it. For small structs this is invisible. For larger ones, it just makes everything a little slower without showing up by name in a profile.
A `readonly struct` tells the compiler "this struct doesn't mutate itself," so the compiler stops making defensive copies.
Every field is readonly, the struct itself is readonly. The Add method returns a new Money rather than mutating this. The compiler can now treat Money as immutable and skip defensive copies.
The matching tool on the caller side is the `in` parameter modifier, which says "pass this struct by reference, but the callee promises not to mutate it."
Without in, a 100-byte struct would be copied onto the stack for the call. With in, only an 8-byte reference is passed.
in works best with readonly struct types. If you pass a non-readonly struct via in and call methods on it, the compiler still emits a defensive copy (because it can't prove the method won't mutate), defeating the optimization. Modern C# versions warn about this.
A defensive copy of a 64-byte struct on every method call can dominate a tight loop. readonly struct plus in typically wins 2-5x for medium-sized value types in hot paths.
A second use case for in: extension methods that operate on a struct without modifying it.
in this on an extension method means "pass the struct by readonly reference." The caller writes myTotal.GrandTotal() exactly as before; the compiler avoids the copy.
Boxing is a correctness issue (modifying a boxed copy doesn't change the original), but it also carries a performance cost.
Boxing is what happens when a value type is treated as a reference type. The runtime allocates a small heap object, copies the value in, and gives back a reference. Unboxing reverses it. The cost is the heap allocation plus the copy. For one box, microseconds. For a box per loop iteration over a million records, milliseconds and a Gen 0 pressure spike.
Boxing happens in places that don't look like boxing.
Passing a struct to a method expecting `object`:
The Log(object value) signature accepts object. Passing an int boxes it. Multiply by a million calls and you have a million tiny heap allocations.
`Equals(object)` on value types:
If you have a struct compared in a hot path, implement IEquatable<T> so Equals(T) is preferred over Equals(object).
Old-style collections: ArrayList and the non-generic Hashtable store everything as object. Adding an int boxes; reading unboxes. Use generics (List<int>, Dictionary<int, T>).
String formatting with old APIs: string.Format("price: {0}", price) boxes price because it takes params object[]. Interpolation with $"price: {price}" uses the interpolated string handler (since C# 10) and avoids boxing for most common types.
A benchmark to make the cost concrete. Two loops, each summing a million values, one through object, one through int:
Typical results:
A 36x speed difference, plus 24 MB of garbage from the boxing version. None of which the developer would have written intentionally; it shows up when an API accidentally takes object instead of a specific type.
Every box is a heap allocation (typically 16-24 bytes on 64-bit), a copy, and eventually a GC walk. In tight loops, prefer generic methods, IEquatable<T>, and avoid object parameters when a typed parameter works.
LINQ is wonderful. It's expressive, composable, and reads like the problem statement. It's also slower than a hand-written loop, and it allocates.
A Where(...).Select(...).ToList() chain does several things you don't see: builds an iterator for Where, another for Select, allocates a List<T> whose buffer grows as items are added, and calls a delegate per element through virtual dispatch. For 10 elements this is invisible; for 10 million, it shows up.
A benchmark filtering and summing prices:
Typical output:
The for-loop version is 3x faster and allocates nothing. That doesn't mean "never use LINQ." It means use LINQ for clarity, drop to a loop for hot paths.
A few practical rules:
ToList() once. Reiterating an unmaterialized LINQ chain re-runs every operator..Count property. Enumerable.Count() walks the sequence; the property is O(1).Any() stops at the first element.Most of the standard LINQ surface doesn't accept Span<T> because LINQ is built on IEnumerable<T>, and a ref struct can't be an IEnumerable. That's another reason hot paths drop out of LINQ.
ValueTask<T>Async methods aren't free. Every async method becomes a generated state machine that captures locals, tracks the current "step," and resumes when an awaited operation completes. The first time the method actually waits, the state machine is boxed onto the heap and held until the operation finishes.
For methods that always wait (network, file I/O), the cost is dwarfed by the operation itself. For methods that usually complete synchronously, the async machinery becomes a measurable overhead.
The shape that gets you: a wrapper that reads from a cache (synchronous, fast) and falls back to a remote call (async, slow).
Every cache hit pays for an allocated state machine, even though no waiting happens. At 90% cache hit rate, that's 90% wasted allocations.
ValueTask<T> is the fix. It's a struct that wraps either a synchronous result or a Task<T>. If the method completes synchronously, no Task is allocated and no state machine is boxed.
Now cache hits allocate nothing.
The rules for ValueTask<T>:
Task<T>, you can't await the same ValueTask<T> twice. If you need to, convert it with AsTask().Task<T> is simpler.Two more async tips:
return DoSomethingAsync(); instead of return await DoSomethingAsync();. (Caveat: if you have using or try/catch around the call, you need the await.)A Task<T> allocation is small (~80 bytes), but at 100,000 requests per second, that's 8 MB per second of garbage. ValueTask<T> eliminates it on the sync paths.
List<T> and Dictionary<TKey, TValue> Up FrontCollections grow by doubling. When a List<T> runs out of room, it allocates a new array twice the size, copies everything, and discards the old one. A list that ends up with 1,000 items goes through roughly 10 doublings.
The fix is trivial: if you know (or can estimate) the final size, pass it to the constructor.
Both produce the same list. The second one allocates once instead of about ten times. The difference is small on one list, real when you create thousands of them.
Dictionary<TKey, TValue> works the same way. Without a capacity hint, it starts small and rehashes (allocates a new bucket array, redistributes every entry) as it grows. With a capacity hint, it allocates the right size once.
Capacity hints are a free win. Even being off by 2x cuts the number of resizes in half.
A few other collection tips:
Dictionary or HashSet is O(1).Collection cost at a glance:
| Collection | Add | Lookup | Remove | Sizing tip |
|---|---|---|---|---|
List<T> | O(1) amortized | O(n) | O(n) | Pass capacity |
Dictionary<K,V> | O(1) | O(1) | O(1) | Pass capacity |
HashSet<T> | O(1) | O(1) | O(1) | Pass capacity |
Queue<T> | O(1) | O(n) | O(1) (Dequeue) | Pass capacity |
SortedDictionary<K,V> | O(log n) | O(log n) | O(log n) | Can't pre-size |
LinkedList<T> | O(1) (at node) | O(n) | O(1) (at node) | Rarely worth it |
The default .NET deployment model is JIT (just-in-time compilation): your assemblies ship as IL, and the runtime compiles them to native code as methods are first executed. JIT is flexible and benefits from runtime information, but it has a startup cost.
Native AOT compiles your entire app to native code at publish time. The output is a single executable with no JIT, no IL, and much smaller startup time and memory footprint. Trade-offs:
System.Reflection.Emit, expression compilation, and dynamic loading don't work.Native AOT shines for command-line tools, serverless functions (where cold start dominates cost), and containers where startup time and image size matter.
Trimming removes code that the publisher can prove is unreachable. Trade-offs are similar: reflection-driven code paths are invisible to the trimmer and may be removed accidentally. The trimmer issues warnings for code patterns it can't analyze; treat those warnings seriously.
Enabling AOT for a .NET 8 project is a couple of lines in the .csproj:
Then dotnet publish -c Release produces a native binary. For most long-running server apps the default JIT model is right; AOT and trimming become interesting when startup or binary size matters.
A worked example that pulls in everything from this chapter. The scenario: parsing a stream of order lines from a CSV blob and computing the running total for a customer. We'll do it twice: a naive version that any developer would write first, then an optimized version that uses spans, pooling, and a for loop. We'll measure both.
The naive version:
This works. The code reads cleanly. It's also dragging a lot of weight: Split allocates an array per line plus a string per field, the Where/Select chain allocates iterators and delegates, ToList allocates a list, and Sum walks it again.
Typical benchmark on 100,000 lines:
Twenty-four megabytes of garbage for a single call. On a server processing thousands of these per minute, that's a steady GC pressure spike.
The optimized version uses ReadOnlySpan<char>, walks the input in place, and never allocates:
The differences:
Split calls. The method walks the buffer once, finding newline and comma positions.while loop replaces the operator chain.SequenceEqual on spans, not string equality.Benchmark on the same 100,000 lines:
Nine times faster, zero allocations.
Worth saying clearly: you do not write code this way by default. The naive version is the readable, idiomatic choice and the right starting point for any feature. You only use the optimized form once you've measured this method and confirmed it's a hot path.
What changed wasn't a clever trick. It's a series of small choices:
string.Split (allocates) with ReadOnlySpan<char>.IndexOf (free).decimal.Parse(string) with decimal.TryParse(ReadOnlySpan<char>).SequenceEqual instead of converting to strings first.Each change is locally small. Stacked, they remove 24 MB of garbage from one method call. The same approach scales to any hot path: identify the allocations, replace them with span-based or pooled alternatives, re-measure.
10 quizzes