Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save InKolev/50ce79c6cc272ef003cd501f328c373a to your computer and use it in GitHub Desktop.

Select an option

Save InKolev/50ce79c6cc272ef003cd501f328c373a to your computer and use it in GitHub Desktop.

.NET 10 / C# 14 - Performance Optimization Guide

Target: .NET 10 (LTS) Β· C# 14 Β· Last updated March 2026

This guide is a curated set of 100 performance rules, tips, and idioms every developer on this project must know. Each entry is concise and actionable. Entries marked πŸ†• leverage features new in .NET 10 / C# 14.


Table of Contents

  1. Async & Task Parallelism
  2. Thread & Concurrency Primitives
  3. LINQ Optimization
  4. Loops & Iteration
  5. Collections & Data Structures
  6. Memory, Span & Allocation
  7. String Processing
  8. Caching & Lazy Loading
  9. Serialization & I/O
  10. JIT, Runtime & GC
  11. EF Core & Data Access
  12. ASP.NET Core & HTTP
  13. General C# Idioms
  14. Benchmarking & Profiling

1. Async & Task Parallelism

1 - Prefer async/await over blocking calls - always

Never call .Result, .Wait(), or .GetAwaiter().GetResult() on hot paths. These block the calling thread and can cause thread-pool starvation and deadlocks (especially under SynchronizationContext).

2 - Use ValueTask / ValueTask<T> for hot-path methods likely to complete synchronously

ValueTask avoids the Task heap allocation when the result is already available. Ideal for cache hits, buffered reads, or fast-path returns.

public ValueTask<int> GetCountAsync()
{
    if (_cache.TryGet("count", out int val))
        return ValueTask.FromResult(val);

    return new ValueTask<int>(FetchCountFromDbAsync());
}

⚠️ Never await a ValueTask more than once and never use .Result on one that is not yet completed.

3 - Use ConfigureAwait(false) in library code

In library/service code (anything that doesn't touch UI), always append .ConfigureAwait(false) to avoid capturing SynchronizationContext. This prevents unnecessary thread marshalling.

4 - Use Task.WhenAll for independent concurrent operations

When you need results from N independent I/O calls, fire them all then await the batch:

var (users, orders) = (GetUsersAsync(), GetOrdersAsync());
await Task.WhenAll(users, orders);

5 - Prefer Parallel.ForEachAsync for CPU+I/O hybrid fan-outs πŸ†•

.NET 6+ provides Parallel.ForEachAsync which respects MaxDegreeOfParallelism and async delegates natively. Prefer it over manually spawning tasks in a loop.

await Parallel.ForEachAsync(urls, new ParallelOptions { MaxDegreeOfParallelism = 8 },
    async (url, ct) => await ProcessAsync(url, ct));

6 - Avoid Task.Run to fake async

Wrapping synchronous code in Task.Run inside a library only hides the blocking. Push async down to the real I/O boundary. Task.Run is appropriate only at the application boundary (e.g., offloading CPU work from a UI thread).

7 - Use CancellationToken everywhere

Pass CancellationToken through every async call chain. Cancelled tasks free resources early and prevent wasted compute. Check token.ThrowIfCancellationRequested() in CPU loops.

8 - Pool and reuse SemaphoreSlim for throttling

Use SemaphoreSlim to limit concurrent access to scarce resources (DB connections, third-party APIs). Create once, reuse always.

private static readonly SemaphoreSlim _gate = new(10, 10);

await _gate.WaitAsync(cancellationToken);
try { /* work */ }
finally { _gate.Release(); }

9 - Prefer channels over BlockingCollection for producer/consumer

System.Threading.Channels are allocation-lean, backpressure-aware, and fully async. Use Channel.CreateBounded<T> for natural flow control.

10 - Avoid async void - period

async void swallows exceptions and cannot be awaited. The only acceptable use is event handlers in UI frameworks. Everywhere else: async Task.


2. Thread & Concurrency Primitives

11 - Don't create raw Thread instances for work items

Use Task.Run, ThreadPool.QueueUserWorkItem, or Parallel.*. The thread pool manages sizing and reuse. Raw threads bypass pool economics.

12 - Use lock (or Lock object in .NET 10) for simple critical sections πŸ†•

.NET 9+ introduced System.Threading.Lock - a lightweight, purpose-built lock type. Using lock (myLockObj) with a Lock instance emits optimized code paths.

private readonly Lock _lock = new();

lock (_lock)
{
    // critical section
}

13 - Prefer ReaderWriterLockSlim for read-heavy workloads

If reads vastly outnumber writes, ReaderWriterLockSlim allows concurrent readers and exclusive writers, beating plain lock on throughput.

14 - Use Interlocked for atomic counter/flag operations

Interlocked.Increment, .CompareExchange, .Exchange are lock-free and orders of magnitude faster than locks for simple counters and flags.

15 - Avoid allocating closures in hot-path delegates

Closures capture local variables into a compiler-generated class on the heap. Pass state via static lambdas + explicit state parameter where possible.

ThreadPool.QueueUserWorkItem(static state =>
{
    var ctx = (MyContext)state!;
    ctx.Process();
}, context);

3. LINQ Optimization

16 - Avoid LINQ in ultra-hot loops

LINQ allocates iterators, delegates, and closures. On hot paths (millions of iterations, tight game loops, packet parsing), use hand-written for/foreach with spans.

17 - Materialize once - query many

Don't re-evaluate .Where(...).Select(...) chains multiple times. Call .ToList() or .ToArray() once and reuse.

18 - Use Enumerable.TryGetNonEnumeratedCount before sizing buffers

Before calling .Count() (which may enumerate the full sequence), use TryGetNonEnumeratedCount to check if the collection already knows its length.

19 - Prefer .Any() over .Count() > 0

.Any() short-circuits after the first element. .Count() may enumerate the entire sequence.

20 - Use System.Linq.AsyncEnumerable for async stream LINQ πŸ†•

.NET 10 includes System.Linq.AsyncEnumerable in the core libraries. Use it for composable async pipelines without third-party packages.

21 - Avoid .ToList() when you only need to iterate once

If the consumer only iterates, return IEnumerable<T> (deferred) or pass the query directly. Materializing into a List<T> allocates an array that may not be needed.

22 - Use .Order() / .OrderDescending() (introduced in .NET 7+) for keyless sorts

These avoid the overhead of a key-selector delegate when sorting by the element itself.

23 - Chain .Select before .Where only when the projection is needed for filtering

Otherwise filter first (.Where) to reduce the number of projections executed.

24 - Use Chunk(n) for batch processing

Enumerable.Chunk(n) (introduced .NET 6) splits a sequence into arrays of size n with zero custom code.


4. Loops & Iteration

25 - Use for over foreach on arrays and List<T> for indexed access

The JIT elides bounds checks when it can prove i < array.Length. foreach on List<T> incurs an enumerator struct copy.

26 - πŸ†• foreach over IEnumerable<T> on arrays is now JIT-optimized

.NET 10's JIT can devirtualize and inline array interface methods - IEnumerable<T> iteration on arrays is now close to raw for loop performance. Still, if you control the code, prefer Span<T> or for.

27 - Iterate Span<T> / ReadOnlySpan<T> for zero-allocation loops

foreach on a span compiles to efficient pointer-arithmetic code with no heap allocation.

ReadOnlySpan<int> data = collection.AsSpan();
foreach (var item in data) { /* zero alloc */ }

28 - Hoist invariant work out of loops

Move allocations, lookups, and computations that don't change per-iteration before the loop.

29 - Avoid LINQ inside loops

Calling .FirstOrDefault(), .Any(), or .Where() inside a for/foreach creates hidden O(NΓ—M) complexity and per-iteration allocations.

30 - Use ref returns and ref locals to avoid struct copies in loops

When iterating over a large struct[], use ref to avoid copying:

for (int i = 0; i < items.Length; i++)
{
    ref var item = ref items[i];
    item.Value += 1; // mutate in place, no copy
}

31 - Prefer CollectionsMarshal.AsSpan(list) for List<T> inner-array access

This gives you a Span<T> over the list's internal buffer - no copy, no enumerator, fully bounds-checked.

var span = CollectionsMarshal.AsSpan(myList);
for (int i = 0; i < span.Length; i++) { /* fast */ }

32 - Unroll only when benchmarks prove it

Manual unrolling rarely beats the JIT's own loop optimizations in .NET 10. Profile first.


5. Collections & Data Structures

33 - Pre-size collections when the count is known or estimable

new List<T>(capacity), new Dictionary<K,V>(capacity), new HashSet<T>(capacity) - prevents repeated resize/re-hash.

34 - Use FrozenDictionary<K,V> and FrozenSet<T> for read-only lookup tables

System.Collections.Frozen collections (.NET 8+) optimize for read-heavy workloads after construction. Lookups are significantly faster than Dictionary in high-concurrency scenarios.

FrozenDictionary<string, Config> config = data.ToFrozenDictionary(x => x.Key, x => x.Value);

35 - Use ConcurrentDictionary only when truly concurrent

The overhead of striped locking isn't free. If access is single-threaded or guarded externally, use a plain Dictionary.

36 - Prefer Dictionary.TryGetValue over ContainsKey + indexer

ContainsKey + [key] performs two lookups. TryGetValue performs one.

37 - Use CollectionExpression syntax [..] for concise initialization (C# 12+)

int[] nums = [1, 2, 3, 4, 5]; // compiler picks optimal backing

The compiler may use ReadOnlySpan<T> or inline arrays internally.

38 - Use PriorityQueue<T, TPriority> instead of sorted collections for priority work

Introduced in .NET 6, it's a proper min-heap - O(log n) enqueue/dequeue.

39 - Use ArrayPool<T>.Shared.Rent/Return for temporary large arrays

Avoid repeated allocation of large arrays. Pool them.

var buffer = ArrayPool<byte>.Shared.Rent(8192);
try { /* use buffer */ }
finally { ArrayPool<byte>.Shared.Return(buffer); }

40 - Use ImmutableArray<T> for thread-safe, small, fixed-size collections

Lower overhead than ImmutableList<T> (no tree, just an array wrapper).

41 - Prefer Stack<T> / Queue<T> over List<T> for LIFO/FIFO patterns

They have purpose-optimized internal storage and avoid the temptation of random access.

42 - Avoid boxing value types in collections

Never store value types in ArrayList, List<object>, or non-generic collections. Each element boxes to the heap. Use generic List<T> or typed arrays.


6. Memory, Span & Allocation

43 - πŸ†• Use implicit span conversions (C# 14)

C# 14 adds implicit conversions between T[], Span<T>, and ReadOnlySpan<T>. You no longer need .AsSpan() in many cases - just pass the array where a span is expected.

void Process(ReadOnlySpan<byte> data) { }
byte[] buffer = GetData();
Process(buffer); // implicit conversion in C# 14

44 - πŸ†• Use params ReadOnlySpan<T> for zero-allocation variadic methods

.NET 8+ supports params Span<T>. Callers pass inline args with no hidden array allocation:

void Log(params ReadOnlySpan<string> messages) { }
Log("start", "processing", "done"); // no array allocated

45 - Use stackalloc for small, fixed-size buffers

For buffers under ~512 bytes whose lifetime doesn't escape the method, stackalloc avoids heap allocation entirely:

Span<byte> buf = stackalloc byte[256];

46 - πŸ†• Small arrays get stack-allocated by the JIT automatically

.NET 10's escape analysis can stack-allocate small arrays that don't escape the method. Write idiomatic code and let the JIT decide:

int[] temp = [1, 2, 3]; // may be stack-allocated in .NET 10
var sum = temp.Sum();

47 - Use Memory<T> when you need to store span-like data on the heap

Span<T> is stack-only. When you need to pass a slice across async boundaries, use Memory<T> / ReadOnlyMemory<T>.

48 - Minimize allocations in hot paths - measure with [MemoryDiagnoser]

Every heap allocation eventually costs GC time. Use BenchmarkDotNet's [MemoryDiagnoser] to track Allocated bytes.

49 - Pool objects with ObjectPool<T> from Microsoft.Extensions.ObjectPool

For expensive-to-create objects (regex engines, StringBuilder, custom contexts) that are needed repeatedly, pool them.

50 - Use MemoryMarshal and Unsafe sparingly for zero-copy casts

MemoryMarshal.Cast<TFrom, TTo>(span) performs zero-copy reinterpret casts between blittable types. Powerful but unsafe - use only when profiling proves necessity.


7. String Processing

51 - Use StringBuilder for concatenation in loops

String concatenation in a loop allocates a new string per iteration. StringBuilder amortizes.

52 - Use string interpolation with DefaultInterpolatedStringHandler (C# 10+)

Modern string interpolation compiles to stack-based Span<char> builders when assigned to string. For custom targets, accept ref DefaultInterpolatedStringHandler.

53 - Use string.Create(length, state, action) for known-length strings

Allocates once, writes directly into the buffer:

string result = string.Create(10, seed, (span, s) =>
{
    // fill span directly
});

54 - Prefer ReadOnlySpan<char> slicing over Substring

Substring allocates a new string. Slicing a ReadOnlySpan<char> is allocation-free:

ReadOnlySpan<char> name = fullName.AsSpan()[..5];

55 - Use StringComparison.Ordinal / OrdinalIgnoreCase for non-linguistic comparisons

Ordinal comparisons are ~5Γ— faster than culture-aware ones. Always specify the comparison type explicitly.

56 - Use SearchValues<char> (.NET 8+) for repeated character searches

SearchValues pre-computes a vectorized lookup table. Reuse the instance across calls.

private static readonly SearchValues<char> Vowels = SearchValues.Create("aeiouAEIOU");
bool hasVowel = input.AsSpan().ContainsAny(Vowels);

57 - Prefer CompositeFormat for repeated string.Format patterns (.NET 8+)

Pre-parse the format string once, reuse many times:

private static readonly CompositeFormat Fmt = CompositeFormat.Parse("Hello, {0}! You have {1} items.");
string msg = string.Format(null, Fmt, name, count);

58 - Use Encoding.UTF8.GetBytes(ReadOnlySpan<char>, Span<byte>) to avoid allocations

Encode directly into a buffer instead of allocating a byte[].

59 - Avoid Regex construction in loops - use GeneratedRegex (source-gen)

[GeneratedRegex(@"\d{4}-\d{2}-\d{2}", RegexOptions.Compiled)]
private static partial Regex DatePattern();

Source-generated regex is compiled at build time - no runtime compilation cost, better throughput, and AOT-compatible.

60 - Use string.Concat(ReadOnlySpan<char>, ...) for few-part concatenation

For 2–4 parts, string.Concat overloads are faster than interpolation or + because they calculate exact length upfront.


8. Caching & Lazy Loading

61 - Use Lazy<T> for thread-safe deferred initialization

private readonly Lazy<ExpensiveResource> _resource = new(() => new ExpensiveResource());

62 - Use LazyInitializer.EnsureInitialized for struct-friendly lazy init

Unlike Lazy<T>, this doesn't require a wrapper object.

63 - Use IMemoryCache with size limits for application-level caching

Always set SizeLimit on MemoryCache and assign Size to entries. Unbounded caches cause memory leaks.

64 - Use GetOrCreateAsync pattern for cache-aside

var value = await cache.GetOrCreateAsync("key", async entry =>
{
    entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
    return await FetchDataAsync();
});

65 - Use ConditionalWeakTable<TKey, TValue> for attaching metadata to objects without preventing GC

Entries are collected when the key is collected.

66 - Prefer HybridCache (.NET 9+) for stampede-resistant distributed caching

HybridCache (Microsoft.Extensions.Caching.Hybrid) coalesces concurrent requests for the same key, preventing cache stampedes. It supports L1 (in-memory) + L2 (distributed) out of the box.


9. Serialization & I/O

67 - Use System.Text.Json source generators for AOT and speed

[JsonSerializable(typeof(MyDto))]
internal partial class AppJsonContext : JsonSerializerContext { }

Source-generated serialization avoids runtime reflection, is NativeAOT-compatible, and is up to 40% faster.

68 - πŸ†• Use streaming JSON deserialization

.NET 10 improves JsonSerializer.DeserializeAsyncEnumerable<T> performance. For large payloads, stream instead of buffering:

await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<Record>(stream))
{
    Process(item);
}

69 - Use IAsyncEnumerable<T> for streaming data out of APIs

Returning IAsyncEnumerable<T> from a controller streams results to the client without buffering the full collection in memory.

70 - Use PipeReader / PipeWriter for high-throughput I/O

System.IO.Pipelines provides backpressure, pooled buffers, and zero-copy parsing. Use for protocol parsers, file processing, and socket servers.

71 - Avoid BinaryFormatter - it is removed

BinaryFormatter is removed in .NET 10. Use System.Text.Json, MessagePack, Protobuf, or MemoryPack.

72 - Buffer file I/O - use FileOptions.Asynchronous and appropriate buffer sizes

await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read,
    FileShare.Read, bufferSize: 4096, FileOptions.Asynchronous | FileOptions.SequentialScan);

73 - Use RecyclableMemoryStream from Microsoft.IO.RecyclableMemoryStream for pooled memory streams

Avoids LOH allocations from large MemoryStream buffers.


10. JIT, Runtime & GC

74 - πŸ†• Enjoy free speed from .NET 10's JIT improvements

The .NET 10 JIT delivers 15–30% raw perf gains on modern hardware through AVX-512/AVX10.2, ARM SVE, improved loop inversion, and better inlining. Upgrade to .NET 10 and recompile - no code changes needed.

75 - πŸ†• Escape analysis now stack-allocates delegates and small arrays

.NET 10 can stack-allocate delegates whose target doesn't escape, eliminating GC pressure from lambdas in tight loops. Write idiomatic code; the JIT optimizes.

76 - πŸ†• DATAS (Dynamic Adaptation To Application Sizes) is on by default

The GC automatically tunes heap sizing based on workload and container memory limits. For most apps, do not manually tune GC settings unless profiling proves otherwise.

77 - Use Server GC for high-throughput services, Workstation GC for client apps

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

78 - Set GCHeapHardLimit in container deployments

Prevent the GC from consuming the full container memory. Set via runtimeconfig.json:

{
  "runtimeOptions": {
    "configProperties": {
      "System.GC.HeapHardLimit": 209715200
    }
  }
}

79 - Use GC.Collect() only in very specific scenarios

Calling GC.Collect() in production disrupts the GC's adaptive heuristics. Acceptable only in known memory-spike scenarios with careful measurement.

80 - Use NativeAOT for startup-critical, low-memory services

Ahead-of-time compilation eliminates JIT warmup and reduces working set. .NET 10 further reduces NativeAOT binary sizes and improves compile-time optimization.

81 - Use [SkipLocalsInit] on perf-critical methods

Skips zero-initialization of locals when you know you'll write before reading:

[SkipLocalsInit]
static void ProcessBuffer(Span<byte> data) { /* ... */ }

82 - Seal your classes

sealed enables the JIT to devirtualize method calls, enabling inlining. Mark every class sealed unless it's explicitly designed for inheritance.


11. EF Core & Data Access

83 - Use AsNoTracking() for read-only queries

Disabling change tracking eliminates the overhead of identity resolution and snapshot creation.

var products = await db.Products.AsNoTracking().ToListAsync();

84 - Use split queries for multi-collection includes

var orders = await db.Orders
    .Include(o => o.Items)
    .AsSplitQuery()
    .ToListAsync();

Prevents cartesian explosion from multiple Include joins.

85 - Use ExecuteUpdateAsync / ExecuteDeleteAsync for bulk operations

Avoid loading entities just to update/delete them:

await db.Products
    .Where(p => p.IsDiscontinued)
    .ExecuteDeleteAsync();

86 - Use compiled queries for repeated hot-path queries

private static readonly Func<AppDbContext, int, Task<Product?>> GetById =
    EF.CompileAsyncQuery((AppDbContext db, int id) =>
        db.Products.FirstOrDefault(p => p.Id == id));

87 - πŸ†• Use named query filters in EF Core 10

Named filters (HasQueryFilter("name", ...)) let you selectively ignore specific filters per query, avoiding the global filter all-or-nothing problem.

88 - Project only what you need with .Select()

Never fetch full entities when you need three columns. Projection reduces I/O, memory, and deserialization cost.

89 - Use connection pooling and keep connections short-lived

Open connections late, close early. Let the pool manage reuse. Configure MaxPoolSize to match your concurrency model.

90 - Avoid N+1 queries - use .Include() or explicit joins

Profile with EF Core logging or interceptors. N+1 is the single most common data-access performance killer.


12. ASP.NET Core & HTTP

91 - Use response compression middleware

builder.Services.AddResponseCompression(opts =>
    opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]));

92 - Use output caching for GET endpoints with stable responses

app.MapGet("/products", GetProducts).CacheOutput(p => p.Expire(TimeSpan.FromMinutes(5)));

93 - Use IHttpClientFactory - never new HttpClient()

Direct instantiation causes socket exhaustion. The factory manages handler lifetimes and DNS rotation.

94 - Use rate limiting middleware (.NET 7+)

builder.Services.AddRateLimiter(opts =>
    opts.AddFixedWindowLimiter("api", o => { o.Window = TimeSpan.FromSeconds(10); o.PermitLimit = 100; }));

95 - Return Results.Ok(data) in Minimal APIs instead of POCO

Explicit IResult return avoids runtime content negotiation overhead.

96 - Use static route handlers in Minimal APIs to avoid closure captures

app.MapGet("/health", static () => Results.Ok("healthy"));

13. General C# Idioms

97 - Use readonly struct for small, immutable value types

Prevents defensive copies when accessed through in parameters or readonly fields.

public readonly struct Point(double X, double Y);

98 - Use record struct for lightweight data carriers that need value equality

record struct auto-generates efficient Equals, GetHashCode without boxing.

99 - πŸ†• Use the field keyword to eliminate backing-field boilerplate (C# 14)

public string Name
{
    get;
    set => field = value ?? throw new ArgumentNullException(nameof(value));
}

100 - Use [InlineArray(N)] for fixed-size stack buffers without unsafe

[InlineArray(8)]
public struct EightInts
{
    private int _element0;
}

Gives you a fixed-size, stack-allocated, span-compatible buffer.


14. Benchmarking & Profiling

You cannot optimize what you do not measure.

Tool Purpose
BenchmarkDotNet Micro-benchmarks with statistical rigor. Use [MemoryDiagnoser].
dotnet-counters Real-time runtime metrics (GC, thread pool, exception rate).
dotnet-trace Collect EventPipe traces, open in PerfView / Speedscope.
dotnet-dump Capture and analyze heap dumps for memory leaks.
Visual Studio 2026 Profiler Agents πŸ†• AI-powered profiler that generates performance recommendations.
JetBrains dotMemory / dotTrace Commercial but powerful memory and CPU profiling.

Workflow

  1. Identify - use dotnet-counters / APM to find the hot service or endpoint.
  2. Trace - collect a dotnet-trace or VS profiler session under realistic load.
  3. Micro-bench - isolate the hot method and benchmark candidate fixes with BenchmarkDotNet.
  4. Ship & verify - deploy the fix and confirm improvement in production metrics.

Quick-Reference Cheat Sheet

Anti-Pattern Fix
.Result / .Wait() await with async all the way
new HttpClient() in a loop IHttpClientFactory
string += in a loop StringBuilder or string.Create
list.Count() > 0 (LINQ) list.Count > 0 (property) or .Any()
Dictionary for static config FrozenDictionary
Large byte[] allocations ArrayPool<byte>.Shared
new List<T>() with known size new List<T>(capacity)
Regex in a loop [GeneratedRegex] static field
EF: loading full entity for 2 fields .Select(x => new { x.A, x.B })
UnSealed classes everywhere sealed class by default

Further Reading


Maintained by the engineering team. PRs welcome - keep entries concise and evidence-backed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment