Back to School Sale! πŸŽ‰
Dometrain Pro 20% off with code BTS2025! (Excl. Business and VS Pro)
00
Days
00
Hrs
00
Min
00
Sec
Get Pro 20% off!

What is Asynchronous Programming in C#?

10/4/2025

Async code lets your app handle more work without blocking threads. In C#, async/await is the way to write non-blocking I/O that still reads nicely. This post covers basics, the core APIs you'll use every day, and the mistakes to avoid, plus a few patterns that play nicely with ASP.NET Core.


The mental model (quick)

  • Tasks aren't threads. A Task is a promise of a result. For I/O, the OS wakes your task when data arrives, and no thread is parked waiting.
  • async/await is composition, not magic. await registers a continuation and returns control to the caller until the awaited work completes. Behind the scenes, a state machine is being generated to handle all of this for you.
  • I/O-bound vs CPU-bound. Async is for I/O (HTTP, DB, file, sockets). Use Task.Run only for short CPU work; you don't want to block a request thread.

Minimal API: pass the CancellationToken all the way down

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/weather/{city}", async Task<IResult> (string city, CancellationToken ct) =>
{
using var http = new HttpClient();
http.Timeout = TimeSpan.FromSeconds(5);

    var json = await http.GetStringAsync($"https://example.com/weather/{city}", ct);
    return Results.Text(json, "application/json");
});

app.Run();
  • ASP.NET Core supplies a CancellationToken that fires when the client disconnects or a timeout happens.
  • Take it as a parameter and forward it into every async call. Free capacity when the request is no longer needed.

Never block on async

These patterns are trouble in the server code:

// ❌ May cause thread pool starvation
var result = SomeAsync().Result;

// ❌ Queues extra work and blocks the request thread
SomeAsync().Wait();

Do this instead:

// βœ… All the way async
var result = await SomeAsync();

Even though ASP.NET Core doesn't have a classic UI synchronization context, blocking steals threads that the runtime could use for other requests.


Compose multiple calls with Task.WhenAll

using var http = new HttpClient();

Task<string> u1 = http.GetStringAsync("https://api.test/users/1");
Task<string> u2 = http.GetStringAsync("https://api.test/users/2");
Task<string> u3 = http.GetStringAsync("https://api.test/users/3");

var results = await Task.WhenAll(u1, u2, u3);
  • Fire requests first, then await them together.
  • Errors: WhenAll throws the first exception; you can inspect Task.Exception to see the aggregate if you need it.

Throttle work: SemaphoreSlim or Parallel.ForEachAsync

Hitting an API 1,000 times at once is impolite and often pointless. Cap concurrency.

var urls = Enumerable.Range(1, 1000).Select(i => $"https://api.test/data/{i}");
using var http = new HttpClient();
using var gate = new SemaphoreSlim(10);

var tasks = urls.Select(async url =>
{
    await gate.WaitAsync();
    try
    {
        return await http.GetStringAsync(url);
    }
    finally
    {
        gate.Release();
    }
});

var data = await Task.WhenAll(tasks);

Or use the built-in helper:

await Parallel.ForEachAsync(urls, new ParallelOptions { MaxDegreeOfParallelism = 10 }, async (url, ct) =>
{
    using var http = new HttpClient();
    await http.GetStringAsync(url, ct);
});

Timeouts without Thread.Sleep: WaitAsync

Add a timeout to any task:

var call = http.GetStringAsync("https://api.test/slow", ct);
var withTimeout = call.WaitAsync(TimeSpan.FromSeconds(2), ct);
var payload = await withTimeout; // throws TaskCanceledException after 2s

Task.WaitAsync (available on .NET 6+) composes timeouts cleanly, no timers to manage.


Cancellation patterns you'll reuse

Link tokens for per-call timeouts:

using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(3));
var json = await http.GetStringAsync(url, cts.Token);

Graceful background worker loop:

public sealed class CleanupWorker(IServiceScopeFactory scopes, ILogger<CleanupWorker> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
        while (await timer.WaitForNextTickAsync(ct))
        {
            using var scope = scopes.CreateScope();
            var svc = scope.ServiceProvider.GetRequiredService<ICleanupService>();
            await svc.RunAsync(ct);
        }
    }
}

Async streams: IAsyncEnumerable<T>

Stream results as they arrive to keep memory low and start sending data sooner.

app.MapGet("/ticks", (CancellationToken ct) => GetTicks(ct));

static async IAsyncEnumerable<string> GetTicks([EnumeratorCancellation] CancellationToken ct)
{
    for (var i = 0; i < 5; i++)
    {
        ct.ThrowIfCancellationRequested();
        await Task.Delay(500, ct);
        yield return DateTime.UtcNow.ToString("O");
    }
}

Consume with await foreach:

await foreach (var tick in GetTicks(ct))
{
    Console.WriteLine(tick);
}

ValueTask the right way

ValueTask can avoid an allocation when results are often already available. Don't return it by default; use it for hot paths where you've measured a win.

public interface ICache
{
    ValueTask<string?> TryGetAsync(string key, CancellationToken ct);
}

public sealed class MemoryFirstCache(IMemoryCache cache, HttpClient http) : ICache
{
    public ValueTask<string?> TryGetAsync(string key, CancellationToken ct)
    {
        if (cache.TryGetValue(key, out string? hit))
            return ValueTask.FromResult(hit); // no allocation

        return new ValueTask<string?>(FetchAndStoreAsync(key, ct));
    }

    private async Task<string?> FetchAndStoreAsync(string key, CancellationToken ct)
    {
        var value = await http.GetStringAsync($"https://api.test/data/{key}", ct);
        cache.Set(key, value, TimeSpan.FromMinutes(1));
        return value;
    }
}

Rules of thumb:

  • Don't store a ValueTask for later.
  • Don't await it multiple times.
  • If you don't need its benefits, stick to Task.

Library code and ConfigureAwait(false)

In application code (ASP.NET Core), you generally don't need ConfigureAwait(false). In libraries that may be used anywhere (including UI apps), add it to avoid capturing a synchronization context the caller might have:

public async Task<string> LoadAsync(string path, CancellationToken ct)
{
    using var fs = File.OpenRead(path);
    using var sr = new StreamReader(fs);
    return await sr.ReadToEndAsync(ct).ConfigureAwait(false);
}

Since .NET Core came out, we no longer have a synchronization context, so ConfigureAwait(false) became a lot less relevant.


Common mistakes

  • Forgetting to pass CancellationToken. Accept it in endpoints and hand it down.
  • Blocking on async with .Result/.Wait(). Keep the chain async.
  • Overusing Task.Run. It doesn't make I/O faster; it just burns a thread.
  • Ignoring backpressure. Throttle with SemaphoreSlim or Parallel.ForEachAsync.
  • Fire-and-forget without protection. If you must, capture exceptions and log them, or queue work to a background service.

Handy snippets

Timeout helper:

public static class TaskExt
{
    public static Task<T> WithTimeout<T>(this Task<T> task, TimeSpan timeout, CancellationToken ct = default) =>
        task.WaitAsync(timeout, ct);
}

Retry-and-cancel (without polly):

public static async Task<T> RetryAsync<T>(Func<CancellationToken, Task<T>> action, int maxAttempts, TimeSpan delay, CancellationToken ct)
{
    for (var attempt = 1; ; attempt++)
    {
        try 
        {
            return await action(ct);
        }
        catch when (attempt < maxAttempts)
        {
            await Task.Delay(delay, ct);
        }
    }
}

Wrap-up and next step

Async isn't about making everything run in parallel; it's about not wasting threads while the OS does I/O. Keep the chain async, pass cancellation, compose with WhenAll, and throttle when needed. If you want a structured path with many more patterns (channels, pipelines, async LINQ, deadlock traps, fine-grained cancellation), check out From Zero to Hero: Asynchronous Programming in C# on Dometrain.

About the Author

author_img

Nick Chapsas

Nick Chapsas is a .NET & C# content creator, educator and a Microsoft MVP for Developer Technologies with years of experience in Software Engineering and Engineering Management.

He has worked for some of the biggest companies in the world, building systems that served millions of users and tens of thousands of requests per second.

Nick creates free content on YouTube and is the host of the Keep Coding Podcast.

More courses by Nick Chapsas

What's New

Getting Started: Caching in .NET
course

Getting Started: Caching in .NET

Let's make the hardest thing in programming easy for .NET software engineers.

Learn More
From Zero to Hero: Testing with xUnit in C#
course

From Zero to Hero: Testing with xUnit in C#

Learn how to test any codebase in .NET with the latest version of xUnit, the industry-standard testing library.

Learn More
Create a ChatGPT Console AI Chatbot in C#
blog

Create a ChatGPT Console AI Chatbot in C#

This walkthrough is your hands-on entry point to create a basic C# console application that talks to ChatGPT using the OpenAI API.

Learn More