Search
C# async and await Tutorial: Task, Task.WhenAll, Exceptions, Cancellation and Progress
Every time your C# program calls a web API, reads a file or queries a database, it has to wait. If it waits the wrong way, a desktop app freezes, a web server runs out of threads, and a job that should take one second takes five. The async and await keywords are how modern C# waits the right way: the code still reads from top to bottom like normal code, but the thread is free to do other work while the slow operation finishes.
In this C# async/await tutorial you will write your first async method, run several tasks at the same time with Task.WhenAll, handle exceptions, cancel slow work with a CancellationToken, report progress, and limit how many tasks run at once. Every example below was compiled and run on .NET 10, and the output shown is the real output. The complete project is available as a download at the end.
What async and await Actually Do
An async method returns a Task (no result) or a Task<T> (a result of type T). A task is a promise that the work will finish later. When the method reaches await on a task that is not finished yet, it does not block. It returns to its caller, and the rest of the method is scheduled to continue when the awaited task completes.
The key idea: async is about waiting efficiently, not about running faster. A single await takes just as long as a blocking call. The win comes from two things: the thread is not stuck while waiting (so the UI stays responsive and servers handle more requests), and you can start several operations and wait for all of them together.
To keep the examples easy to run, a small helper uses Task.Delay to stand in for a slow network or database call:
- // Simulates a slow I/O call such as a web request or a database query.
- static async Task<string> GetDataAsync(string name, int delayMs)
- {
- await Task.Delay(delayMs); // the thread is free while we wait
- return $"{name} ({delayMs} ms)";
- }
Setting Up the Project
You need the .NET SDK (version 6 or newer works for every example; the output below came from .NET 10). Create a console app:
dotnet new console -n AsyncDemo cd AsyncDemo
Thanks to top-level statements, Program.cs can use await directly without writing static async Task Main. The demo program picks an example from the command line:
- using System.Diagnostics;
- // Run one example: dotnet run -- 1 (1 to 7)
- // Run all examples: dotnet run
- string pick = args.Length > 0 ? args[0] : "all";
- {
- ["1"] = Examples.FirstAsyncMethod,
- ["2"] = Examples.SequentialVsWhenAll,
- ["3"] = Examples.HandleErrors,
- ["4"] = Examples.WhenAllErrors,
- ["5"] = Examples.CancelWithTimeout,
- ["6"] = Examples.ProgressReport,
- ["7"] = Examples.LimitConcurrency,
- };
- foreach (var (key, run) in examples)
- {
- if (pick != "all" && pick != key) continue;
- Console.WriteLine($"=== Example {key} ===");
- await run();
- Console.WriteLine();
- }
Run a single example with dotnet run -- 2, or all of them with dotnet run.
Example 1: Your First async Method
- // 1. Your first async method
- public static async Task FirstAsyncMethod()
- {
- Console.WriteLine("Before await");
- string result = await GetDataAsync("Weather report", 500);
- Console.WriteLine($"Got: {result}");
- Console.WriteLine("After await");
- }
Output:
Before await Got: Weather report (500 ms) After await
Three rules to remember from this small example:
- Mark the method
asyncand returnTaskorTask<T>. - By convention the name ends with
Async. awaitunwraps the result:GetDataAsyncreturnsTask<string>, butawaitgives you thestring.
Example 2: Sequential await vs Task.WhenAll
This is the example that shows why async matters. Here we load three pieces of data that do not depend on each other, first one after another, then all at once:
- // 2. Run tasks one after another vs. at the same time
- public static async Task SequentialVsWhenAll()
- {
- var sw = Stopwatch.StartNew();
- string a = await GetDataAsync("Students", 1000);
- string b = await GetDataAsync("Grades", 800);
- string c = await GetDataAsync("Subjects", 500);
- Console.WriteLine($"Sequential: {a}, {b}, {c} -> {Secs(sw)}");
- sw.Restart();
- Task<string> t1 = GetDataAsync("Students", 1000); // start all three
- Task<string> t2 = GetDataAsync("Grades", 800);
- Task<string> t3 = GetDataAsync("Subjects", 500);
- string[] all = await Task.WhenAll(t1, t2, t3); // wait for all
- Console.WriteLine($"WhenAll: {string.Join(", ", all)} -> {Secs(sw)}");
- }
Output:
Sequential: Students (1000 ms), Grades (800 ms), Subjects (500 ms) -> 2.3 s WhenAll: Students (1000 ms), Grades (800 ms), Subjects (500 ms) -> 1.0 s
The sequential version takes the sum of the delays (1.0 + 0.8 + 0.5 = 2.3 seconds). The WhenAll version starts all three tasks first, then waits once, so it only takes as long as the slowest task (1.0 second). Task.WhenAll also returns the results in the same order as the tasks you passed in, not in the order they finished.
Use this pattern whenever operations are independent, for example loading a dashboard that needs a user profile, notifications and recent orders. If the second call needs the result of the first, you must await them one by one.
Example 3: Handling Exceptions with try/catch
When an async method throws, the exception is stored in the task and re-thrown at the await. That means ordinary try/catch works exactly as you would expect. This helper throws for some product codes:
- // 3. try/catch works with await just like normal code
- public static async Task HandleErrors()
- {
- {
- try
- {
- int qty = await GetStockAsync(sku);
- Console.WriteLine($"{sku}: {qty} in stock");
- }
- catch (KeyNotFoundException ex)
- {
- Console.WriteLine($"{sku}: error -> {ex.Message}");
- }
- }
- }
Output:
PEN-01: 42 in stock X-404: error -> Product X-404 not found
Example 4: Getting All Errors from Task.WhenAll
There is one surprise with WhenAll. If several tasks fail, await re-throws only the first exception. The full list is still available on the task's Exception property, which is an AggregateException. Keep a reference to the WhenAll task so you can inspect it:
- // 4. Errors from Task.WhenAll
- public static async Task WhenAllErrors()
- {
- Task<int>[] tasks = { GetStockAsync("PEN-01"), GetStockAsync("X-404"), GetStockAsync("X-500") };
- Task<int[]> whenAll = Task.WhenAll(tasks);
- try
- {
- await whenAll;
- }
- catch (Exception ex)
- {
- Console.WriteLine($"await rethrows only the first: {ex.GetType().Name}: {ex.Message}");
- foreach (var inner in whenAll.Exception!.InnerExceptions)
- Console.WriteLine($" all errors -> {inner.GetType().Name}: {inner.Message}");
- foreach (var t in tasks.Where(t => t.IsCompletedSuccessfully))
- Console.WriteLine($" this one still succeeded -> {t.Result}");
- }
- }
Output:
await rethrows only the first: KeyNotFoundException: Product X-404 not found all errors -> KeyNotFoundException: Product X-404 not found all errors -> InvalidOperationException: Inventory service is down this one still succeeded -> 42
Notice that one failing task does not cancel the others: PEN-01 still finished successfully, and you can still read its result. This is useful when you want to show partial data instead of an error page.
Example 5: Cancellation and Timeouts with CancellationToken
Long operations should be cancellable, for example when the user clicks Cancel or a request takes too long. .NET uses a CancellationTokenSource to send the signal and a CancellationToken to receive it. Pass the token down to every async call that accepts one:
- static async Task<string> GenerateReportAsync(CancellationToken token)
- {
- for (int page = 1; page <= 10; page++)
- {
- await Task.Delay(300, token); // throws if the token is cancelled
- Console.WriteLine($" page {page} done");
- }
- return "Report complete";
- }
- // 5. Cancellation and timeouts with CancellationToken
- public static async Task CancelWithTimeout()
- {
- try
- {
- string r = await GenerateReportAsync(cts.Token);
- Console.WriteLine(r);
- }
- catch (OperationCanceledException)
- {
- Console.WriteLine("Cancelled: the report took longer than 1 second");
- }
- }
Output:
page 1 done page 2 done page 3 done Cancelled: the report took longer than 1 second
new CancellationTokenSource(TimeSpan.FromSeconds(1)) cancels itself automatically after one second, which makes it a simple timeout. You can also call cts.Cancel() yourself, for example from a Cancel button. Most .NET async APIs, including HttpClient, File.ReadAllTextAsync and Entity Framework Core queries, accept a token. When the token is cancelled they throw OperationCanceledException (or its subclass TaskCanceledException), so catch the base type.
Example 6: Real Async File I/O with Progress Reporting
So far we have used Task.Delay. This example does real work: it writes four CSV files with File.WriteAllLinesAsync, then reads them back with File.ReadAllLinesAsync and reports progress through IProgress<T>:
- static async Task<int> ImportFilesAsync(string[] files, IProgress<int> progress)
- {
- int lines = 0;
- for (int i = 0; i < files.Length; i++)
- {
- string[] content = await File.ReadAllLinesAsync(files[i]);
- lines += content.Length;
- progress.Report((i + 1) * 100 / files.Length);
- }
- return lines;
- }
- // 6. Real async file I/O with progress reporting
- public static async Task ProgressReport()
- {
- string dir = Path.Combine(Path.GetTempPath(), "async-demo");
- Directory.CreateDirectory(dir);
- for (int i = 1; i <= 4; i++)
- {
- string path = Path.Combine(dir, $"section{i}.csv");
- var rows = Enumerable.Range(1, i * 25).Select(n => $"{i},{n},student{n}");
- await File.WriteAllLinesAsync(path, rows);
- files.Add(path);
- }
- int total = await ImportFilesAsync(files.ToArray(), progress);
- await Task.Delay(50); // give the last progress callback time to print
- Console.WriteLine($"Imported {total} lines from {files.Count} files");
- }
Output:
progress: 25% progress: 50% progress: 75% progress: 100% Imported 250 lines from 4 files
The four files have 25, 50, 75 and 100 lines, so the total is 250. Progress<T> runs its callback on the thread that created it when there is a UI context (WinForms, WPF, MAUI), so you can safely update a progress bar from it. In a console app the callback runs on the thread pool, which is why the example waits a moment before printing the total.
Example 7: Limit Concurrency with Parallel.ForEachAsync
Starting 1,000 tasks at once with WhenAll can overload an API or a database. Since .NET 6, Parallel.ForEachAsync lets you process a list with a maximum number of tasks running at the same time:
- // 7. Limit how many tasks run at once with Parallel.ForEachAsync
- public static async Task LimitConcurrency()
- {
- int running = 0, maxSeen = 0;
- var ids = Enumerable.Range(1, 10);
- var sw = Stopwatch.StartNew();
- async (id, token) =>
- {
- int now = Interlocked.Increment(ref running);
- lock (ids) maxSeen = Math.Max(maxSeen, now);
- await Task.Delay(400, token); // pretend to call an API for this id
- Interlocked.Decrement(ref running);
- });
- Console.WriteLine($"Processed 10 items, at most {maxSeen} at a time -> {Secs(sw)}");
- }
Output:
Processed 10 items, at most 3 at a time -> 1.6 s
Ten items that each take 0.4 seconds, three at a time, need four rounds (3 + 3 + 3 + 1), so about 1.6 seconds. Without the limit, all ten would hit the server at once; fully sequential, it would take 4 seconds.
Common async/await Mistakes
- Using
async void. Anasync voidmethod cannot be awaited, and an exception inside it can crash the process. Always returnTask. The only exception is event handlers such as a button'sClickhandler, which must bevoid. - Blocking with
.Resultor.Wait(). These block the thread until the task finishes, which throws away the benefit of async and can deadlock in WinForms, WPF and older ASP.NET apps. Useawaitall the way up instead. - Forgetting
await. CallingSaveAsync()withoutawaitstarts the work but does not wait for it, and any exception is silently lost. The compiler warns you about this (warning CS4014), so do not ignore that warning. - Awaiting inside a loop when the calls are independent.
foreach (...) await LoadAsync(id);runs one at a time. Collect the tasks and useTask.WhenAll, orParallel.ForEachAsyncif you need a limit. - Using
Task.Runto make I/O "async".Task.Runmoves CPU-heavy work to a background thread. For I/O, call the real async API (ReadAllTextAsync,GetStringAsync,ToListAsync) instead of wrapping the blocking version. - Not passing the
CancellationTokendown. A token only helps if each async call in the chain receives it.
Download the Example Project
The zip file below contains the AsyncDemo console project with all seven examples and the output file. Extract it, open a terminal in the AsyncDemo folder and run dotnet run. It works with Visual Studio, VS Code or Rider. The project targets net10.0; if you have an older SDK, change TargetFramework in AsyncDemo.csproj to your version, such as net8.0. Timings may differ by a few milliseconds on your computer.
Conclusion
You now know the core of asynchronous programming in C#: async methods return tasks, await waits without blocking, Task.WhenAll runs independent work at the same time, try/catch handles failures, a CancellationToken stops work that takes too long, IProgress<T> reports progress, and Parallel.ForEachAsync keeps concurrency under control. Try replacing Task.Delay in the demo with real calls using HttpClient.GetStringAsync, and you will have the building blocks of a fast, responsive .NET application.