Data & Performance

Studying High-Performance C# and .NET: Measurement Notes and Code Experiments

Afzal AhmedFaz Ahmed
·27 July 2026·52 min read
C#.NETPerformanceGarbage CollectionBenchmarkDotNetLINQEF CoreAsync/AwaitConcurrencyProfiling

Why This Matters

My source-backed study and experiments in measuring .NET performance across memory, garbage collection, collections, LINQ, I/O, databases, async code and concurrency.

Let’s treat performance as an engineering discipline rather than a collection of clever tricks.

When I mentor a developer on high-performance .NET, I begin with one idea: performance comes from understanding what our code asks the runtime, memory, database, network, CPU, thread pool and garbage collector to do.

We will work through the CLR and JIT, memory allocation, garbage collection, profiling, collections, LINQ, file and stream I/O, network calls, data access, responsive applications, distributed systems, multithreading, parallelism and asynchronous programming. The aim is not to optimise everything. It is to recognise when performance matters and respond with evidence.

My approach is straightforward:

Measure first. Understand the bottleneck. Reduce unnecessary work. Reduce unnecessary allocation. Choose the right data structure. Avoid blocking threads. Keep I/O asynchronous. Watch database access. Use profiling tools. Design systems that stay responsive under pressure.

That is what this article is about.

How to study this guide

Keep a console project and a small ASP.NET Core project open while reading. Type the examples instead of copying them blindly. Predict what allocates, what blocks and what scales before running the code. Then use a profiler or benchmark to challenge your prediction.

Performance has several meanings:

  • latency is how long one operation takes;
  • throughput is how many operations complete in a period;
  • allocation rate is how quickly managed memory is requested;
  • CPU utilisation describes processor demand;
  • scalability describes behaviour as users or data increase;
  • responsiveness describes whether the application remains usable while work happens;
  • resilience describes whether the system remains useful when dependencies slow down or fail.
Optimising one can harm another. Batching database writes may improve throughput while increasing the latency of an individual item. Caching may reduce latency while consuming memory and introducing staleness. Parallel work may finish sooner while using more CPU and reducing capacity for other requests.

The mentoring question is therefore not “How do I make this fast?” It is “Which performance outcome matters here, under what workload, and how will we prove improvement?”


Part I - Understand the runtime and memory


1. Performance begins with the runtime

Let’s start with the foundation.

C# code does not directly become machine code in the way C or C++ traditionally does. Normally, C# compiles into Intermediate Language, often called IL or MSIL. At runtime, the .NET runtime loads the assembly, reads metadata, verifies types, and the JIT compiler converts IL into native machine code for the current machine.

That means performance is influenced by several layers:

C# Source Code
   ↓
Roslyn Compiler
   ↓
IL + Metadata
   ↓
CLR / .NET Runtime
   ↓
JIT Compilation
   ↓
Native Machine Code
   ↓
CPU Execution

The foundation includes the CLR, CoreCLR, JIT, garbage collection, native compilation, threading, collections, LINQ, networking and ASP.NET Core performance.

So when someone says, “This C# code is slow,” my first response is:

“Slow where? Compilation? Startup? JIT? CPU execution? Allocation? GC? I/O? SQL? Network? Serialization? Lock contention?”

That question separates a serious engineer from someone guessing.

Example:

public decimal CalculateTotal(IEnumerable<OrderLine> lines)
{
    return lines.Sum(x => x.Quantity * x.UnitPrice);
}

This looks harmless. But performance depends on context.

How many lines? Is lines already in memory? Is it an EF Core query? Does Sum execute in SQL or memory? Is UnitPrice decimal calculation expensive at scale? Is this called once or 50,000 times? Is it allocating closures? Is it inside a hot path?

Performance is always contextual.

My rule of thumb:

Never optimize code because it “looks slow.” Measure it because it behaves slow.

JIT compilation, tiering and Native AOT

The JIT does not necessarily produce the final highly optimised version of every method immediately. Tiered compilation can create code quickly for startup, observe which methods become hot and then recompile important methods with stronger optimisation. Profile-guided optimisation can use observed execution behaviour to improve decisions such as inlining and devirtualisation.

Inlining replaces a small method call with its body, avoiding call overhead and exposing more code to optimisation. Devirtualisation occurs when the runtime can determine the concrete target of a virtual or interface call and optimise it more directly. You normally help by writing clear code and choosing sensible abstractions, not by trying to outsmart the JIT.

Native AOT compiles an application ahead of time into native code. It can improve startup and deployment characteristics, especially for small services and command-line tools, but it also restricts some dynamic features and may increase build complexity. It is a workload and deployment decision, not a universal “faster” switch.

JIT application: IL deployed, native code produced while the app runs
Native AOT:       native code produced during publishing

Warm-up changes measurements

The first call may include type loading and JIT work that later calls do not:

static long SumSquares(int[] values)
{
    long total = 0;
    foreach (int value in values)
        total += (long)value * value;
    return total;
}

A single Stopwatch around the first call mixes startup, JIT and execution. BenchmarkDotNet performs warm-up and repeated measurement so you can study steady-state behaviour. If startup is the product requirement, measure startup explicitly rather than accidentally.

Check your understanding: the compiler, runtime, JIT and CPU are different layers. A slow request may spend most of its time in none of them—it may be waiting for SQL or a network dependency.


2. Memory: stack, heap, value types and reference types

Now we come to one of the most important performance topics: memory.

In C#, we often talk about stack and heap.

The stack is used for method call frames, local variables, return addresses and short-lived execution context. It is fast and works like a pile of plates. A method gets called, a frame goes on the stack. The method returns, the frame is removed.

The heap is used for objects that live beyond a simple method frame. Most reference type instances live on the managed heap and are managed by the garbage collector.

A simple example:

public void Process()
{
    int count = 10;                     // value type local
    Customer customer = new Customer(); // reference points to object on heap
}

count is a value type local. The customer variable is a reference. The actual Customer object created by new Customer() is on the managed heap.

But here is the important detail: value types do not always live on the stack.

public class Order
{
    public int Quantity { get; set; } // value type inside heap object
}

Quantity is an int, but because it is a field/property inside a heap object, it lives as part of that heap object.

Arrays are also important:

int[] numbers = new int[1000];

Even though int is a value type, the array object lives on the heap, and the values are stored inside that heap allocation.

So do not say in an interview:

“Value types live on the stack and reference types live on the heap.”

That is convenient shorthand, but it is incomplete.

Say:

“Value types are copied by value semantically, but their physical storage depends on where they are declared. Locals may be stack-based, fields may live inside heap objects, and arrays are heap allocations.”

That is a proper answer.

Passing values: copy, ref, in and out

Normal parameters are passed by value. For a value type, the value is copied. For a reference type, the reference is copied; both references can identify the same object.

static void IncrementCopy(int value) => value++;
static void IncrementOriginal(ref int value) => value++;

int count = 10;
IncrementCopy(count);
Console.WriteLine(count); // 10

IncrementOriginal(ref count);
Console.WriteLine(count); // 11

ref permits reading and replacing the caller's variable. out requires the method to assign it. in passes a readonly reference and can avoid copying a large struct, although the JIT and calling context still matter.

Do not add ref everywhere for speed. It complicates APIs and aliasing. It is most relevant for measured hot paths involving larger value types.

Choosing a struct or class

A struct has value semantics and is copied as a value. A class has reference semantics and normally adds a heap object. Small immutable values such as coordinates, dates and identifiers can suit structs. Large mutable business entities usually suit classes.

public readonly record struct Coordinate(double X, double Y);

public sealed class Customer
{
    public required int Id { get; init; }
    public required string Name { get; init; }
}

A struct is not “free memory.” Put one million 32-byte structs into an array and the array still contains roughly 32 MB of element data. Copy a large struct repeatedly and you move that data repeatedly. Choose from semantics first, then measure representation-sensitive code.

Stack allocation and spans

stackalloc creates a small temporary buffer whose lifetime is limited to the current method. Span provides a safe view over contiguous memory and can slice without copying:

static int CountSeparators(ReadOnlySpan<char> text)
{
    int count = 0;
    foreach (char character in text)
    {
        if (character == ',') count++;
    }
    return count;
}

ReadOnlySpan<char> code = "UK,LONDON,42";
int separators = CountSeparators(code);

Use Memory when data must be stored or cross an await. Spans are stack-only lifetime-checked views. Keep stack allocations small and bounded; input-controlled large stack buffers can overflow the stack.


3. Strings: the silent allocation machine

Strings are reference types and immutable.

That means once a string is created, you do not modify it. If you appear to change it, .NET creates a new string.

Bad code:

public string BuildCsv(IEnumerable<Customer> customers)
{
    string csv = "";

    foreach (var customer in customers)
    {
        // Every += can create a new string.
        // In a large loop, this becomes allocation-heavy.
        csv += $"{customer.Id},{customer.Name},{customer.Email}\n";
    }

    return csv;
}

This works for small data. For large data, it creates repeated allocations.

Better:

public string BuildCsv(IEnumerable<Customer> customers)
{
    var builder = new StringBuilder();

    foreach (var customer in customers)
    {
        builder.Append(customer.Id);
        builder.Append(',');
        builder.Append(customer.Name);
        builder.Append(',');
        builder.Append(customer.Email);
        builder.AppendLine();
    }

    return builder.ToString();
}

The code is not just “cleaner.” It is kinder to memory.

I review string-heavy code carefully, especially in:

Logging loops. CSV generation. JSON manipulation. Report building. Large import/export processes. Middleware. High-traffic APIs.

One bad string loop in a busy API can cause huge allocation pressure.

My rule of thumb:

String immutability is safe and useful, but repeated string concatenation in hot paths is expensive.

Understand why concatenation grows expensive

If a loop has already built a 10,000-character string and appends ten more characters, immutability requires another string large enough for the complete result and copying the previous content. Repeat that growth many times and the same earlier characters are copied again and again.

StringBuilder maintains a growable buffer. Supply a reasonable capacity when you can estimate the final size:

var builder = new StringBuilder(capacity: customers.Count * 48);

Do not calculate an enormous speculative capacity; that simply moves the waste to the beginning.

For a few pieces, interpolation is clear and modern C# handles many interpolation scenarios efficiently:

string message = $"Customer {customer.Id} owes {customer.Balance:C}.";

The lesson is not “always use StringBuilder.” Use interpolation for small expressions, string.Concat or string.Join for known collections, StringBuilder for incremental construction, and spans for measured parsing paths.

Parsing without substring allocation

static bool TryReadAmount(ReadOnlySpan<char> line, out decimal amount)
{
    int separator = line.IndexOf(':');
    if (separator < 0)
    {
        amount = default;
        return false;
    }

    return decimal.TryParse(line[(separator + 1)..], out amount);
}

The slice is a view into the original characters rather than a new substring. Use this technique only where allocation measurements justify the additional lifetime rules.


4. Boxing and unboxing: small syntax, hidden cost

Boxing happens when a value type is wrapped inside an object.

int number = 42;
object boxed = number;      // boxing
int unboxed = (int)boxed;   // unboxing

Why does this matter?

Because boxing allocates an object on the heap. If it happens inside a hot path, it can quietly create performance problems.

Classic example:

ArrayList list = new ArrayList();

for (int i = 0; i < 10000; i++)
{
    list.Add(i); // boxing every int
}

Better:

List<int> list = new List<int>();

for (int i = 0; i < 10000; i++)
{
    list.Add(i); // no boxing
}

Generics were partly introduced to avoid this kind of unnecessary boxing and casting.

Another subtle case:

public void LogValue(object value)
{
    Console.WriteLine(value);
}

int count = 10;
LogValue(count); // boxing

Not always a problem. But in performance-sensitive code, these details matter.

My rule of thumb:

Boxing once is nothing. Boxing millions of times is a GC problem wearing innocent syntax.

Where boxing hides

Boxing can appear through non-generic APIs, interface calls on some value types, formatting paths and APIs accepting object:

static void WriteObject(object value) => Console.WriteLine(value);

for (int i = 0; i < 1_000_000; i++)
    WriteObject(i); // Each int must be represented as an object.

A generic alternative can retain the concrete type:

static void WriteValue<T>(T value) => Console.WriteLine(value);

That does not guarantee zero allocation—the implementation called by Console.WriteLine still matters—but it shows why generic APIs preserve type information better than object-based ones.

Use allocation profiling or BenchmarkDotNet's MemoryDiagnoser rather than searching syntax manually. The goal is to locate high-frequency boxing, not remove an occasional harmless conversion.


5. Garbage collection: your friend, not your cleaner

The garbage collector is one of .NET’s biggest strengths. It frees developers from manually releasing ordinary managed memory.

But GC is not free.

When you allocate many objects, the GC eventually has to clean them up. More allocations mean more GC pressure. More GC pressure can mean pauses, CPU cost and unpredictable latency.

The wider memory discussion includes object generations, weak references, finalisation, IDisposable, memory leaks, COM object release and event-based leaks.

The GC works with generations:

Generation 0: short-lived objects
Generation 1: objects that survived Gen 0
Generation 2: longer-lived objects
Large Object Heap: large allocations

Most objects die young. That is why Gen 0 collection is optimized to be fast.

Bad pattern:

public List<CustomerDto> GetCustomers()
{
    var result = new List<CustomerDto>();

    foreach (var customer in _customers)
    {
        result.Add(new CustomerDto
        {
            Id = customer.Id,
            Name = customer.Name,
            Email = customer.Email
        });
    }

    return result;
}

This is fine if needed. But if this is called repeatedly for huge collections, it allocates many DTOs.

Better thinking:

Can we page the data? Can we project directly from SQL? Can we stream results? Can we avoid duplicate mapping? Can we cache reference data? Can we return only columns needed by the UI?

The real performance question is not “Is allocation bad?”

Allocation is normal.

The question is:

“Is this allocation necessary, and is it happening at the correct scale?”

Memory leak example with events:

public class Dashboard
{
    public event EventHandler? Refreshed;
}

public class Widget
{
    public Widget(Dashboard dashboard)
    {
        dashboard.Refreshed += OnDashboardRefreshed;
    }

    private void OnDashboardRefreshed(object? sender, EventArgs e)
    {
        // React to dashboard refresh
    }
}

If Dashboard lives for the lifetime of the application and Widget is not unsubscribed, the event reference can keep Widget alive.

Better:

public class Widget : IDisposable
{
    private readonly Dashboard _dashboard;

    public Widget(Dashboard dashboard)
    {
        _dashboard = dashboard;
        _dashboard.Refreshed += OnDashboardRefreshed;
    }

    private void OnDashboardRefreshed(object? sender, EventArgs e)
    {
        // React to dashboard refresh
    }

    public void Dispose()
    {
        _dashboard.Refreshed -= OnDashboardRefreshed;
    }
}

My rule of thumb:

In .NET, memory leaks usually happen when objects are still reachable, not because the GC forgot them.

Generations and survival

The GC is generational because most new objects become unreachable quickly. Generation 0 is collected frequently. Survivors are promoted, and generation 2 is collected less frequently because scanning long-lived data is more expensive.

Large objects normally enter the Large Object Heap. Repeated large temporary arrays can increase memory pressure and fragmentation. Pooling may help a measured buffer-heavy workload, but pooled arrays must always be returned and sensitive data may need clearing.

byte[] buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);
try
{
    int read = await stream.ReadAsync(buffer.AsMemory(0, 64 * 1024), ct);
    Process(buffer.AsSpan(0, read));
}
finally
{
    ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
}

Never retain the buffer after returning it; another caller may receive and overwrite it.

Weak references are specialised

A WeakReference allows an object to be collected even while the weak reference exists. It can support specialised caches or metadata, but it gives no guarantee the target will remain available:

var weak = new WeakReference<byte[]>(new byte[1024]);

if (weak.TryGetTarget(out byte[]? data))
    Console.WriteLine(data.Length);

Weak references are not a replacement for cache eviction policies. A production cache needs size bounds, expiration, observability and a plan for rebuilding missing values.

Read GC signals correctly

High allocation does not automatically mean a leak. A leak normally shows retained live memory growing because unwanted objects remain reachable. Allocation rate, heap size after full collections, generation counts and retention paths tell different stories. A memory profiler can show what keeps an object alive.


6. IDisposable and finalizers: clean up deterministically

Managed memory is GC-managed. But unmanaged resources need proper cleanup.

Examples:

File handles. Database connections. Network sockets. Streams. Native handles. COM objects. Graphics resources.

Use IDisposable:

public sealed class ReportWriter : IDisposable
{
    private readonly StreamWriter _writer;
    private bool _disposed;

    public ReportWriter(string path)
    {
        _writer = new StreamWriter(path);
    }

    public void WriteLine(string line)
    {
        if (_disposed)
            throw new ObjectDisposedException(nameof(ReportWriter));

        _writer.WriteLine(line);
    }

    public void Dispose()
    {
        if (_disposed)
            return;

        _writer.Dispose();
        _disposed = true;
    }
}

Usage:

using var writer = new ReportWriter("report.csv");

writer.WriteLine("Id,Name,Amount");
writer.WriteLine("1,Faz,1000");

Or:

using (var stream = File.OpenRead("large-file.csv"))
{
    // Use stream safely
}

The point of using is deterministic cleanup. The resource is released when the scope ends.

Finalizers are different. They run when the GC eventually finalizes an object. You do not control exactly when that happens. Finalizers should be rare and usually only appear when wrapping unmanaged resources directly.

My rule of thumb:

Prefer IDisposable and using. Treat finalizers as last-resort safety nets, not normal cleanup logic.

The ownership rule

The object that creates or takes ownership of a disposable resource must ensure it is disposed. If a dependency is injected and owned by the container, your class usually should not dispose it independently. Ownership is the real question behind the pattern.

For asynchronous cleanup, implement IAsyncDisposable and use await using:

await using FileStream stream = new(
    "report.csv",
    FileMode.Create,
    FileAccess.Write,
    FileShare.None,
    bufferSize: 64 * 1024,
    useAsync: true);

await stream.WriteAsync(payload, ct);

When wrapping a raw native handle, prefer SafeHandle rather than writing finalisation logic directly. SafeHandle centralises reliable handle release and works with the runtime's finalisation infrastructure.

Calling Dispose does not normally reclaim the managed object immediately. It releases owned resources deterministically and makes the object eligible for normal collection once references disappear.

Revision checkpoint for Part I

  • Define the metric before optimising.
  • Separate startup, JIT, steady-state CPU and external waiting.
  • Value/reference semantics are not a simplistic stack/heap rule.
  • Strings and boxing can create hidden high-frequency allocations.
  • GC reclaims unreachable objects; leaks remain reachable.
  • Dispose resources according to ownership and lifetime.

Part II - Measure and improve application code


7. Profiling: stop guessing

This is where many developers fail.

They look at code and guess.

An engineer investigating performance measures.

Useful profiling and tracing tools include code metrics, static analysis, memory dumps, debugging diagnostics, Visual Studio Performance Profiler, JetBrains dotMemory, JetBrains dotTrace and dotnet-counters.

A performance investigation should follow a pattern:

1. Define the slow scenario
2. Reproduce it
3. Measure baseline
4. Identify bottleneck
5. Fix one thing
6. Measure again
7. Keep evidence

Example scenario:

“The customer search page takes 8 seconds.”

Bad response:

“I think LINQ is slow.”

Senior response:

“Let’s measure browser timing, API time, SQL time, CPU, memory allocation, GC, logs, traces, and payload size.”

Useful commands:

dotnet-counters ps
dotnet-counters monitor --process-id 12345

BenchmarkDotNet example:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<CustomerSearchBenchmarks>();

public class CustomerSearchBenchmarks
{
    private readonly List<Customer> _customers = Enumerable.Range(1, 100_000)
        .Select(i => new Customer(i, $"Customer {i}"))
        .ToList();

    [Benchmark]
    public Customer? FirstOrDefaultSearch()
    {
        return _customers.FirstOrDefault(x => x.Id == 90_000);
    }

    [Benchmark]
    public Customer? DictionarySearch()
    {
        var dictionary = _customers.ToDictionary(x => x.Id);
        return dictionary.GetValueOrDefault(90_000);
    }
}

public record Customer(int Id, string Name);

Now, this benchmark itself has a flaw: the dictionary is built inside the benchmark method. That means you are benchmarking dictionary construction plus lookup.

Better:

public class CustomerSearchBenchmarks
{
    private readonly List<Customer> _customers;
    private readonly Dictionary<int, Customer> _customerById;

    public CustomerSearchBenchmarks()
    {
        _customers = Enumerable.Range(1, 100_000)
            .Select(i => new Customer(i, $"Customer {i}"))
            .ToList();

        _customerById = _customers.ToDictionary(x => x.Id);
    }

    [Benchmark]
    public Customer? FirstOrDefaultSearch()
    {
        return _customers.FirstOrDefault(x => x.Id == 90_000);
    }

    [Benchmark]
    public Customer? DictionarySearch()
    {
        return _customerById.GetValueOrDefault(90_000);
    }
}

That is the difference between “running a benchmark” and “understanding what you are benchmarking.”

My rule of thumb:

A bad benchmark can make bad code look good and good code look bad.

Choose the diagnostic tool from the question

QuestionUseful evidence
Which method consumes CPU?CPU sampling profile or trace
What allocates most?allocation profile
Why does memory remain high?heap dump and retention paths
Are requests waiting on locks?contention events and thread stacks
Is GC causing pauses?GC counters and trace
Which SQL call dominates latency?distributed trace and database plan
Did my micro-optimisation help?BenchmarkDotNet
dotnet-counters gives live health signals. dotnet-trace records runtime events for later analysis. dotnet-gcdump captures managed heap information with less disruption than some full dumps. A debugger answers correctness questions; leaving it attached can distort timing.

Static analysis and code metrics

Static analysis does not measure runtime performance, but it can reveal complexity, disposal mistakes and suspicious patterns before production. Cyclomatic complexity estimates the number of independent paths through a method. High complexity is not automatically slow, but complicated code is harder to reason about and benchmark accurately.

Build a performance investigation record

Write down:

Scenario: search for a customer from the dashboard
Workload: 50 concurrent users, 100,000 customers
Baseline: p50 180 ms, p95 1.8 s, 35 MB/s allocation
Evidence: SQL accounts for 1.5 s at p95; missing index; 18,000 rows read
Change: add matching index and project three required columns
Result: p50 42 ms, p95 110 ms, 7 MB/s allocation
Regression guard: load-test threshold and query-plan review

Percentiles matter. An average can hide a small group of users waiting several seconds. Use p50 for the typical request and p95 or p99 for tail latency.

Practice: deliberately add a large allocation inside a loop, observe the allocation rate, remove it, and compare the trace. Learning the tool on a known problem prepares you for an unknown one.


8. Collections: choose the data structure based on access pattern

Choosing collections well requires an understanding of Big O notation, arrays versus collections, IEnumerable, IEnumerator, yield, concurrency, parallelism and equality semantics.

This matters every day.

Use List when you need ordered items and frequent iteration.

Use Dictionary when you need fast lookup by key.

Use HashSet when you need uniqueness and fast membership checks.

Use Queue when first-in-first-out matters.

Use Stack when last-in-first-out matters.

Use concurrent collections when multiple threads access shared data.

Example:

var customers = await GetCustomersAsync();

var vipCustomerIds = await GetVipCustomerIdsAsync();

// Bad for large collections:
// For every customer, scans vipCustomerIds.
var vipCustomers = customers
    .Where(c => vipCustomerIds.Contains(c.Id))
    .ToList();

If vipCustomerIds is a List, Contains scans the list each time.

Better:

var vipCustomerIdSet = vipCustomerIds.ToHashSet();

var vipCustomers = customers
    .Where(c => vipCustomerIdSet.Contains(c.Id))
    .ToList();

Now lookup is much faster for large datasets.

Another example:

public Customer? FindCustomer(List<Customer> customers, int id)
{
    return customers.FirstOrDefault(x => x.Id == id);
}

Fine for small lists.

But if you repeatedly search by ID, prepare a dictionary:

Dictionary<int, Customer> customerById = customers.ToDictionary(x => x.Id);

Customer? customer = customerById.GetValueOrDefault(id);

My rule of thumb:

Performance often improves not by clever syntax, but by choosing the correct collection for the access pattern.

Big O connects the requirement to the collection

Big O describes how work grows with input size n:

OperationListDictionaryHashSet
index by positionO(1)not its purposenot its purpose
search by value/keyO(n)O(1) averageO(1) average
append/addO(1) amortisedO(1) averageO(1) average
ordered iterationinsertion sequenceno sorting guaranteeno sorting guarantee
Amortised O(1) means most list appends fill an existing slot, while occasional resizing copies the internal array. The expensive resize is spread across many cheap additions.

Arrays use fixed contiguous storage. List adds resizing and a logical count. If you know the exact final size, an array may be compact. If size changes, a list is normally clearer. Setting a realistic list capacity can reduce resizing for large known batches.

Interfaces communicate capability

IEnumerable promises enumeration. ICollection adds count and mutation capabilities. IReadOnlyCollection offers a count without mutation through that reference. IList and IReadOnlyList add positional indexing.

Accept the weakest contract the algorithm needs:

static decimal Total(IEnumerable<OrderLine> lines) =>
    lines.Sum(line => line.Quantity * line.UnitPrice);

Do not claim immutability from IReadOnlyList alone. Another reference may still mutate the underlying list.

Iterators and yield return

An iterator produces values on demand:

static IEnumerable<int> PositiveValues(IEnumerable<int> source)
{
    foreach (int value in source)
    {
        if (value > 0)
            yield return value;
    }
}

The compiler creates a state machine implementing IEnumerable and IEnumerator. Work occurs during enumeration, so stopping early avoids producing remaining values. Re-enumeration repeats the work.

Equality drives dictionaries and sets

==, Equals and an equality comparer can express different policies. For case-insensitive identifiers, provide the comparer when constructing the collection:

var codes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
    "uk-lon"
};

Console.WriteLine(codes.Contains("UK-LON")); // True

Equal keys must produce equal hash codes, and key equality must remain stable while stored.

Concurrency does not make a workflow atomic

ConcurrentDictionary makes its individual operations thread-safe. A separate “check then update” sequence can still race. Prefer TryAdd, GetOrAdd or AddOrUpdate when their semantics fit, and use a larger coordination strategy when one business invariant spans several operations.


9. LINQ performance: beautiful code can hide expensive work

LINQ is one of C#’s best features. It is expressive, readable and powerful.

But LINQ can also hide allocations, deferred execution, repeated enumeration, database translation issues and closure costs.

Example:

var activeCustomers = customers
    .Where(x => x.IsActive)
    .OrderBy(x => x.LastName)
    .ToList();

This is fine.

But watch this:

var query = customers.Where(x => x.IsActive);

if (query.Count() > 0)
{
    foreach (var customer in query)
    {
        Console.WriteLine(customer.Name);
    }
}

This may enumerate twice.

Better:

var activeCustomers = customers
    .Where(x => x.IsActive)
    .ToList();

if (activeCustomers.Count > 0)
{
    foreach (var customer in activeCustomers)
    {
        Console.WriteLine(customer.Name);
    }
}

Or:

if (customers.Any(x => x.IsActive))
{
    foreach (var customer in customers.Where(x => x.IsActive))
    {
        Console.WriteLine(customer.Name);
    }
}

But even here, be aware of multiple enumeration if the source is expensive.

Another common issue:

var lastOrder = orders.OrderBy(x => x.CreatedOn).Last();

Better:

var lastOrder = orders.OrderByDescending(x => x.CreatedOn).First();

For in-memory, both may work. For database queries, SQL translation and indexing matter.

EF Core example:

var orders = await _dbContext.Orders.ToListAsync();

var pending = orders
    .Where(x => x.Status == OrderStatus.Pending)
    .ToList();

Bad. This loads all orders into memory first.

Better:

var pending = await _dbContext.Orders
    .Where(x => x.Status == OrderStatus.Pending)
    .ToListAsync();

Now filtering happens in SQL.

My rule of thumb:

LINQ is not slow. Unexamined LINQ is slow.

Deferred execution, streaming and materialisation

Most IEnumerable operators such as Where and Select are deferred. They create a recipe that runs when enumerated. Operators such as ToList, ToArray, Count and First trigger work.

var values = new List<int> { 1, 2, 3 };
IEnumerable<int> query = values.Where(value => value > 1);

values.Add(4);
Console.WriteLine(string.Join(", ", query)); // 2, 3, 4

Materialise when you intentionally need a stable snapshot or repeated traversal. Keep streaming when the source is large and each value can be processed once.

Filter before expensive transformations

var summaries = orders
    .Where(order => order.Status == OrderStatus.Pending)
    .Select(order => BuildExpensiveSummary(order))
    .ToList();

Filtering first avoids constructing summaries that will be discarded. Similarly, filter before sorting when semantics permit because sorting fewer items reduces O(n log n) work.

GroupBy builds groups and retains values until grouping is complete. It is useful, but grouping millions of in-memory records can consume significant memory. For database-backed queries, let SQL aggregate whenever possible:

var totals = await db.Orders
    .AsNoTracking()
    .GroupBy(order => order.CustomerId)
    .Select(group => new
    {
        CustomerId = group.Key,
        Total = group.Sum(order => order.Amount)
    })
    .ToListAsync(ct);

Closures capture state

decimal minimum = 100m;
var expensive = orders.Where(order => order.Amount >= minimum);

The lambda captures minimum, so the compiler may create a closure object to hold captured state. This is normally fine. In an extremely hot loop, repeated delegate and closure creation can matter. Measure before replacing readable LINQ with manual loops.

Database LINQ is a translation language

IQueryable builds an expression tree for a provider such as EF Core. Unsupported expressions may fail translation or force undesirable evaluation. Inspect generated SQL, use AsNoTracking for read-only work, project only required columns, and avoid N+1 query patterns.

LINQ review questions: What is the source type? When does execution occur? How many times? Where does it execute? What materialises? Does ordering or grouping retain the whole sequence?


10. File and stream I/O: don’t load the world into memory

File I/O performance matters in imports, exports, logs, documents, images and report systems.

Bad:

var content = File.ReadAllText("large-file.csv");
var lines = content.Split(Environment.NewLine);

This loads the whole file and then creates many strings.

Better for large files:

public async Task<int> CountLinesAsync(string path, CancellationToken ct)
{
    var count = 0;

    await using var stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);

    while (!reader.EndOfStream)
    {
        ct.ThrowIfCancellationRequested();

        var line = await reader.ReadLineAsync();

        if (!string.IsNullOrWhiteSpace(line))
        {
            count++;
        }
    }

    return count;
}

For processing:

public async IAsyncEnumerable<CustomerImportRow> ReadCustomersAsync(
    string path,
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await using var stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);

    while (!reader.EndOfStream)
    {
        ct.ThrowIfCancellationRequested();

        var line = await reader.ReadLineAsync();

        if (string.IsNullOrWhiteSpace(line))
            continue;

        var parts = line.Split(',');

        yield return new CustomerImportRow(
            parts[0],
            parts[1],
            parts[2]);
    }
}

Usage:

await foreach (var row in ReadCustomersAsync("customers.csv", ct))
{
    await SaveCustomerAsync(row, ct);
}

This avoids loading the whole file into memory.

My rule of thumb:

For large files, stream. Do not swallow the whole ocean just to count the fish.

Buffering is the bridge between your code and storage

Reading one byte through an operating-system call would be expensive. Streams use buffers so many small logical reads can be served from larger physical reads. A larger buffer is not automatically faster: it consumes more memory and benefits depend on storage, access pattern and concurrency.

await using var source = new FileStream(
    inputPath,
    FileMode.Open,
    FileAccess.Read,
    FileShare.Read,
    bufferSize: 64 * 1024,
    options: FileOptions.Asynchronous | FileOptions.SequentialScan);

SequentialScan communicates expected access to the operating system. Do not use it for random seeks.

Move and copy have different costs

Moving a file within the same volume may update filesystem metadata. Copying must read and write all bytes. Moving across volumes may become a copy-plus-delete operation. The API call name alone does not reveal the physical cost.

Handle partial work and failure

I/O can fail because a file disappears, permissions change, a disk fills or another process holds a conflicting handle. Use temporary output and an atomic replacement strategy when readers must never see a partially written file.

string temporary = outputPath + ".tmp";
await File.WriteAllTextAsync(temporary, content, ct);
File.Move(temporary, outputPath, overwrite: true);

For large content, stream to the temporary file rather than building one giant string. Propagate cancellation, dispose streams and decide whether partial files should be deleted or retained for diagnosis.

IAsyncEnumerable provides asynchronous streaming with backpressure from the consumer: the producer advances when the consumer requests the next item. This keeps memory bounded when each item can be processed independently.


11. Network performance: latency is the invisible tax

Network performance is not only about your code. It includes protocol, payload size, serialization, compression, caching, connection reuse and server responsiveness.

Network-performance work can involve TCP/IP, web traffic, browser performance recording, gRPC, pipelines, connection behaviour and caching.

In web APIs, common performance issues include:

Too many HTTP calls. Huge JSON payloads. No compression. No caching. Repeated authentication overhead. Slow serialization. Chatty frontend/backend interaction. No pagination. Returning full entity graphs.

Bad API:

[HttpGet("customers")]
public async Task<List<Customer>> GetCustomers()
{
    return await _dbContext.Customers
        .Include(x => x.Orders)
        .ThenInclude(x => x.OrderLines)
        .ToListAsync();
}

This may return far too much data.

Better:

[HttpGet("customers")]
public async Task<List<CustomerListItemDto>> GetCustomers(
    int page = 1,
    int pageSize = 50)
{
    return await _dbContext.Customers
        .AsNoTracking()
        .OrderBy(x => x.LastName)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(x => new CustomerListItemDto
        {
            Id = x.Id,
            FullName = x.FirstName + " " + x.LastName,
            Email = x.Email,
            OrderCount = x.Orders.Count
        })
        .ToListAsync();
}

Now we return only what the screen needs.

Caching example:

public async Task<ProductDto?> GetProductAsync(int id)
{
    var cacheKey = $"product:{id}";

    var cached = await _cache.GetStringAsync(cacheKey);

    if (cached is not null)
    {
        return JsonSerializer.Deserialize<ProductDto>(cached);
    }

    var product = await _dbContext.Products
        .AsNoTracking()
        .Where(x => x.Id == id)
        .Select(x => new ProductDto(x.Id, x.Name, x.Price))
        .SingleOrDefaultAsync();

    if (product is not null)
    {
        await _cache.SetStringAsync(
            cacheKey,
            JsonSerializer.Serialize(product),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
            });
    }

    return product;
}

My rule of thumb:

The fastest network call is the one you do not make. The second fastest is the one that returns only what is needed.

Latency has layers

A remote call may include DNS resolution, connection establishment, TLS negotiation, request upload, server queueing, server work, response transfer, deserialisation and retries. Distributed tracing separates these stages across services.

TCP provides a reliable ordered byte stream. UDP sends independent datagrams without TCP's delivery and ordering guarantees. HTTP and gRPC normally build on reliable transports and offer higher-level semantics. Choose protocols from correctness, interoperability and workload needs, not benchmark headlines.

Reuse connections

Create and reuse HttpClient through IHttpClientFactory rather than constructing and disposing a client per request:

builder.Services.AddHttpClient<ProductClient>(client =>
{
    client.BaseAddress = new Uri("https://products.internal");
    client.Timeout = TimeSpan.FromSeconds(5);
});

Connection pooling avoids repeating handshakes and helps manage DNS changes.

gRPC, JSON APIs and streaming

gRPC uses strongly typed contracts and compact binary messages and supports streaming, making it attractive for controlled service-to-service communication. JSON HTTP APIs remain excellent for broad interoperability and browser-facing systems. Compare payload shape, tooling, latency and operational complexity.

System.IO.Pipelines can process streaming bytes efficiently using pooled buffers and careful backpressure. It is an advanced tool for parsers and servers; do not replace straightforward stream code without evidence.

Cache with an invalidation story

Every cache needs:

  • a key with stable identity;
  • an expiration or invalidation rule;
  • a size bound;
  • behaviour when data is missing or stale;
  • protection against many callers rebuilding the same entry;
  • hit-rate and memory observability.
Caching an unbounded collection exchanges a latency problem for a memory problem. Caching private or tenant-specific data under an incomplete key can become a security bug.

12. Database access: EF Core, Dapper and ADO.NET

Relational data-access performance often leads to comparisons between Entity Framework Core, Dapper and ADO.NET across insertion, querying and updates.

This is a very practical topic.

EF Core gives productivity, tracking, LINQ, migrations and rich mapping.

Dapper gives lightweight, fast SQL mapping.

ADO.NET gives low-level control.

The mature answer is not:

“Dapper is faster, so always use Dapper.”

The mature answer is:

“Use the tool that fits the use case.”

EF Core is excellent for normal business CRUD, domain workflows and maintainable application development.

Dapper is excellent for optimized queries, reporting, stored procedures and read-heavy scenarios where SQL shape is important.

ADO.NET is useful when you need maximum control, streaming data readers or lower-level database operations.

EF Core projection:

public async Task<IReadOnlyList<LoanSummaryDto>> GetLoanSummariesAsync()
{
    return await _dbContext.Loans
        .AsNoTracking()
        .Where(x => x.Status == LoanStatus.Submitted)
        .OrderByDescending(x => x.SubmittedOn)
        .Select(x => new LoanSummaryDto
        {
            Id = x.Id,
            ApplicantName = x.ApplicantName,
            Amount = x.Amount,
            SubmittedOn = x.SubmittedOn
        })
        .ToListAsync();
}

Dapper example:

public async Task<IReadOnlyList<LoanSummaryDto>> GetLoanSummariesAsync()
{
    const string sql = """
        SELECT Id, ApplicantName, Amount, SubmittedOn
        FROM Loans
        WHERE Status = @Status
        ORDER BY SubmittedOn DESC
        """;

    using var connection = new SqlConnection(_connectionString);

    var result = await connection.QueryAsync<LoanSummaryDto>(
        sql,
        new { Status = "Submitted" });

    return result.AsList();
}

ADO.NET example:

public async Task<IReadOnlyList<LoanSummaryDto>> GetLoanSummariesAsync()
{
    var loans = new List<LoanSummaryDto>();

    await using var connection = new SqlConnection(_connectionString);
    await connection.OpenAsync();

    await using var command = connection.CreateCommand();
    command.CommandText = """
        SELECT Id, ApplicantName, Amount, SubmittedOn
        FROM Loans
        WHERE Status = @Status
        ORDER BY SubmittedOn DESC
        """;

    command.Parameters.AddWithValue("@Status", "Submitted");

    await using var reader = await command.ExecuteReaderAsync();

    while (await reader.ReadAsync())
    {
        loans.Add(new LoanSummaryDto
        {
            Id = reader.GetInt32(0),
            ApplicantName = reader.GetString(1),
            Amount = reader.GetDecimal(2),
            SubmittedOn = reader.GetDateTime(3)
        });
    }

    return loans;
}

My rule of thumb:

EF Core for productivity, Dapper for focused SQL performance, ADO.NET when you need full control. Benchmark important paths instead of arguing by religion.

The database often dominates before the mapper matters

Indexes, query shape, rows scanned, round trips, locking and result size commonly matter more than the object mapper. An index accelerates matching and ordering but consumes storage and adds work to inserts and updates.

Use the actual execution plan. A query returning 20 rows after scanning 10 million needs a different fix from a query efficiently returning 500,000 rows the client did not need.

Avoid N+1 queries

var customers = await db.Customers.ToListAsync(ct);

foreach (Customer customer in customers)
{
    // If this causes a query per customer, total round trips become 1 + n.
    Console.WriteLine(customer.Orders.Count);
}

Projection can ask the database for the final shape in one query:

var summaries = await db.Customers
    .AsNoTracking()
    .Select(customer => new CustomerSummary(
        customer.Id,
        customer.Name,
        customer.Orders.Count))
    .ToListAsync(ct);

Parameterise SQL

Parameters protect against SQL injection and allow the database to handle values correctly:

command.CommandText = "SELECT Id, Name FROM Products WHERE CategoryId = @CategoryId";
command.Parameters.Add(new SqlParameter("@CategoryId", SqlDbType.Int)
{
    Value = categoryId
});

Prefer explicit types over AddWithValue where inferred type or length could lead to conversions and poor plans.

Benchmark the complete lifecycle fairly

Separate cold startup, connection opening, query execution, materialisation and change tracking. Use the same database state and verify that every implementation returns equivalent data. Microbenchmarks against a local empty database do not predict production behaviour over a network with real indexes and concurrent users.

Batch writes when atomicity and memory allow, avoid calling SaveChangesAsync once per row, and use bulk facilities for genuinely large imports. Always balance throughput with transaction size, log growth and failure recovery.

Revision checkpoint for Part II

  • Diagnose with the tool that answers the current question.
  • Choose collections from dominant operations and equality rules.
  • Understand when and where LINQ executes.
  • Stream large data and design bounded buffers.
  • Reduce network round trips and reuse connections.
  • Optimise SQL shape and indexes before arguing over mappers.

Part III - Responsiveness, distribution and concurrency


13. Responsive UI: never block the user interface

A responsive application is not just a fast application. It is an application that does not freeze while doing work.

Responsiveness matters across WinForms, WPF, ASP.NET Core, .NET MAUI and WinUI, as well as applications using caching, WebSockets and SignalR.

Classic UI mistake:

private void Button_Click(object sender, EventArgs e)
{
    var report = _reportService.GenerateLargeReport(); // blocks UI
    ShowReport(report);
}

Better:

private async void Button_Click(object sender, EventArgs e)
{
    try
    {
        button.Enabled = false;
        statusLabel.Text = "Generating report...";

        var report = await _reportService.GenerateLargeReportAsync();

        ShowReport(report);
    }
    finally
    {
        button.Enabled = true;
        statusLabel.Text = "Ready";
    }
}

In ASP.NET Core, the equivalent mistake is blocking request threads:

var result = _service.GetDataAsync().Result; // bad

Better:

var result = await _service.GetDataAsync();

My rule of thumb:

Responsiveness means expensive work should not block the thread that must stay available.

UI threads and continuations

Desktop UI frameworks normally require controls to be accessed from their owning UI thread. An event handler begins there. When it awaits truly asynchronous I/O, the UI thread can process input and repaint. When the operation completes, the continuation normally resumes in the captured UI context so controls can be updated safely.

CPU-heavy work does not become non-blocking merely because a method is marked async. If report generation performs ten seconds of CPU work before reaching an incomplete await, the UI still freezes. Offload appropriate CPU work or redesign it in chunks, and always control concurrency.

private async void AnalyseButton_Click(object sender, EventArgs e)
{
    analyseButton.Enabled = false;
    using var cancellation = new CancellationTokenSource();

    try
    {
        Analysis result = await Task.Run(
            () => AnalyseImages(_files, cancellation.Token),
            cancellation.Token);

        Render(result);
    }
    catch (OperationCanceledException)
    {
        statusLabel.Text = "Cancelled";
    }
    finally
    {
        analyseButton.Enabled = true;
    }
}

Task.Run is appropriate here only because AnalyseImages is CPU-bound desktop work. In ASP.NET Core request code, adding Task.Run usually consumes another thread-pool worker without increasing server capacity.

Progress, paging and incremental display

Responsiveness includes feedback. IProgress can report updates to a UI context, while cancellation lets the user abandon work:

var progress = new Progress<int>(percentage =>
    progressBar.Value = percentage);

await importer.ImportAsync(path, progress, cancellationToken);

Do not report every processed record if UI updates become the bottleneck. Report at sensible intervals. Page large tables, virtualise long lists and update only the part of the screen that changed.

For web UIs, SignalR or WebSockets can push progress and live updates instead of aggressive polling. Caching and pagination keep request work bounded. The framework changes; the principle remains: the thread serving interaction should not be trapped doing avoidable work.


14. Distributed systems: performance becomes architecture

Once you move into distributed systems, performance is no longer only about one method.

It becomes architecture.

Distributed-system performance brings CQRS, event sourcing, Azure Functions, Durable Functions, containers, serverless infrastructure and cloud deployment into the discussion.

In a monolith, one request might be:

Controller → Service → Database

In a distributed system:

API Gateway
  ↓
Order Service
  ↓
Message Broker
  ↓
Payment Service
  ↓
Inventory Service
  ↓
Notification Service
  ↓
Reporting Projection

Now performance questions become:

How many network hops? Which calls are synchronous? Which are async events? What happens if Payment Service is slow? Do we retry? Can we duplicate messages? Are consumers idempotent? Is there backpressure? Can we trace the request? Do we have correlation IDs?

CQRS example:

public record SubmitLoanCommand(
    string ApplicantName,
    decimal Amount) : IRequest<int>;

public class SubmitLoanHandler : IRequestHandler<SubmitLoanCommand, int>
{
    private readonly AppDbContext _db;

    public SubmitLoanHandler(AppDbContext db)
    {
        _db = db;
    }

    public async Task<int> Handle(SubmitLoanCommand request, CancellationToken ct)
    {
        var loan = new Loan
        {
            ApplicantName = request.ApplicantName,
            Amount = request.Amount,
            Status = LoanStatus.Submitted,
            SubmittedOn = DateTime.UtcNow
        };

        _db.Loans.Add(loan);
        await _db.SaveChangesAsync(ct);

        return loan.Id;
    }
}

Query side:

public record GetLoanDashboardQuery() : IRequest<LoanDashboardDto>;

public class GetLoanDashboardHandler
    : IRequestHandler<GetLoanDashboardQuery, LoanDashboardDto>
{
    private readonly AppDbContext _db;

    public GetLoanDashboardHandler(AppDbContext db)
    {
        _db = db;
    }

    public async Task<LoanDashboardDto> Handle(
        GetLoanDashboardQuery request,
        CancellationToken ct)
    {
        return new LoanDashboardDto
        {
            SubmittedCount = await _db.Loans.CountAsync(x => x.Status == LoanStatus.Submitted, ct),
            ApprovedCount = await _db.Loans.CountAsync(x => x.Status == LoanStatus.Approved, ct),
            TotalApprovedAmount = await _db.Loans
                .Where(x => x.Status == LoanStatus.Approved)
                .SumAsync(x => x.Amount, ct)
        };
    }
}

CQRS does not automatically mean separate databases. It means reads and writes are designed separately.

My rule of thumb:

In distributed systems, performance is affected by boundaries, communication style, consistency model, retries, observability and deployment design.

CQRS is separation, not free speed

Command Query Responsibility Segregation separates models for changing state from models for reading it. A read model can be shaped for common queries, but now it must be kept consistent with writes. Separate models add operational and cognitive cost, so use CQRS where differing read/write needs justify it.

Event sourcing changes the source of truth

Event sourcing stores a sequence of domain events rather than only the latest row state:

LoanSubmitted
LoanReviewed
LoanApproved
FundsReleased

Current state is rebuilt by replaying events or loading a snapshot and applying later events. This provides history and temporal reasoning but introduces event versioning, projection lag, storage growth and replay concerns. It is not an audit-log checkbox.

public abstract record LoanEvent(DateTimeOffset OccurredAt);
public sealed record LoanSubmitted(decimal Amount, DateTimeOffset OccurredAt)
    : LoanEvent(OccurredAt);
public sealed record LoanApproved(DateTimeOffset OccurredAt)
    : LoanEvent(OccurredAt);

Reliability affects performance

Retries increase load. Without backoff and jitter, many clients can retry together and worsen an outage. Timeouts bound waiting. Circuit breakers stop repeatedly calling a dependency known to be unhealthy. Bulkheads prevent one dependency from consuming every available worker or connection.

Messages may be delivered more than once, so consumers should be idempotent: processing the same message again does not duplicate the business effect. The transactional outbox pattern records a state change and outgoing message in one database transaction, then publishes the message separately.

Observe end to end

Use correlation identifiers, structured logs, metrics and distributed traces. A trace should show time spent at the gateway, service, database and message broker. Without this, teams optimise the visible service while latency lives in another boundary.

Serverless functions, containers and cloud services change scaling and deployment, but do not remove limits. Cold starts, connection pools, downstream quotas, partition keys, message size and cost per operation remain performance constraints. Infrastructure as code makes those choices reviewable and repeatable.


15. Threads, parallelism and async are not the same thing

This is a key interview topic.

Multithreading, parallel programming and asynchronous programming are related but distinct. Together they involve threads, synchronisation, locking, TPL, PLINQ, benchmarking, TAP, async, await, Task, ValueTask, WhenAll, cancellation and asynchronous I/O.

These concepts are related, but not identical.

A thread is an execution worker.

Multithreading means multiple threads exist.

Parallelism means doing CPU work at the same time across cores.

Async means not blocking while waiting for I/O.

This is wrong thinking:

“Async makes code run faster.”

More accurate:

“Async helps scalability and responsiveness by freeing threads while waiting for I/O.”

Example:

public async Task<CustomerDto> GetCustomerAsync(int id)
{
    var customer = await _dbContext.Customers.FindAsync(id);

    return new CustomerDto(customer.Id, customer.Name);
}

While the database is working, the thread can return to the thread pool instead of blocking.

Bad:

var customer = GetCustomerAsync(10).Result;

This blocks.

Better:

var customer = await GetCustomerAsync(10);

Parallelism example for CPU-bound work:

Parallel.ForEach(files, file =>
{
    ProcessImage(file);
});

Good for CPU-heavy processing if controlled properly.

But do not use Parallel.ForEach for database calls blindly.

Better for async I/O with controlled concurrency:

public async Task ProcessFilesAsync(
    IReadOnlyList<string> files,
    CancellationToken ct)
{
    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = 4,
        CancellationToken = ct
    };

    await Parallel.ForEachAsync(files, options, async (file, token) =>
    {
        await UploadFileAsync(file, token);
    });
}

Task.WhenAll example:

public async Task<DashboardDto> GetDashboardAsync()
{
    var customersTask = _customerService.GetCustomerCountAsync();
    var ordersTask = _orderService.GetOpenOrderCountAsync();
    var revenueTask = _financeService.GetMonthlyRevenueAsync();

    await Task.WhenAll(customersTask, ordersTask, revenueTask);

    return new DashboardDto
    {
        CustomerCount = await customersTask,
        OpenOrderCount = await ordersTask,
        MonthlyRevenue = await revenueTask
    };
}

This is useful when tasks are independent.

My rule of thumb:

Use async for I/O. Use parallelism for CPU work. Use threads directly rarely. Use cancellation everywhere long-running work can be abandoned.

Threads and the thread pool

Creating an operating-system thread is relatively expensive and each thread needs stack space. The .NET thread pool reuses worker threads for short units of work. Task is an abstraction representing an operation; it is not necessarily a dedicated thread.

Blocking many thread-pool workers on .Result, .Wait() or synchronous I/O can cause thread-pool starvation. Requests queue while the pool slowly adds workers. Symptoms include growing latency with CPU not fully utilised.

Direct Thread use is reserved for uncommon cases requiring a dedicated long-running thread, apartment state or specialised scheduling. Prefer tasks, async APIs and higher-level coordination.

Parallelism needs enough work

Parallel execution adds partitioning, scheduling, synchronisation and merging overhead. Ten tiny calculations may run slower in parallel. Large independent CPU-bound items are better candidates.

var options = new ParallelOptions
{
    MaxDegreeOfParallelism = Environment.ProcessorCount,
    CancellationToken = ct
};

Parallel.ForEach(images, options, image =>
{
    ResizeAndEncode(image);
});

MaxDegreeOfParallelism is not automatically the processor count for every workload. The service may share the machine, individual operations may use native parallelism, and memory bandwidth may become the limit.

PLINQ adds parallel execution to LINQ with AsParallel(). It is suitable only for sufficiently large in-memory CPU-bound work with independent items. Ordering with AsOrdered and shared state can reduce its benefit.

var hashes = files
    .AsParallel()
    .WithCancellation(ct)
    .Select(CalculateCpuIntensiveHash)
    .ToArray();

Benchmark realistic sizes and confirm result ordering requirements.

TAP, Task and ValueTask

The Task-based Asynchronous Pattern uses methods ending in Async, returns Task or Task, accepts an optional CancellationToken, and reports exceptions through the returned task.

ValueTask can avoid allocating a Task when an operation frequently completes synchronously. It has usage restrictions and can make callers more complicated. Begin with Task and use ValueTask only after measurement proves a high-frequency benefit.

WhenAll and failure

Start independent operations before awaiting them, then await them together. If operations depend on each other, running them concurrently is incorrect.

Task<Customer> customerTask = GetCustomerAsync(id, ct);
Task<IReadOnlyList<Order>> ordersTask = GetOrdersAsync(id, ct);

await Task.WhenAll(customerTask, ordersTask);

return new Dashboard(await customerTask, await ordersTask);

Awaiting observes failure without blocking a thread. Decide whether one failure invalidates the whole result, whether partial results are acceptable and whether remaining work should be cancelled.

Cancellation is cooperative. A token communicates a request; code must pass it to dependencies and periodically observe it. A timeout is a policy about how long you are willing to wait and can be expressed with a linked cancellation source.


16. Locks and shared state

Performance is not only speed. Correctness matters too.

If multiple threads access shared mutable state, race conditions can happen.

Bad:

private int _counter;

public void Increment()
{
    _counter++;
}

_counter++ is not one operation. It is read, increment, write.

Better for simple counters:

Interlocked.Increment(ref _counter);

For critical sections:

private readonly object _lock = new();
private readonly List<string> _items = new();

public void AddItem(string item)
{
    lock (_lock)
    {
        _items.Add(item);
    }
}

But avoid locking for too long.

Bad:

lock (_lock)
{
    var data = await GetDataAsync(); // not allowed with lock anyway
}

For async coordination, use SemaphoreSlim:

private readonly SemaphoreSlim _semaphore = new(1, 1);

public async Task UpdateCacheAsync()
{
    await _semaphore.WaitAsync();

    try
    {
        await RefreshCacheAsync();
    }
    finally
    {
        _semaphore.Release();
    }
}

My rule of thumb:

Shared mutable state is where performance and correctness both go to fight. Avoid it when possible. Control it carefully when unavoidable.

Know what a lock protects

A lock protects an invariant, not merely a collection. Keep the protected region small, avoid I/O inside it and use the same lock consistently for every access participating in that invariant.

Interlocked handles small atomic numeric and reference operations. lock/Monitor handles synchronous critical sections. SemaphoreSlim limits concurrency and supports asynchronous waiting. ReaderWriterLockSlim can help specialised read-heavy synchronous state, but its complexity and overhead require measurement.

Prefer ownership over sharing

A channel can give one consumer ownership of mutable state while producers send commands:

Channel<OrderCommand> channel = Channel.CreateBounded<OrderCommand>(100);

await channel.Writer.WriteAsync(command, ct);

await foreach (OrderCommand next in channel.Reader.ReadAllAsync(ct))
{
    await HandleInOrderAsync(next, ct);
}

The bounded channel applies backpressure. A single reader can update its state without competing writers. Immutable snapshots are another strategy: writers create a new version and readers safely retain the previous version.

Deadlocks and lock ordering

If one path locks A then B while another locks B then A, each can wait forever for the other. Establish one lock order and keep locks private. Avoid executing unknown callbacks while holding a lock because the callback may acquire other locks or take unbounded time.

Correctness comes first. A fast race condition is still a defect.

Revision checkpoint for Part III

  • UI and request threads must remain available.
  • Async waiting is not CPU parallelism.
  • Distributed performance includes failure, retries and observability.
  • Parallel work needs sufficient independent CPU work and controlled degree.
  • Cancellation is cooperative and must flow through the call chain.
  • Reduce shared mutable state before adding increasingly clever locks.

Part IV - Build mastery through practice

A repeatable performance review

When reviewing a feature, work from the outside in:

  1. Define the user-visible scenario and target.
  2. Record data size, concurrency and environment.
  3. Measure end-to-end latency and throughput.
  4. Use traces to divide time across application, database and network.
  5. Profile CPU and allocations only where the trace points.
  6. Check query plans, rows read, payload size and round trips.
  7. Check queue length, thread-pool starvation, contention and GC.
  8. Form one hypothesis and change one relevant factor.
  9. Measure the same workload again.
  10. Add a regression guard and document the trade-off.

Final revision questions

Try answering these without scrolling back:

  1. What is the difference between latency and throughput?
  2. Why can the first method call be slower than later calls?
  3. Why is “struct equals stack, class equals heap” incomplete?
  4. When does StringBuilder help, and when is interpolation clearer?
  5. What is boxing and how can generics reduce it?
  6. Why can a garbage-collected application leak memory?
  7. What is the difference between disposal and garbage collection?
  8. Which tool would you choose for CPU, allocation and retention questions?
  9. Why can a dictionary beat a list for repeated lookup but lose for one tiny scan?
  10. When does a LINQ query execute, and where does IQueryable execute?
  11. Why does streaming keep memory bounded?
  12. What costs can occur before a remote server begins handling a request?
  13. Why might an index improve reads but harm writes?
  14. What creates an N+1 query pattern?
  15. Why does async improve scalability without making CPU work faster?
  16. When is parallel execution likely to be slower?
  17. What does Task.WhenAll require about the operations?
  18. Why does retry behaviour need backoff and idempotency?
  19. What invariant does a lock protect?
  20. What evidence proves an optimisation succeeded?

Capstone: diagnose and improve an import service

Build an ASP.NET Core endpoint that accepts a large product file and processes it in the background.

Start with a deliberately simple version, then improve it through evidence:

  1. Stream the uploaded file instead of reading it entirely into memory.
  2. Parse rows into a bounded channel so reading cannot outrun database writing indefinitely.
  3. Validate and batch database writes using parameterised operations.
  4. Expose progress through a status endpoint or SignalR.
  5. Propagate cancellation when the job is abandoned.
  6. Record counters for rows read, rejected and persisted.
  7. Trace database batches and external calls with one correlation ID.
  8. Benchmark parsing separately from integration load testing.
  9. Capture allocation and GC behaviour before and after buffer changes.
  10. Document why the selected batch size and channel capacity fit the workload.
This capstone combines memory, strings, spans, disposal, profiling, collections, async streams, file I/O, data access, responsiveness, backpressure, cancellation and observability. Do not optimise every stage at once. Establish a baseline, find the current limit and improve one bottleneck at a time.

Mentoring session: turning a vague complaint into a useful investigation

Junior: “A customer says the orders page is slow. Should I start by replacing LINQ with a loop?”

Senior: “Not yet. What does *slow* mean in this journey?”

That question is the beginning of responsible performance work. The complaint might mean the first page takes six seconds to become usable, a filter pauses after every keystroke, one API call occasionally times out, or the system becomes unresponsive only when many people export data. Those are different problems. A local LINQ allocation might be irrelevant if the request spends five seconds waiting for an unindexed SQL query.

Start by writing a compact performance statement:

For a customer with 50,000 orders, loading the first 50 rows through the production-like environment takes 4.8 seconds at the 95th percentile. The agreed target is below 1.5 seconds while 40 users are active.
The statement names the journey, data volume, environment, measurement, percentile, concurrency and target. It gives the team something testable. Without it, two developers can optimise different things and both claim success.

Junior: “Why use the 95th percentile instead of the average?”

Senior: “Because an average can hide the customers having the worst ordinary experience. We normally care about the distribution: median for the typical request, a high percentile for the slower requests, and the maximum as a clue rather than a stable target.”

Next, capture one trace for a representative slow request. Suppose it shows:

  • 40 ms in Angular rendering;
  • 35 ms in ASP.NET Core middleware and application logic;
  • 4,400 ms awaiting SQL Server;
  • 120 ms serialising and transferring a large response.
The trace has already protected us from a premature rewrite of the frontend or C# collection code. The database span becomes the next boundary to inspect. Capture the generated SQL, actual execution plan, row counts and logical reads. You might discover that a date filter applies a function to the indexed column:
var orders = await db.Orders
    .Where(order => order.CreatedAt.Date == requestedDate)
    .OrderByDescending(order => order.CreatedAt)
    .Take(50)
    .Select(order => new OrderRow(order.Id, order.Number, order.Total))
    .ToListAsync(cancellationToken);

Depending on the provider and schema, the date transformation can prevent an efficient seek. Expressing a range communicates the requirement more directly:

var start = requestedDate.Date;
var end = start.AddDays(1);

var orders = await db.Orders
    .Where(order => order.CreatedAt >= start && order.CreatedAt < end)
    .OrderByDescending(order => order.CreatedAt)
    .Take(50)
    .Select(order => new OrderRow(order.Id, order.Number, order.Total))
    .ToListAsync(cancellationToken);

This is not automatically faster merely because the code looks better. Check the translated SQL and plan. Confirm the supporting index matches the filtering and ordering needs. Repeat the same workload, compare elapsed time and logical reads, and watch whether the change harms write cost or storage. Performance advice becomes engineering only when the evidence closes the loop.

What I would expect in the pull request

A credible performance pull request should include more than “optimised query.” I would look for:

  1. the user-visible scenario and baseline;
  2. the evidence that identified the bottleneck;
  3. the hypothesis being tested;
  4. before-and-after measurements using comparable data;
  5. correctness tests, especially around date boundaries and time zones;
  6. any new index and its expected write/storage cost;
  7. monitoring that will reveal regression after release;
  8. a rollback or mitigation plan for a high-risk change.
Junior: “What if the improvement is only visible on my machine?”

Senior: “Then we have learned that the evidence is not yet strong enough. Development measurements help us form hypotheses, but production-like data shape, network distance, concurrency and resource limits can change the result.”

A practical exercise

Choose one endpoint from a project you control and create a performance investigation note. Do not change the code during the first pass. Record its response-size distribution, query count, rows read, dependency timings, allocations and behaviour under modest concurrency. Then write three hypotheses in priority order. Test only the first hypothesis, capture the result and decide whether to keep, revise or reject it. The goal is not to produce a dramatic speed-up; it is to practise a method that makes accidental optimisation less likely.

Final mentor's note

Fast code that returns the wrong answer is a defect. Fast code that nobody can maintain often transfers cost from runtime to people. And a microbenchmark win that does not improve the user journey is an interesting experiment, not yet a product improvement. Protect correctness, measure the system at the right boundary, and optimise where evidence says the business will benefit.


What I want you to take away

High-performance C# is not about memorising tricks. It is about understanding the machine beneath the code.

A senior .NET developer should understand that C# compiles to IL, the runtime loads assemblies, the JIT produces native code, and the GC manages memory. They should understand stack, heap, value types, reference types, strings, boxing, allocations, object lifetime, events, IDisposable, finalizers and memory leaks.

They should know how to profile before optimising. They should use BenchmarkDotNet for focused benchmarks, dotnet-counters and profiling tools for runtime behaviour, and memory profilers when allocation or leaks are suspected.

They should choose collections based on access patterns, not habit. List, Dictionary, HashSet, arrays, queues, stacks and concurrent collections each solve different problems. They should use LINQ carefully, understanding deferred execution, repeated enumeration, database translation and hidden allocation.

They should stream large files, avoid loading huge data into memory unnecessarily, use async I/O, reduce network calls, shape API payloads, use caching wisely and understand when gRPC, HTTP APIs, SignalR or raw TCP-style communication makes sense.

They should know that EF Core, Dapper and ADO.NET are not enemies. They are tools. EF Core gives productivity. Dapper gives focused SQL performance. ADO.NET gives low-level control. The serious engineer benchmarks important paths and chooses deliberately.

They should understand that responsive UI means not blocking the UI thread, and responsive web APIs mean not blocking request threads. They should know that distributed-system performance is architecture: boundaries, messaging, CQRS, event sourcing, retries, idempotency, observability and cloud infrastructure.

Finally, they must understand the difference between threads, parallelism and async. Threads execute. Parallelism uses multiple cores for CPU work. Async frees threads while waiting for I/O. Task.WhenAll, cancellation tokens, SemaphoreSlim, locks, Interlocked, concurrent collections and TPL are tools, not decorations.

The mature interview answer is this:

“High-performance .NET development starts with measurement. I look at CPU, memory, allocations, GC, database queries, network calls, thread pool behaviour and user-perceived responsiveness. Then I optimise the actual bottleneck using the right data structures, async I/O, efficient LINQ, controlled parallelism, caching, streaming, profiling tools and clean architecture.”
That is the level where performance stops being guesswork and becomes engineering.

References and source material

Applied In

The thinking in this article has been applied throughout my enterprise portfolio, where architecture, workflows, permissions, notifications, reporting and modular design are all built around real business operations rather than isolated technical features.

View Technical Skills →

Use this journal entry for recall practice

Compare your explanation with the questions and working answers in my Practice Room.

Practise .NET performance and concurrency questions →
Afzal Ahmed

Faz Ahmed

Senior Full Stack Engineer & Technical Lead

A hands-on engineer with 15+ years in commercial software. I publish what I am studying, revising and testing so visitors can see both established experience and learning still in progress.

How would you approach this problem? I'd love to hear your thoughts or continue the discussion.

Connect on LinkedIn →