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#
dotnet add package BenchmarkDotNet
[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#
// 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.
// 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#
// 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#
// 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.
// 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#
// 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:
// 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();
// 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#
// Blocks a thread and can deadlock
var result = GetDataAsync().Result;
GetDataAsync().Wait();
// Correct
var result = await GetDataAsync();
// 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.
// 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.
// 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();
// 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#
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#
- C# collections — choosing correctly in the first place.
- Asynchronous programming with C# — the async section in depth.
- C# and databases — where most of the time goes.