Skip to content
Happy Programming Guide
Start learning
C#

C# Performance Optimization

Profiling before guessing, then the fixes that pay: allocations, LINQ in hot paths, string building, Span, async correctness and database queries.

An office workspace with a computer

C# is fast, and slow C# applications are usually slow for reasons outside the language — a database query in a loop, excessive allocation, or blocking on async work. This guide covers how to find the real cost and the changes that reliably help, in the order worth trying them.

Measure properly#

Terminal
dotnet add package BenchmarkDotNet
CSHARP
[MemoryDiagnoser]
public class StringBenchmarks
{
    private readonly string[] _parts = Enumerable.Range(0, 100)
                                                 .Select(i => i.ToString())
                                                 .ToArray();

    [Benchmark(Baseline = true)]
    public string Concatenation()
    {
        string result = "";
        foreach (var part in _parts) result += part;
        return result;
    }

    [Benchmark]
    public string Builder()
    {
        var sb = new StringBuilder();
        foreach (var part in _parts) sb.Append(part);
        return sb.ToString();
    }
}

A Stopwatch around a loop is misleading, because the JIT optimises code after it has run a few thousand times and may eliminate work whose result you ignore. BenchmarkDotNet handles warm-up, runs enough iterations to be statistically meaningful, and with [MemoryDiagnoser] reports allocations too.

For a whole application, use a profiler — Visual Studio’s, dotTrace, or the free dotnet-trace and dotnet-counters tools.

Strings#

CSHARP
// Allocates a new string every iteration
string result = "";
foreach (var part in parts) result += part;

// One buffer
var sb = new StringBuilder();
foreach (var part in parts) sb.Append(part);
string result = sb.ToString();

// Or, for a simple join
string result = string.Join(", ", parts);

Concatenation outside a loop is fine — the compiler turns it into a single efficient call. Only the loop is quadratic.

CSHARP
// Comparison without allocating
name.Equals(other, StringComparison.OrdinalIgnoreCase);   // good
name.ToLower() == other.ToLower();                        // two allocations

// Checking a prefix
path.StartsWith("/api", StringComparison.Ordinal);

Passing StringComparison.Ordinal explicitly is both faster and more predictable than culture-aware comparison, which is the default for some overloads.

Span and slicing#

CSHARP
// Allocates three substrings
string date = "2026-09-06";
int year = int.Parse(date.Substring(0, 4));

// Allocates nothing
ReadOnlySpan<char> span = date;
int year = int.Parse(span[..4]);

Span is a view over existing memory, so slicing it costs nothing. It is genuinely useful in parsing loops that run millions of times; it is unnecessary ceremony in ordinary application code.

Collections#

CSHARP
// Checks every element, for every lookup
var blocked = new List<string>(fiftyThousandWords);
if (blocked.Contains(word)) { }

// One hash lookup
var blockedSet = new HashSet<string>(fiftyThousandWords);
if (blockedSet.Contains(word)) { }

A Contains on a list inside a loop is a quadratic algorithm hiding in plain sight, and converting to a set is often the single largest improvement available.

CSHARP
// Size it up front if you know roughly how many
var list = new List<Order>(expectedCount);
var map = new Dictionary<string, Order>(expectedCount);

LINQ in hot paths#

CSHARP
// Allocates enumerators and closures per call
var count = orders.Where(o => o.IsPaid).Count();

// No allocation
var count = 0;
foreach (var o in orders) if (o.IsPaid) count++;

LINQ is not slow in any way that matters for ordinary code. In a method called millions of times it does add measurable overhead. Write LINQ by default and convert only where a profiler points.

Two LINQ mistakes that matter more than the overhead:

CSHARP
// Enumerated twice - the work runs twice
var query = orders.Where(o => o.IsPaid);
Console.WriteLine(query.Count());
foreach (var o in query) { }        // filters again

// Materialise once
var paid = orders.Where(o => o.IsPaid).ToList();
CSHARP
// Loads everything then filters in memory
var recent = context.Orders.ToList().Where(o => o.Date > cutoff);

// Filters in the database
var recent = context.Orders.Where(o => o.Date > cutoff).ToList();

Async correctness#

CSHARP
// Blocks a thread and can deadlock
var result = GetDataAsync().Result;
GetDataAsync().Wait();

// Correct
var result = await GetDataAsync();
CSHARP
// Sequential - the total is the sum
var a = await FetchAAsync();
var b = await FetchBAsync();

// Concurrent - the total is the slowest
var taskA = FetchAAsync();
var taskB = FetchBAsync();
await Task.WhenAll(taskA, taskB);

The second pattern is frequently the largest single improvement in an application that calls several services.

CSHARP
// Avoid allocating a Task when the result is already known
public ValueTask<int> GetAsync(int id)
{
    if (_cache.TryGetValue(id, out var cached))
        return new ValueTask<int>(cached);

    return new ValueTask<int>(LoadAsync(id));
}

ValueTask is worth it only for methods that usually complete synchronously and are called very often. Elsewhere, plain Task is simpler and correct.

Database access#

In most business applications this dominates everything above.

CSHARP
// N+1: one query, then one per row
var orders = context.Orders.ToList();
foreach (var o in orders) Console.WriteLine(o.Customer.Name);

// One query
var orders = context.Orders.Include(o => o.Customer).ToList();
CSHARP
// Read-only queries do not need change tracking
var orders = context.Orders.AsNoTracking().Where(...).ToList();

// Select only the columns you need
var summaries = context.Orders
    .Select(o => new { o.Id, o.Total })
    .ToList();

Turn on query logging in development and count the queries per page. If the number is in the hundreds, nothing else here matters yet.

Caching#

CSHARP
public async Task<Product?> GetAsync(int id)
{
    return await _cache.GetOrCreateAsync(id, async entry =>
    {
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
        return await _repository.GetAsync(id);
    });
}

The fastest work is work you do not do. Prefer short expiry times to clever invalidation until you have evidence you need more.

Things that do not matter#

  • Declaring variables outside loops. The JIT handles it.
  • i++ versus ++i. Identical.
  • Removing comments. They are gone at compile time.
  • Micro-tuning code called ten times. Optimise what runs ten million times.
  • Calling GC.Collect(). Almost always makes things worse.

Questions people ask#

Is C# slower than C++?

Somewhat, and much less than people assume. The JIT compiles hot paths to machine code using runtime information. For typical server work the difference is dominated by I/O.

Should I use structs instead of classes?

Only for small, immutable values used in large quantities. Structs avoid heap allocation but are copied on every assignment, so a large struct is slower than the class it replaced.

Does Native AOT make my application faster?

It makes start-up much faster and memory use lower, which matters for serverless and command-line tools. Steady-state throughput can be slightly lower, since there is no JIT to optimise using runtime data.

How do I find allocations in a running application?

dotnet-counters for a live view of the garbage collector, and dotnet-trace or a profiler for a full allocation profile.

Where to go next#

Async and await in C#, done correctlyRead next

Keep reading

C#

Introduction to C#

What C# is, what .NET provides, where the language is used, and your first program — plus how it compares with Java,…

4 min read

C#

C# Events and Delegates

What a delegate is, how events build on them, Action and Func, and the two mistakes that cause memory leaks and null…

5 min read

C#

C# Design Patterns

The design patterns that earn their place in C#: strategy, factory, repository, decorator, observer and options — with the language features that…

4 min read

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

Your email address will not be published. Required fields are marked *