Tasks and Resilience
These helpers cover the async code you write around a call that can be slow or can fail: put a limit on the wait, fall back to a default, retry with backoff, race several replicas, or collect every result and every error from a batch. Reach for them when you would otherwise write the same try/catch, Task.WhenAny or retry loop by hand.
At a glance
| Method | What it does |
|---|---|
WithTimeout | Waits for a task, but throws TimeoutException if it takes too long. |
WithFallback | Returns a default value instead of failing when the task throws or is too slow. |
Forget | Lets a task run in the background and reports its failure to a callback. |
Timed | Awaits a task and also returns how long it took. |
FirstSuccessful | Returns the result of the first task that succeeds; skips failures. |
WhenAllSettled | Waits for every task and returns one outcome per task, without throwing. |
TaskOutcome<T> | How one task ended: IsSuccess, Value and Error. |
InCompletionOrder | Returns the tasks in the order they finish, so you can handle the fastest first. |
Then | Runs the next step once a task succeeds. Async steps are flattened. |
Catch | Recovers from one exception type. On Task<T>, the exception type comes first. |
Retry.ExecuteAsync | Runs an operation again after a failure, with exponential backoff, an optional filter and cancellation. |
Everything on this page is in the namespace System.Threading.Tasks, which SDK-style projects import by default, so you do not need a using. The extension methods are on the class AsyncExtensions; Retry and TaskOutcome<T> are classes of their own.
Which helper do I need?
| You want to... | Use |
|---|---|
| Stop waiting after 5 seconds and treat that as an error | WithTimeout |
| Stop waiting after 2 seconds and show a default instead | WithFallback |
| Try again when a call fails now and then | Retry.ExecuteAsync |
| Ask three mirrors and take the first good answer | FirstSuccessful |
| Run a batch where some items may fail, and report each one | WhenAllSettled |
| Show results as they arrive instead of waiting for the slowest | InCompletionOrder |
| Start work you do not await, without losing its exceptions | Forget |
Measure a call without a Stopwatch in your code | Timed |
| Write "load, then parse, else use a default" as one expression | Then and Catch |
Timeouts and fallbacks
WithTimeout
WithTimeout waits for the task, but throws TimeoutException if the task does not finish within the given time. Use it for calls that have no timeout of their own, or whose timeout you do not control. There are overloads for Task and Task<T>, each with an optional CancellationToken.
public async Task<PaymentResult> ChargeAsync(Invoice invoice, CancellationToken cancellationToken)
{
try
{
return await _gateway.ChargeAsync(invoice).WithTimeout(5.Seconds(), cancellationToken);
}
catch (TimeoutException)
{
// The charge may still go through; check its status later instead of charging twice.
return PaymentResult.Pending(invoice.Id);
}
}
int total = await Task.FromResult(42).WithTimeout(10.Seconds());
// → 42
Task<int> neverCompletes = new TaskCompletionSource<int>().Task;
await neverCompletes.WithTimeout(50.Milliseconds());
// throws TimeoutException
Task failing = Task.FromException(new InvalidOperationException("boom"));
await failing.WithTimeout(10.Seconds());
// throws InvalidOperationException "boom" (the task's own exception, unchanged)
Only the wait is abandoned. The task itself keeps running after the timeout, because a Task cannot be stopped from outside. If the operation accepts a CancellationToken, pass it a token as well, so the work stops too.
- A
nulltask throwsArgumentNullExceptionwithParamName"task". - If the task fails before the timeout, its own exception propagates unchanged.
- If the token is cancelled, before or during the wait, the result is an
OperationCanceledException. - The .NET 10 build calls
Task.WaitAsync. The netstandard2.1 build, which apps on .NET 6 to 9 use, races the task againstTask.Delay. 5.Seconds()and50.Milliseconds()come from Durations. AnyTimeSpanworks.
WithFallback
WithFallback returns a value you choose instead of failing when the task throws. Give it a timeout too, and a slow task also returns the fallback. Use it for data that is nice to have, such as a recommendation panel or a price estimate, so one slow service does not break the whole page.
There are three overloads on Task<T>:
| Overload | Falls back when |
|---|---|
WithFallback(fallback) | the task throws or is cancelled |
WithFallback(fallback, timeout) | the task throws, is cancelled, or takes longer than timeout |
WithFallback(fallback, timeout, onFallback) | the same, and passes the exception to onFallback |
public async Task<ProductPage> BuildPageAsync(string sku)
{
Product product = await _catalog.GetAsync(sku);
decimal discount = await _pricing.GetDiscountAsync(sku)
.WithFallback(0m, 2.Seconds(), ex => _logger.LogWarning(ex, "Discount unavailable for {Sku}", sku));
return new ProductPage(product, discount);
}
int stock = await Task.FromResult(5).WithFallback(-1);
// → 5
int failed = await Task.FromException<int>(new InvalidOperationException("boom")).WithFallback(-1);
// → -1
int cancelled = await Task.FromCanceled<int>(new CancellationToken(canceled: true)).WithFallback(-1);
// → -1
Exception? reason = null;
int slow = await new TaskCompletionSource<int>().Task
.WithFallback(-1, 50.Milliseconds(), ex => reason = ex);
// → -1, and reason is a TimeoutException
onFallbackreceives the task's exception, or aTimeoutExceptionwhen the timeout elapsed. It is not called when the task succeeds. It may benull.- Pass
Timeout.InfiniteTimeSpanfor "no timeout". A timeout of zero still returns the result of a task that has already completed. - A negative timeout (other than
Timeout.InfiniteTimeSpan) or one longer thanint.MaxValuemilliseconds (about 24.8 days) throwsArgumentOutOfRangeExceptionwithParamName"timeout"at the call. It is never turned into the fallback, andonFallbackis not called. - A
nulltask throwsArgumentNullExceptionwithParamName"task". - As with
WithTimeout, a slow task keeps running after the fallback is returned.
A fallback hides the failure from the user, not from you. Prefer the overload with onFallback and log there, so a service that is always down does not go unnoticed.
Background work and timing
Forget
Forget is for a task you do not want to await, such as sending a welcome e-mail or a telemetry event. Writing _ = SendAsync(); drops the exception without a trace. Forget(onError) passes it to your callback instead.
public async Task<User> RegisterAsync(SignUpRequest request)
{
User user = await _users.CreateAsync(request);
_mailer.SendWelcomeEmailAsync(user)
.Forget(ex => _logger.LogError(ex, "Welcome e-mail failed for user {UserId}", user.Id));
return user;
}
- The method does not start the task. The task is already running when you call
Forget;Forgetonly watches for its failure. onErrorreceives the base exception (AggregateException.GetBaseException()), not anAggregateException, unless the task holds several exceptions (for example fromTask.WhenAll).onErrorruns only if the task faults. A successful or cancelled task does not call it.- It works on any
Task, so also on aTask<T>. The result is discarded. - A
nulltask oronErrorthrowsArgumentNullExceptionwithParamName"task"or"onError".
Keep onError from throwing. An exception thrown inside it is not reported anywhere. Also keep in mind that forgotten work can be cut off when the process shuts down. For work that must finish, use a queue or a hosted service.
Timed
Timed awaits a task and also returns how long it took. On a Task<T> it returns the result and the elapsed time as a tuple. On a plain Task it returns only the elapsed TimeSpan.
(Report report, TimeSpan elapsed) = await _reports.BuildMonthlyAsync(month).Timed();
if (elapsed > 10.Seconds())
{
_logger.LogWarning("Monthly report for {Month} took {Elapsed}", month, elapsed);
}
TimeSpan importTime = await _importer.ImportInvoicesAsync(file).Timed();
- The clock starts when
Timed()is called, not when the task was created. Call it on the same line that starts the operation, as above, so the time covers the whole operation. - On an already completed task the elapsed time is close to zero, never negative.
- If the task fails, its exception propagates unchanged and no time is returned.
- A
nulltask throwsArgumentNullExceptionwithParamName"task".
Working with many tasks
FirstSuccessful
FirstSuccessful takes several tasks that compute the same thing and returns the result of the first one that succeeds. Failed and cancelled tasks are skipped. This is the "hedged request" pattern: ask several mirrors or replicas and take the fastest good answer.
string[] mirrors =
{
"https://eu.downloads.example.com/setup.zip",
"https://us.downloads.example.com/setup.zip",
"https://ap.downloads.example.com/setup.zip",
};
byte[] installer = await mirrors.Select(url => _http.GetByteArrayAsync(url)).FirstSuccessful();
Task<int>[] replicas =
{
Task.FromException<int>(new InvalidOperationException("replica down")),
Task.FromResult(9),
};
int count = await replicas.FirstSuccessful();
// → 9
- If every task fails, it throws an
AggregateExceptionwhose message starts with"Every task failed."(AggregateExceptionappends each inner message in parentheses). ItsInnerExceptionsholds one exception per task, in the order they failed. A cancelled task appears as aTaskCanceledException. - The other tasks keep running after the winner returns. Cancel them yourself if they are expensive, as in the next example.
- The sequence is enumerated once, at the call. A lazy
Selectstarts all of its tasks at that moment. - An empty sequence, or one that contains a
nulltask, throwsArgumentExceptionwithParamName"tasks"at the call. Anullsequence throwsArgumentNullException.
using CancellationTokenSource hedge = new CancellationTokenSource();
Quote quote = await _quoteProviders
.Select(provider => provider.GetQuoteAsync(order, hedge.Token))
.FirstSuccessful();
hedge.Cancel();
WhenAllSettled
WhenAllSettled waits for every task and returns how each one ended, without throwing. It is like JavaScript's Promise.allSettled. Use it for a batch where some failures are acceptable, such as uploading a folder of files, and you want to report each failure rather than stop at the first one.
Awaiting Task.WhenAll throws if any task failed, and gives you only the first exception. WhenAllSettled gives you one TaskOutcome<T> per task, in the same order as the input.
IReadOnlyList<TaskOutcome<string>> outcomes = await files
.Select(file => _storage.UploadAsync(file))
.WhenAllSettled();
for (int i = 0; i < outcomes.Count; i++)
{
if (!outcomes[i].IsSuccess)
{
_logger.LogWarning(outcomes[i].Error, "Upload of {FileName} failed", files[i].Name);
}
}
int uploaded = outcomes.Count(outcome => outcome.IsSuccess);
List<Task<int>> tasks = new List<Task<int>>
{
Task.FromResult(1),
Task.FromException<int>(new InvalidOperationException("boom")),
Task.FromCanceled<int>(new CancellationToken(canceled: true)),
Task.FromResult(4),
};
IReadOnlyList<TaskOutcome<int>> outcomes = await tasks.WhenAllSettled();
int count = outcomes.Count; // → 4
int first = outcomes[0].Value; // → 1
bool secondSucceeded = outcomes[1].IsSuccess; // → false
int secondValue = outcomes[1].Value; // → 0
string? secondError = outcomes[1].Error?.Message; // → "boom"
bool thirdCancelled = outcomes[2].Error is OperationCanceledException; // → true
int fourth = outcomes[3].Value; // → 4
- It never throws because a task failed, even when every task failed.
- It waits for all tasks, including ones that fail late.
- An empty sequence returns an empty list.
- A sequence that contains a
nulltask throwsArgumentExceptionwithParamName"tasks"at the call. Anullsequence throwsArgumentNullException. - The sequence is enumerated once.
TaskOutcome<T>
TaskOutcome<T> is the result type of WhenAllSettled. You do not create it yourself; it has no public constructor.
| Member | Type | Meaning |
|---|---|---|
IsSuccess | bool | true when the task completed successfully. |
Value | T? | The result. default(T) when the task failed. |
Error | Exception? | The exception for a failed or cancelled task. null on success. |
For a cancelled task, Error is an OperationCanceledException. It is often the TaskCanceledException subclass, but not always: an async method that calls token.ThrowIfCancellationRequested() reports the base type. Test with Error is OperationCanceledException, not is TaskCanceledException. For a faulted task, Error is its first exception, not an AggregateException.
InCompletionOrder
InCompletionOrder returns the same number of tasks as you passed in, but ordered by when they finish. The first returned task completes with the first input that finishes, the second with the second, and so on. Use it to process results as they arrive instead of waiting for the slowest one.
IEnumerable<Task<SearchResults>> searches = _providers.Select(provider => provider.SearchAsync(query));
foreach (Task<SearchResults> next in searches.InCompletionOrder())
{
SearchResults results = await next; // the fastest provider not handled yet
await _hub.PublishAsync(results);
}
TaskCompletionSource<string>[] sources =
{
new TaskCompletionSource<string>(),
new TaskCompletionSource<string>(),
new TaskCompletionSource<string>(),
};
Task<string>[] ordered = sources.Select(source => source.Task).InCompletionOrder().ToArray();
sources[2].SetResult("c");
sources[0].SetResult("a");
sources[1].SetResult("b");
string[] results = await Task.WhenAll(ordered);
// → ["c", "a", "b"]
- Each returned task completes like the input that finished in that position: with its result, its exception (unwrapped, not inside an
AggregateException) or its cancellation. Soawait nextcan throw; wrap it intry/catchif one failure should not stop the loop. - The input sequence is enumerated once, when the method is called. The returned sequence is an array, so enumerating it again does not start new work.
- Tasks that are already complete take the first positions.
- An empty sequence returns an empty sequence. A
nulltask throwsArgumentExceptionwithParamName"tasks"; anullsequence throwsArgumentNullException.
Promise-style chaining
Then and Catch let you write a pipeline of async steps as one expression that reads from top to bottom, like promises in JavaScript, instead of nesting try blocks:
Config config = await _store.LoadAsync(path)
.Then(json => ConfigParser.Parse(json))
.Catch<ParseException, Config>(ex => Config.Default);
A faulted or cancelled task skips every Then and goes to the first matching Catch. The original exception propagates unchanged; it is never wrapped in an AggregateException. A null task, next or handler throws ArgumentNullException at the call, not when the result is awaited.
Then
Then runs the next step only if the task succeeds, and passes it the result. If the next step is itself async, Then flattens it: you get a Task<TResult>, not a Task<Task<TResult>>.
| Receiver | next | Returns |
|---|---|---|
Task<T> | Func<T, TResult> | Task<TResult> |
Task<T> | Func<T, Task<TResult>> | Task<TResult> (flattened) |
Task<T> | Func<T, Task> | Task |
Task<T> | Action<T> | Task |
Task | Func<TResult> | Task<TResult> |
Task | Func<Task<TResult>> | Task<TResult> (flattened) |
Task | Func<Task> | Task |
Task | Action | Task |
int doubled = await Task.FromResult(21).Then(value => value * 2);
// → 42
Order[] orders = await _users.GetAsync(userId)
.Then(user => _orders.GetForCustomerAsync(user.CustomerId));
int imported = await _importer.ImportInvoicesAsync(file).Then(() => _importer.Count);
await _importer.ImportInvoicesAsync(file).Then(() => _search.ReindexAsync());
- If the task fails or is cancelled,
nextdoes not run and the task's exception or cancellation propagates. - If
nextthrows, the returned task faults with that exception.
An async lambda that returns nothing, such as async order => { await _audit.WriteAsync(order); }, binds to the Func<T, Task> overload, not to Action<T>. The returned Task completes only after the lambda's own await has finished, so failures inside it are not lost.
Catch
Catch recovers from a failure of one exception type, or a type derived from it. Other exceptions propagate unchanged.
There are two forms:
- On a
Task<T>:Catch<TException, T>(Func<TException, T> handler). The handler returns a replacement result. You write the exception type first and the result type second, because C# cannot infer just one of two type arguments. - On a plain
Task:Catch<TException>(Action<TException> handler). The handler runs and the task completes normally.
int pending = await _orders.CountPendingAsync().Catch<TimeoutException, int>(ex => -1);
await _webhooks.SendAsync(payload)
.Catch<HttpRequestException>(ex => _logger.LogWarning(ex, "Webhook delivery failed"));
int parsed = await Task.FromResult("4")
.Then(text => int.Parse(text, CultureInfo.InvariantCulture))
.Catch<FormatException, int>(ex => 0);
// → 4
int fallback = await Task.FromResult("x")
.Then(text => int.Parse(text, CultureInfo.InvariantCulture))
.Catch<FormatException, int>(ex => 0);
// → 0
- Derived types match:
Catch<IOException, T>also handles aFileNotFoundException. - Cancellation counts as a failure only when
TExceptionisOperationCanceledException, its subclassTaskCanceledException(only when the cancelled task reports that subclass; see the note underTaskOutcome<T>), or one of its base types (SystemException,Exception).Catch<TimeoutException, int>lets a cancellation through. - If the task succeeds, the handler does not run and the result passes through.
On a Task<T>, writing only one type argument, as in countTask.Catch<TimeoutException>(...), compiles. It binds to the plain-Task form, because a Task<T> is also a Task, and returns a Task without the result. Write both type arguments when you need the value.
Retries
Retry.ExecuteAsync
Retry.ExecuteAsync runs an async operation, and if it fails, waits and runs it again, until it succeeds or the attempts run out. The wait doubles after each failure (exponential backoff). Use it for calls that fail now and then for reasons that pass, such as a busy database or a network blip.
Retry is a static class, not an extension method, because C# cannot call a method on a lambda.
There are four overloads:
| Overload | Use when |
|---|---|
ExecuteAsync<T>(Func<Task<T>> operation, int maxAttempts, TimeSpan initialDelay) | the operation returns a result |
ExecuteAsync(Func<Task> operation, int maxAttempts, TimeSpan initialDelay) | the operation returns no result |
ExecuteAsync<T>(Func<CancellationToken, Task<T>> operation, int maxAttempts, TimeSpan initialDelay, Func<Exception, bool>? shouldRetry, CancellationToken cancellationToken) | you need a filter or cancellation |
ExecuteAsync(Func<CancellationToken, Task> operation, int maxAttempts, TimeSpan initialDelay, Func<Exception, bool>? shouldRetry, CancellationToken cancellationToken) | the same, without a result |
Order order = await Retry.ExecuteAsync(
() => _api.GetOrderAsync(orderId),
maxAttempts: 3,
initialDelay: 200.Milliseconds());
int calls = 0;
Func<Task<int>> flaky = () =>
{
calls++;
return calls < 3
? Task.FromException<int>(new InvalidOperationException($"attempt {calls}"))
: Task.FromResult(calls);
};
int result = await Retry.ExecuteAsync(flaky, maxAttempts: 3, initialDelay: 1.Milliseconds());
// → 3 (and calls is 3)
Backoff
maxAttempts is the total number of attempts, including the first. initialDelay is the wait after the first failure; each later wait is twice the one before. There is no wait after the last failure: that failure is rethrown unchanged, not wrapped.
For maxAttempts: 4 and initialDelay: 200.Milliseconds(), when every attempt fails:
attempt 1 fails → wait 200 ms
attempt 2 fails → wait 400 ms
attempt 3 fails → wait 800 ms
attempt 4 fails → its exception is thrown to the caller
The total waiting time is 1.4 seconds. With initialDelay: TimeSpan.Zero the attempts run back to back. The doubling stops at int.MaxValue - 1 milliseconds (about 24.8 days): no wait after the first is longer than that, however many attempts you allow. The first wait is initialDelay exactly as you passed it.
int calls = 0;
Func<Task<int>> alwaysFails = () =>
{
calls++;
return Task.FromException<int>(new InvalidOperationException($"attempt {calls}"));
};
await Retry.ExecuteAsync(alwaysFails, maxAttempts: 4, initialDelay: TimeSpan.Zero);
// throws InvalidOperationException "attempt 4" (and calls is 4)
Choosing what to retry with shouldRetry
By default every exception is retried. Some failures will not pass on their own, such as a validation error or a 404, and retrying them only wastes time. Pass shouldRetry to decide per exception. When it returns false, that exception is thrown at once, with no further attempts. Passing null retries every exception.
Invoice invoice = await Retry.ExecuteAsync(
token => _billing.GetInvoiceAsync(invoiceId, token),
maxAttempts: 5,
initialDelay: 500.Milliseconds(),
shouldRetry: exception => exception is HttpRequestException or TimeoutException,
cancellationToken: cancellationToken);
If the first attempt throws a TimeoutException and the second an InvalidOperationException, the filter above stops after the second attempt and the caller sees the InvalidOperationException.
shouldRetryis not called for the last allowed attempt, nor after the token is cancelled. The exception is thrown in both cases.- If
shouldRetryitself throws, that counts as "do not retry" and the original failure propagates. This is how C# exception filters work.
Cancellation
The overloads with a CancellationToken pass the token to every attempt and also use it for the waits between attempts.
- Cancelling during a wait stops at once with an
OperationCanceledException. No further attempt runs. - If the token is cancelled while an attempt is running and that attempt then fails, there is no retry. The attempt's own exception propagates, whatever its type.
- The first attempt always runs, even with a token that is already cancelled. Your operation decides what to do with the token, for example by calling
token.ThrowIfCancellationRequested(). - The overloads without a token use
CancellationToken.None.
Argument checks
These throw at the call, before the first attempt:
operationisnull:ArgumentNullExceptionwithParamName"operation".maxAttemptsis below 1:ArgumentOutOfRangeExceptionwithParamName"maxAttempts".initialDelayis negative:ArgumentOutOfRangeExceptionwithParamName"initialDelay".
There is no upper limit on initialDelay at the call. A value longer than Task.Delay accepts (about 49.7 days) fails only after the first failed attempt, with an ArgumentOutOfRangeException whose ParamName is "delay".
To limit each attempt rather than the whole retry, put a WithTimeout inside the operation. A TimeoutException is then retried like any other failure.
Customer customer = await Retry.ExecuteAsync(
token => _crm.GetCustomerAsync(customerId, token).WithTimeout(2.Seconds(), token),
maxAttempts: 3,
initialDelay: 250.Milliseconds(),
shouldRetry: null,
cancellationToken: cancellationToken);
Retry has no jitter, no circuit breaker and no retry budget. For busy services where many clients may retry at the same moment, a full resilience library such as Polly is a better fit.
Related helpers on other pages
SelectAsyncruns an async operation for every item with at mostmaxConcurrencyrunning at once, and returns the results in input order. It is the usual way to call an API for 1,000 items without opening 1,000 connections:string[] bodies = await urls.SelectAsync(FetchAsync, maxConcurrency: 4);. See Transforming sequences.SingleFlightmakes calls that overlap share one run and one task, so 100 concurrent callers trigger one load:Func<Task<Config>> loadOnce = load.SingleFlight();. See Functions.await taskswaits for a sequence or a tuple of tasks and returns the results. See Await anything.
See also
- Await anything:
awaitaTimeSpan, aCancellationToken, a tuple of tasks or aSemaphoreSlim. - Functions:
Memoize,Debounce,ThrottleandSingleFlight. - Durations:
5.Seconds(),200.Milliseconds()and the otherTimeSpanhelpers used on this page. - Exceptions and disposables: working with the exceptions these helpers pass on.