.NET Performance Tips die Daadwerkelijk Verschil Maken

Praktische optimalisaties voor .NET applicaties die substantiële impact hebben op performance en resource gebruik.

Jean-Pierre Broeders

Freelance DevOps Engineer

22 februari 20266 min. leestijd

.NET Performance Tips die Daadwerkelijk Verschil Maken

Performance optimalisatie wordt vaak gezien als iets voor later. Tot de applicatie live gaat en productie servers beginnen te zweten onder realistische load. Een productie API die binnen 100ms moet antwoorden maar structureel 800ms doet, is een probleem dat niet met meer RAM opgelost wordt.

Span en Memory voor String Operaties

De meeste .NET applicaties verspillen geheugen aan substring operaties. Elke keer dat string.Substring() wordt aangeroepen, komt er een nieuwe string instance bij. Bij hoge throughput stapelt dat zich op.

// Traditioneel: nieuwe allocatie per substring
string input = "2026-02-22T06:30:00Z";
string date = input.Substring(0, 10);  // alloceert nieuw object

// Met Span<T>: zero-copy slicing
ReadOnlySpan<char> input = "2026-02-22T06:30:00Z";
ReadOnlySpan<char> date = input.Slice(0, 10);  // geen allocatie

Het verschil lijkt marginaal, maar in een API die 1000 requests per seconde verwerkt wordt dit snel merkbaar. Span werkt direct op de onderliggende memory zonder nieuwe objecten te creëren.

Voor parsing scenarios presteert dit bijzonder goed:

public static bool TryParseCustomFormat(ReadOnlySpan<char> input, out DateTime result)
{
    if (input.Length != 19) 
    {
        result = default;
        return false;
    }
    
    int year = int.Parse(input.Slice(0, 4));
    int month = int.Parse(input.Slice(5, 2));
    int day = int.Parse(input.Slice(8, 2));
    
    result = new DateTime(year, month, day);
    return true;
}

ValueTask voor Hot Paths

Async/await is standaard in moderne .NET applicaties, maar creëert onnodige allocaties wanneer operaties synchroon kunnen voltooien. Een typisch scenario: cache lookup die meestal een hit oplevert.

// Task<T> alloceert altijd
public async Task<User> GetUserAsync(int id)
{
    if (_cache.TryGetValue(id, out var user))
        return user;  // synchrone return, maar Task wordt toch gealloceerd
    
    return await _database.GetUserAsync(id);
}

// ValueTask<T> voorkomt allocatie bij cache hit
public async ValueTask<User> GetUserAsync(int id)
{
    if (_cache.TryGetValue(id, out var user))
        return user;  // geen heap allocatie
    
    return await _database.GetUserAsync(id);
}

Een applicatie met 70% cache hit rate bespaart hiermee duizenden allocaties per seconde. Garbage collector heeft minder werk, latency daalt.

StringBuilder Capacity

StringBuilder wordt gebruikt om string concatenatie efficiënt te houden, maar wordt vaak verkeerd geïnitialiseerd.

// Groeit incrementeel, meerdere allocaties
var builder = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
    builder.Append($"Item {i}\n");
}

// Pre-allocate met geschatte capacity
var builder = new StringBuilder(capacity: 15000);  // ~15 chars per item
for (int i = 0; i < 1000; i++)
{
    builder.Append($"Item {i}\n");
}

Wanneer capacity niet wordt opgegeven, start StringBuilder op 16 characters en verdubbelt bij elke overschrijding. Dat betekent meerdere array copies. Bij known workloads scheelt pre-allocatie aanzienlijk.

Array Pooling voor Buffers

Tijdelijke buffers worden vaak gealloceerd en direct weer weggegooid. ArrayPool hergebruikt arrays zonder garbage collector overhead.

// Standaard: nieuwe allocatie per request
byte[] ProcessRequest(Stream input)
{
    byte[] buffer = new byte[4096];
    // ... verwerk data
    return result;
}  // buffer wordt GC'd

// Met ArrayPool: hergebruik
byte[] ProcessRequest(Stream input)
{
    byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
    try
    {
        // ... verwerk data
        return result;
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buffer);
    }
}

Dit patroon is vooral effectief in high-throughput services waar tijdelijke buffers continu nodig zijn. De pool beheert automatisch beschikbare arrays en hergebruikt ze waar mogelijk.

LINQ Materialiseert Collections

LINQ is expressief en leesbaar, maar materialiseert intermediaire resultaten wanneer niet opgelet wordt.

// Materialiseert 3x
var result = items
    .Where(x => x.Active)      // IEnumerable
    .ToList()                   // List - eerste materialisatie
    .OrderBy(x => x.Priority)   // IOrderedEnumerable
    .ToList()                   // List - tweede materialisatie
    .Take(10)                   // IEnumerable
    .ToList();                  // List - derde materialisatie

// Materialiseert 1x aan het einde
var result = items
    .Where(x => x.Active)
    .OrderBy(x => x.Priority)
    .Take(10)
    .ToList();

Elke .ToList() call creëert een nieuwe collection en kopieert alle elementen. Chain operaties eerst, materialiseer pas als nodig.

Voor grote datasets kan switch naar for-loops zelfs beter presteren:

// LINQ
var active = items.Where(x => x.Active && x.Priority > 5).ToList();

// Loop (sneller bij grote collections)
var active = new List<User>(capacity: items.Count / 2);
foreach (var item in items)
{
    if (item.Active && item.Priority > 5)
        active.Add(item);
}

Async Streams voor Grote Datasets

Wanneer grote datasets worden verwerkt, blokkeert traditioneel async vaak totdat alles geladen is. IAsyncEnumerable streamt resultaten terwijl ze beschikbaar komen.

// Laadt eerst alles, dan pas returnen
public async Task<List<User>> GetUsersAsync()
{
    var users = new List<User>();
    await foreach (var batch in _database.GetBatchesAsync())
    {
        users.AddRange(batch);
    }
    return users;  // caller moet wachten op complete lijst
}

// Stream resultaten direct
public async IAsyncEnumerable<User> GetUsersStreamAsync()
{
    await foreach (var batch in _database.GetBatchesAsync())
    {
        foreach (var user in batch)
        {
            yield return user;  // consumer kan direct verwerken
        }
    }
}

De consumer kan beginnen met verwerken zodra eerste resultaten binnenkomen, in plaats van te wachten op het complete resultaat.

Conclusie

Deze optimalisaties zijn geen premature optimization. Het zijn fundamentele patterns die vanaf dag één toegepast kunnen worden zonder code complexiteit te verhogen. Span voor string operaties, ValueTask voor hot paths, ArrayPool voor buffers - implementatie kost minuten, impact loopt in de duizenden requests per seconde.

Performance is geen feature die later toegevoegd wordt. Het wordt ingebouwd vanaf het begin, of het wordt een probleem in productie.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op