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:

  1.     // Simulates a slow I/O call such as a web request or a database query.
  2.     static async Task<string> GetDataAsync(string name, int delayMs)
  3.     {
  4.         await Task.Delay(delayMs);           // the thread is free while we wait
  5.         return $"{name} ({delayMs} ms)";
  6.     }

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:

  1. using System.Diagnostics;
  2.  
  3. // Run one example:  dotnet run -- 1   (1 to 7)
  4. // Run all examples: dotnet run
  5. string pick = args.Length > 0 ? args[0] : "all";
  6. var examples = new Dictionary<string, Func<Task>>
  7. {
  8.     ["1"] = Examples.FirstAsyncMethod,
  9.     ["2"] = Examples.SequentialVsWhenAll,
  10.     ["3"] = Examples.HandleErrors,
  11.     ["4"] = Examples.WhenAllErrors,
  12.     ["5"] = Examples.CancelWithTimeout,
  13.     ["6"] = Examples.ProgressReport,
  14.     ["7"] = Examples.LimitConcurrency,
  15. };
  16. foreach (var (key, run) in examples)
  17. {
  18.     if (pick != "all" && pick != key) continue;
  19.     Console.WriteLine($"=== Example {key} ===");
  20.     await run();
  21.     Console.WriteLine();
  22. }

Run a single example with dotnet run -- 2, or all of them with dotnet run.

Example 1: Your First async Method

  1.     // 1. Your first async method
  2.     public static async Task FirstAsyncMethod()
  3.     {
  4.         Console.WriteLine("Before await");
  5.         string result = await GetDataAsync("Weather report", 500);
  6.         Console.WriteLine($"Got: {result}");
  7.         Console.WriteLine("After await");
  8.     }

Output:

Before await
Got: Weather report (500 ms)
After await

Three rules to remember from this small example:

  • Mark the method async and return Task or Task<T>.
  • By convention the name ends with Async.
  • await unwraps the result: GetDataAsync returns Task<string>, but await gives you the string.

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:

  1.     // 2. Run tasks one after another vs. at the same time
  2.     public static async Task SequentialVsWhenAll()
  3.     {
  4.         var sw = Stopwatch.StartNew();
  5.         string a = await GetDataAsync("Students", 1000);
  6.         string b = await GetDataAsync("Grades", 800);
  7.         string c = await GetDataAsync("Subjects", 500);
  8.         Console.WriteLine($"Sequential: {a}, {b}, {c} -> {Secs(sw)}");
  9.  
  10.         sw.Restart();
  11.         Task<string> t1 = GetDataAsync("Students", 1000);   // start all three
  12.         Task<string> t2 = GetDataAsync("Grades", 800);
  13.         Task<string> t3 = GetDataAsync("Subjects", 500);
  14.         string[] all = await Task.WhenAll(t1, t2, t3);       // wait for all
  15.         Console.WriteLine($"WhenAll:    {string.Join(", ", all)} -> {Secs(sw)}");
  16.     }

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:

  1.     static async Task<int> GetStockAsync(string sku)
  2.     {
  3.         await Task.Delay(200);
  4.         if (sku == "X-404") throw new KeyNotFoundException($"Product {sku} not found");
  5.         if (sku == "X-500") throw new InvalidOperationException("Inventory service is down");
  6.         return sku.Length * 7;
  7.     }
  1.     // 3. try/catch works with await just like normal code
  2.     public static async Task HandleErrors()
  3.     {
  4.         foreach (var sku in new[] { "PEN-01", "X-404" })
  5.         {
  6.             try
  7.             {
  8.                 int qty = await GetStockAsync(sku);
  9.                 Console.WriteLine($"{sku}: {qty} in stock");
  10.             }
  11.             catch (KeyNotFoundException ex)
  12.             {
  13.                 Console.WriteLine($"{sku}: error -> {ex.Message}");
  14.             }
  15.         }
  16.     }

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:

  1.     // 4. Errors from Task.WhenAll
  2.     public static async Task WhenAllErrors()
  3.     {
  4.         Task<int>[] tasks = { GetStockAsync("PEN-01"), GetStockAsync("X-404"), GetStockAsync("X-500") };
  5.         Task<int[]> whenAll = Task.WhenAll(tasks);
  6.         try
  7.         {
  8.             await whenAll;
  9.         }
  10.         catch (Exception ex)
  11.         {
  12.             Console.WriteLine($"await rethrows only the first: {ex.GetType().Name}: {ex.Message}");
  13.             foreach (var inner in whenAll.Exception!.InnerExceptions)
  14.                 Console.WriteLine($"  all errors -> {inner.GetType().Name}: {inner.Message}");
  15.             foreach (var t in tasks.Where(t => t.IsCompletedSuccessfully))
  16.                 Console.WriteLine($"  this one still succeeded -> {t.Result}");
  17.         }
  18.     }

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:

  1.     static async Task<string> GenerateReportAsync(CancellationToken token)
  2.     {
  3.         for (int page = 1; page <= 10; page++)
  4.         {
  5.             await Task.Delay(300, token);   // throws if the token is cancelled
  6.             Console.WriteLine($"  page {page} done");
  7.         }
  8.         return "Report complete";
  9.     }
  10.  
  11.     // 5. Cancellation and timeouts with CancellationToken
  12.     public static async Task CancelWithTimeout()
  13.     {
  14.         using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
  15.         try
  16.         {
  17.             string r = await GenerateReportAsync(cts.Token);
  18.             Console.WriteLine(r);
  19.         }
  20.         catch (OperationCanceledException)
  21.         {
  22.             Console.WriteLine("Cancelled: the report took longer than 1 second");
  23.         }
  24.     }

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>:

  1.     static async Task<int> ImportFilesAsync(string[] files, IProgress<int> progress)
  2.     {
  3.         int lines = 0;
  4.         for (int i = 0; i < files.Length; i++)
  5.         {
  6.             string[] content = await File.ReadAllLinesAsync(files[i]);
  7.             lines += content.Length;
  8.             progress.Report((i + 1) * 100 / files.Length);
  9.         }
  10.         return lines;
  11.     }
  12.  
  13.     // 6. Real async file I/O with progress reporting
  14.     public static async Task ProgressReport()
  15.     {
  16.         string dir = Path.Combine(Path.GetTempPath(), "async-demo");
  17.         Directory.CreateDirectory(dir);
  18.         var files = new List<string>();
  19.         for (int i = 1; i <= 4; i++)
  20.         {
  21.             string path = Path.Combine(dir, $"section{i}.csv");
  22.             var rows = Enumerable.Range(1, i * 25).Select(n => $"{i},{n},student{n}");
  23.             await File.WriteAllLinesAsync(path, rows);
  24.             files.Add(path);
  25.         }
  26.         var progress = new Progress<int>(p => Console.WriteLine($"  progress: {p}%"));
  27.         int total = await ImportFilesAsync(files.ToArray(), progress);
  28.         await Task.Delay(50); // give the last progress callback time to print
  29.         Console.WriteLine($"Imported {total} lines from {files.Count} files");
  30.     }

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:

  1.     // 7. Limit how many tasks run at once with Parallel.ForEachAsync
  2.     public static async Task LimitConcurrency()
  3.     {
  4.         int running = 0, maxSeen = 0;
  5.         var ids = Enumerable.Range(1, 10);
  6.         var sw = Stopwatch.StartNew();
  7.         await Parallel.ForEachAsync(ids, new ParallelOptions { MaxDegreeOfParallelism = 3 },
  8.             async (id, token) =>
  9.             {
  10.                 int now = Interlocked.Increment(ref running);
  11.                 lock (ids) maxSeen = Math.Max(maxSeen, now);
  12.                 await Task.Delay(400, token);   // pretend to call an API for this id
  13.                 Interlocked.Decrement(ref running);
  14.             });
  15.         Console.WriteLine($"Processed 10 items, at most {maxSeen} at a time -> {Secs(sw)}");
  16.     }

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. An async void method cannot be awaited, and an exception inside it can crash the process. Always return Task. The only exception is event handlers such as a button's Click handler, which must be void.
  • Blocking with .Result or .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. Use await all the way up instead.
  • Forgetting await. Calling SaveAsync() without await starts 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 use Task.WhenAll, or Parallel.ForEachAsync if you need a limit.
  • Using Task.Run to make I/O "async". Task.Run moves 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 CancellationToken down. 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.