Skip to main content
ANVISoftware Solutions
Lesson 62 of 62Advanced18 min

Performance Considerations

By the end of this lesson

Measure before optimising, and recognise the allocations that matter.

Performance work has one rule that comes before all the others: measure first. Not because measuring is virtuous, but because intuition about performance is unreliable in a specific and repeatable way. Developers guess wrong about which line is slow, and they guess wrong often.

The reason is that the real cost is usually somewhere nobody was looking. A page taking four seconds is rarely slow because of a loop. It is slow because of a query with no index, or a query issued 300 times instead of once, or a call to an external service nobody realised was on that path. Those are two or three orders of magnitude more expensive than anything happening in memory, and no amount of tightening the loop will show up next to them.

So the sequence is always the same. Find out what is slow. Confirm it with a number. Change one thing. Measure again. Anything else is redecorating.

Timing two implementations honestly
C#
public static class ReferenceFormatter
{
    // Allocates a new string on every iteration.
    public static string Concatenate(IReadOnlyList<Order> orders)
    {
        string result = "";

        foreach (Order order in orders)
        {
            result += order.Reference + ";";
        }

        return result;
    }

    // Writes into one growing buffer.
    public static string Build(IReadOnlyList<Order> orders)
    {
        StringBuilder builder = new();

        foreach (Order order in orders)
        {
            builder.Append(order.Reference).Append(';');
        }

        return builder.ToString();
    }
}

// A rough comparison. Release build, no debugger attached.
List<Order> orders = Enumerable.Range(1, 20_000)
    .Select(i => new Order { Reference = $"SO-{i}" })
    .ToList();

// Warm up so the just-in-time compiler is not part of the measurement.
ReferenceFormatter.Concatenate(orders.Take(100).ToList());
ReferenceFormatter.Build(orders.Take(100).ToList());

long before = GC.GetTotalAllocatedBytes();
Stopwatch watch = Stopwatch.StartNew();

ReferenceFormatter.Concatenate(orders);

watch.Stop();
long concatenateBytes = GC.GetTotalAllocatedBytes() - before;

Console.WriteLine($"Concatenate: {watch.ElapsedMilliseconds} ms, {concatenateBytes / 1024} KB");

before = GC.GetTotalAllocatedBytes();
watch.Restart();

ReferenceFormatter.Build(orders);

watch.Stop();
long buildBytes = GC.GetTotalAllocatedBytes() - before;

Console.WriteLine($"Build: {watch.ElapsedMilliseconds} ms, {buildBytes / 1024} KB");
  • The two methods produce identical output. That is the first requirement of a comparison: if the results differ, you are not measuring the same thing.
  • Concatenate allocates a new string per iteration, because strings are immutable — result += x cannot modify result, so it creates a longer string and abandons the old one. By the 20,000th order it is copying a large string to add a few characters, so the total work grows with the square of the count.
  • Build appends into one buffer that is resized occasionally. The difference at 20,000 items is large, typically hundreds of times, and it widens as the list grows. At 20 items it is not measurable, which is the part worth remembering.
  • The warm-up calls exist so the just-in-time compiler has already compiled both methods. Without them the first method measured is charged for its own compilation.
  • GC.GetTotalAllocatedBytes reports bytes allocated, which is often more useful than elapsed time. Time varies with the machine and its current load; allocation is a property of the code and barely varies between runs.
  • Stopwatch is the right timer for this — DateTime.Now is not precise enough and can move backwards. But this whole harness is still approximate. For a real comparison, BenchmarkDotNet handles the warm-up, iteration count, statistics and allocation reporting properly, and refuses to run in Debug.

Allocations that actually show up in measurements, roughly in the order you are likely to meet them:

  • String concatenation in a loop. The cost grows with the square of the iteration count, so it is invisible at 20 items and severe at 20,000. Use StringBuilder when the number of pieces is not small and fixed. Outside a loop, ordinary concatenation and interpolation are fine and clearer.
  • LINQ on a hot path. Each operator in a chain allocates an enumerator and a closure, and every lambda that captures a variable allocates too. In a request handler that runs once, this is irrelevant and LINQ is the more readable choice. In a loop running a million times, a plain for loop over a list avoids that machinery.
  • Enumerating the same query more than once. A LINQ query over a database is executed each time you enumerate it. Calling Count and then iterating means two round trips. ToList once and reuse it — this is usually a much larger win than anything in-memory.
  • Boxing. Value types stored in object, in a non-generic collection, or passed where an object is expected. Each one is a separate heap allocation, as the previous lesson showed.
  • Large short-lived arrays and buffers. Anything over roughly 85,000 bytes goes to the large object heap, which is collected with generation 2 and not compacted by default. Repeatedly allocating big buffers is more expensive than the size suggests; ArrayPool<T> exists for that case.
  • Async machinery in a very hot method. A method that awaits allocates a small state object when it actually pauses. This matters only in code called millions of times, and ValueTask exists for methods that usually complete without pausing.
  • Adding to a collection without a capacity. A list that grows from nothing to 100,000 items reallocates and copies its backing array repeatedly. Passing the expected count to the constructor removes that when you know it.
  • Exceptions used for control flow. Throwing is expensive — a stack trace has to be captured — and it does not belong in a normal, expected path. A TryParse or a null check costs almost nothing by comparison.

A sequence that finds real problems rather than imagined ones:

  1. Decide what fast enough means

    A target turns an open-ended activity into a finishable one. "The order page responds in under 400 milliseconds at the 95th percentile." Without a number, performance work has no end and no way to tell success from motion.

  2. Measure the real system

    Timings from production or a realistic load test, with realistic data volumes. A table with 200 rows in development behaves nothing like the same table with two million rows, and an index that seems unnecessary at the first size is decisive at the second.

  3. Find where the time goes before touching anything

    A profiler, or request tracing, or a log line recording the duration of each stage. The aim is to identify the one or two places that account for most of the time. This step is where guessing usually turns out to have been wrong.

  4. Check the database and the network first

    In a typical application this is where the time is. A missing index, a query run once per row, a call to an external service inside a loop, data fetched and discarded. These are the changes with the largest effect, and they are frequently also simplifications.

  5. Change one thing and measure again

    Two changes at once tells you the combined effect and nothing about either. Measuring after each change also catches the case where your improvement made things slower, which happens more often than people expect.

  6. Stop when you reach the target

    Optimisation past the point of usefulness costs clarity and buys nothing. Record what you measured and why the code is shaped the way it is, so the next person does not undo it or repeat the investigation.

Summary

  • Measure before changing anything: intuition about what is slow is unreliable, and the cost is usually in data access
  • Timings are only meaningful from a Release build with no debugger, after a warm-up
  • Allocation is worth watching where it compounds — string concatenation in loops, LINQ on hot paths, boxing, large buffers
  • Most code is not hot, and optimising it costs clarity while changing nothing observable
  • Set a target, change one thing at a time, measure again, and stop when the target is met

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Think about it

An order list page takes 6 seconds for 200 rows. A developer replaces the LINQ in the mapping code with for loops, swaps string interpolation for StringBuilder, and reports the page now takes 5.94 seconds.

The changes are all technically correct. What went wrong in the approach, and where would you look instead?

Show solution

No measurement preceded the work. The changes were applied to the code that was easiest to see rather than the code that was taking the time, and the result — a 1% improvement — is the evidence that the in-memory work was never the cost.

The arithmetic makes it clear. Mapping 200 rows in memory takes single-digit milliseconds even written carelessly. Six seconds is roughly a thousand times that, so almost all of it is somewhere else entirely, and no amount of improvement to a few milliseconds can change the total.

The place to look is the data access. Two hundred rows with 6 seconds is the signature of one query per row: fetch the orders, then for each one fetch the customer and the lines. That is 401 round trips, each costing perhaps 15 milliseconds. A profiler or the query log would show it immediately.

The fix is to fetch the related data in the same query — Include in EF Core, or a projection that selects exactly the fields the page shows. That replaces 401 round trips with one and would plausibly take the page under 200 milliseconds.

There is a cost to the work that was done, and it is worth naming. The mapping code is now longer and harder to read, permanently, in exchange for 60 milliseconds. That is the trade the readability callout warns about, made without a measurement to justify it. Reverting it would be reasonable.

The general lesson: when a measured time is orders of magnitude larger than the work you can see, the cause is not in the work you can see. Find the number before choosing the change.

Try it yourself

Try it yourself

You are asked to speed up a method that loads every invoice for a customer, filters to the unpaid ones, and totals them. It currently takes 3 seconds for a customer with 5,000 invoices, 12 of which are unpaid.

Before changing any code, write down what you would measure and what you expect to find. Then decide on the change.

Show solution

What to measure: how long the database query takes on its own, how many rows come back, and how much data that is. The shape of those three numbers points at the answer without reading much code.

What to expect: 5,000 rows are being fetched across the network, materialised into objects, and then 4,988 of them are discarded. The filtering is in memory after the query rather than in the query. The 3 seconds is transfer and materialisation, not arithmetic.

The change is to move the filter and the total into the query, so the database does the work and returns one number. That turns 5,000 rows of transfer into a single scalar. Loading nothing you do not need is the largest available win here, and it is a smaller change than it sounds — often a couple of lines.

Note which technique is not the answer. Making the in-memory sum faster cannot help, because it was never the cost. Caching the result hides the problem and introduces staleness. Concurrency would only issue the same oversized query in parallel.

One real trade-off to be aware of: with the sum done in the database, the method can no longer show which invoices are unpaid, because it no longer has them. If the caller needs that, select only the unpaid ones and only the columns needed. Deciding what the caller actually requires is part of the optimisation, not separate from it.

Verify with the same measurement afterwards, including the row count. A query that still returns 5,000 rows has not been fixed however it is written.

C#
// Before: 5,000 rows fetched, 4,988 thrown away.
public async Task<decimal> OutstandingBefore(int customerId, CancellationToken ct)
{
    List<Invoice> invoices = await _db.Invoices
        .Where(i => i.CustomerId == customerId)
        .ToListAsync(ct);                       // everything comes back here

    return invoices
        .Where(i => !i.IsPaid)                  // filtered in memory, too late
        .Sum(i => i.Total);
}

// After: the database filters and totals, and returns one value.
public async Task<decimal> OutstandingAfter(int customerId, CancellationToken ct)
{
    return await _db.Invoices
        .Where(i => i.CustomerId == customerId && !i.IsPaid)
        .SumAsync(i => i.Total, ct);
}

// If the caller needs the invoices as well, fetch only those, and only what is shown.
public async Task<List<UnpaidInvoice>> UnpaidAsync(int customerId, CancellationToken ct)
{
    return await _db.Invoices
        .Where(i => i.CustomerId == customerId && !i.IsPaid)
        .Select(i => new UnpaidInvoice(i.Id, i.Reference, i.Total, i.DueOn))
        .ToListAsync(ct);
}

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

Why should performance measurements come from a Release build rather than a Debug build?
A page takes 5 seconds for 100 rows. Where is the time most likely going?

Saved in this browser only.

End of the published lessons

That is everything written so far in C#

More lessons in this course are on the way. In the meantime, the course page shows the full roadmap, and the projects are the best way to consolidate what you have covered.