Async/Await in .NET: Fouten die Bijna Iedereen Maakt

De meest voorkomende async/await valkuilen in .NET applicaties, van deadlocks tot fire-and-forget bugs. Met concrete fixes.

Jean-Pierre Broeders

Freelance DevOps Engineer

16 maart 20267 min. leestijd

Async/Await in .NET: Fouten die Bijna Iedereen Maakt

Async/await ziet er simpel uit. Twee keywords, klaar. Maar onder de motorkap gebeurt er genoeg om hele teams weken debugging te bezorgen. Deadlocks die alleen in productie optreden. Exceptions die stilletjes verdwijnen. Threads die vastlopen zonder duidelijke reden.

Dit gaat over de fouten die er op het eerste gezicht prima uitzien — tot het misgaat.

.Result en .Wait(): De Deadlock Fabriek

Het begint meestal onschuldig. Ergens in een legacy codebase moet een synchrone methode een async call doen. De snelle oplossing: .Result of .Wait() erachter plakken.

// ❌ Deadlock waiting to happen
public string GetUserName(int userId)
{
    var user = _userService.GetUserAsync(userId).Result;
    return user.Name;
}

In een console applicatie werkt dit prima. In ASP.NET (vooral pre-.NET 6 met SynchronizationContext) loopt dit gegarandeerd vast. De synchrone thread wacht op het resultaat, maar het resultaat moet terug op diezelfde thread. Impasse.

De echte fix is async helemaal doortrekken in de call chain:

// ✅ Async all the way down
public async Task<string> GetUserNameAsync(int userId)
{
    var user = await _userService.GetUserAsync(userId);
    return user.Name;
}

Soms is dat een enorme refactor. In die gevallen werkt Task.Run als noodoplossing — maar noem het dan ook zo in de comments:

// Workaround: legacy sync method, refactor naar async staat op de backlog
public string GetUserName(int userId)
{
    return Task.Run(() => _userService.GetUserAsync(userId)).Result;
}

Fire-and-Forget: De Stille Moordenaar

Een pattern dat overal opduikt: een async methode aanroepen zonder het resultaat af te wachten. Logging, notificaties, analytics — "dat hoeft niet te blokkeren."

// ❌ Exception? Welke exception?
public void ProcessOrder(Order order)
{
    _orderRepo.SaveAsync(order);          // geen await
    _emailService.SendConfirmationAsync(order); // geen await
    _analytics.TrackAsync("order_placed");      // geen await
}

Drie problemen tegelijk. Exceptions uit die calls verdwijnen in het niets. De methode returned voordat de save klaar is. En als de applicatie shutdown terwijl die tasks nog lopen, worden ze gewoon afgebroken.

Voor achtergrondwerk dat echt niet hoeft te blokkeren, gebruik IHostedService of een message queue:

// ✅ Background work via channel
public class EmailBackgroundService : BackgroundService
{
    private readonly Channel<EmailRequest> _channel;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        await foreach (var request in _channel.Reader.ReadAllAsync(ct))
        {
            try
            {
                await _emailSender.SendAsync(request);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Email verzenden mislukt voor {To}", request.To);
            }
        }
    }
}

De Channel vangt pieken op, exceptions worden gelogd, en bij shutdown wordt het netjes afgehandeld.

Async Void: Nooit Doen (Behalve Event Handlers)

Async void methodes zijn technisch geldig C#. Maar ze zijn giftig. Een exception in een async void methode crasht het hele proces — geen try/catch die dat opvangt.

// ❌ Dit crasht de applicatie bij een fout
async void UpdateCache()
{
    var data = await _api.FetchDataAsync();
    _cache.Set("key", data);
}

// ✅ Altijd Task returnen
async Task UpdateCacheAsync()
{
    var data = await _api.FetchDataAsync();
    _cache.Set("key", data);
}

De enige uitzondering: event handlers in UI frameworks die een void signature vereisen. Overal anders is het een bug.

Te Veel Parallellisme met Task.WhenAll

Task.WhenAll is krachtig maar wordt regelmatig misbruikt. Honderden HTTP calls tegelijk afvuren klinkt efficiënt, maar de target API denkt daar anders over.

// ❌ 500 parallelle HTTP calls = rate limit of timeout
var tasks = userIds.Select(id => _httpClient.GetAsync($"/users/{id}"));
var responses = await Task.WhenAll(tasks);

Beter: werk in batches of gebruik een SemaphoreSlim als throttle:

// ✅ Max 10 tegelijk
var semaphore = new SemaphoreSlim(10);
var tasks = userIds.Select(async id =>
{
    await semaphore.WaitAsync();
    try
    {
        return await _httpClient.GetAsync($"/users/{id}");
    }
    finally
    {
        semaphore.Release();
    }
});

var responses = await Task.WhenAll(tasks);

In .NET 8+ biedt Parallel.ForEachAsync een schonere oplossing:

// ✅ .NET 8+: ingebouwde concurrency limiet
await Parallel.ForEachAsync(userIds,
    new ParallelOptions { MaxDegreeOfParallelism = 10 },
    async (id, ct) =>
    {
        var response = await _httpClient.GetAsync($"/users/{id}", ct);
        // verwerk response
    });

CancellationToken Negeren

Vrijwel elke async methode in .NET accepteert een CancellationToken. Toch wordt die parameter massaal genegeerd — of erger, niet doorgestuurd.

// ❌ Token wordt nergens doorgegeven
public async Task<Report> GenerateReportAsync(CancellationToken ct)
{
    var data = await _db.GetDataAsync();       // geen ct
    var result = await _processor.RunAsync(data); // geen ct
    return result;
}

Als een gebruiker navigeert weg van een pagina of een API request timeout, draait die hele pipeline gewoon door. Verspilde resources, onnodige database load.

// ✅ Token overal doorsturen
public async Task<Report> GenerateReportAsync(CancellationToken ct)
{
    var data = await _db.GetDataAsync(ct);
    var result = await _processor.RunAsync(data, ct);
    return result;
}

Kleine moeite, groot verschil. Vooral bij langlopende operaties of endpoints die vaak geannuleerd worden.

ConfigureAwait: Wanneer Wel, Wanneer Niet

In library code — dus NuGet packages of shared projecten — hoort ConfigureAwait(false) erbij. Dat voorkomt dat de continuation terug moet naar de originele SynchronizationContext.

// Library code
public async Task<Data> FetchDataAsync()
{
    var response = await _client.GetAsync(url).ConfigureAwait(false);
    var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
    return JsonSerializer.Deserialize<Data>(content);
}

In ASP.NET Core applicatie code (controllers, services) maakt het niet uit — daar is geen SynchronizationContext meer. Maar in Blazor, WPF, of WinForms is het verschil cruciaal.

ContextConfigureAwait(false) nodig?Reden
Library / NuGet packageJa, altijdOnbekend wie de consumer is
ASP.NET CoreNiet nodigGeen SynchronizationContext
Blazor / WPF / WinFormsAlleen als UI-thread niet nodig isContext switch kan deadlocken

ValueTask: Niet Zomaar Overal Gebruiken

ValueTask vermijdt een heap allocatie als het resultaat al beschikbaar is — bijvoorbeeld bij caching. Maar het heeft restricties die Task niet heeft: een ValueTask mag maar één keer geawaited worden.

// ✅ Goed: cache hit vermijdt allocatie
public ValueTask<User> GetUserAsync(int id)
{
    if (_cache.TryGetValue(id, out var user))
        return ValueTask.FromResult(user);

    return new ValueTask<User>(LoadUserFromDbAsync(id));
}

// ❌ Fout: twee keer awaiten
var task = GetUserAsync(1);
var user1 = await task;
var user2 = await task; // undefined behavior

Gebruik ValueTask voor hot paths waar het resultaat vaak al beschikbaar is. In alle andere gevallen is gewoon Task prima — en veiliger.

Samenvatting

Async code schrijven is makkelijk. Correcte async code schrijven vergt discipline. De compiler klaagt niet over gemiste CancellationTokens of fire-and-forget calls. Dat maakt het extra belangrijk om deze patronen te kennen en consequent toe te passen. Een goede Roslyn analyzer zoals AsyncFixer pikt veel van deze problemen automatisch op — dat scheelt code reviews.

Wil je op de hoogte blijven?

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

Neem Contact Op