Skip to main content

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​

MethodWhat it does
WithTimeoutWaits for a task, but throws TimeoutException if it takes too long.
WithFallbackReturns a default value instead of failing when the task throws or is too slow.
ForgetLets a task run in the background and reports its failure to a callback.
TimedAwaits a task and also returns how long it took.
FirstSuccessfulReturns the result of the first task that succeeds; skips failures.
WhenAllSettledWaits for every task and returns one outcome per task, without throwing.
TaskOutcome<T>How one task ended: IsSuccess, Value and Error.
InCompletionOrderReturns the tasks in the order they finish, so you can handle the fastest first.
ThenRuns the next step once a task succeeds. Async steps are flattened.
CatchRecovers from one exception type. On Task<T>, the exception type comes first.
Retry.ExecuteAsyncRuns 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 errorWithTimeout
Stop waiting after 2 seconds and show a default insteadWithFallback
Try again when a call fails now and thenRetry.ExecuteAsync
Ask three mirrors and take the first good answerFirstSuccessful
Run a batch where some items may fail, and report each oneWhenAllSettled
Show results as they arrive instead of waiting for the slowestInCompletionOrder
Start work you do not await, without losing its exceptionsForget
Measure a call without a Stopwatch in your codeTimed
Write "load, then parse, else use a default" as one expressionThen 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.

Fail fast when the payment gateway hangs
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);
}
}
What each case returns
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)
note

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 null task throws ArgumentNullException with ParamName "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 against Task.Delay.
  • 5.Seconds() and 50.Milliseconds() come from Durations. Any TimeSpan works.

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

OverloadFalls 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
Keep the product page fast when pricing is slow
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);
}
What each case returns
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
  • onFallback receives the task's exception, or a TimeoutException when the timeout elapsed. It is not called when the task succeeds. It may be null.
  • Pass Timeout.InfiniteTimeSpan for "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 than int.MaxValue milliseconds (about 24.8 days) throws ArgumentOutOfRangeException with ParamName "timeout" at the call. It is never turned into the fallback, and onFallback is not called.
  • A null task throws ArgumentNullException with ParamName "task".
  • As with WithTimeout, a slow task keeps running after the fallback is returned.
tip

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.

Send the welcome e-mail without making the sign-up wait
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; Forget only watches for its failure.
  • onError receives the base exception (AggregateException.GetBaseException()), not an AggregateException, unless the task holds several exceptions (for example from Task.WhenAll).
  • onError runs only if the task faults. A successful or cancelled task does not call it.
  • It works on any Task, so also on a Task<T>. The result is discarded.
  • A null task or onError throws ArgumentNullException with ParamName "task" or "onError".
warning

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.

Log slow reports
(Report report, TimeSpan elapsed) = await _reports.BuildMonthlyAsync(month).Timed();

if (elapsed > 10.Seconds())
{
_logger.LogWarning("Monthly report for {Month} took {Elapsed}", month, elapsed);
}
A task without a result
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 null task throws ArgumentNullException with ParamName "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.

Download from the fastest healthy mirror
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();
Failures are skipped
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 AggregateException whose message starts with "Every task failed." (AggregateException appends each inner message in parentheses). Its InnerExceptions holds one exception per task, in the order they failed. A cancelled task appears as a TaskCanceledException.
  • 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 Select starts all of its tasks at that moment.
  • An empty sequence, or one that contains a null task, throws ArgumentException with ParamName "tasks" at the call. A null sequence throws ArgumentNullException.
Cancel the slower providers once one has answered
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.

Upload a batch and report the failures
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);
Success, failure and cancellation in one batch
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 null task throws ArgumentException with ParamName "tasks" at the call. A null sequence throws ArgumentNullException.
  • 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.

MemberTypeMeaning
IsSuccessbooltrue when the task completed successfully.
ValueT?The result. default(T) when the task failed.
ErrorException?The exception for a failed or cancelled task. null on success.
note

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.

Publish search results from each provider as soon as they arrive
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);
}
The order follows completion, not input
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. So await next can throw; wrap it in try/catch if 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 null task throws ArgumentException with ParamName "tasks"; a null sequence throws ArgumentNullException.

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:

Load, parse, or use the defaults
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>>.

ReceivernextReturns
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
TaskFunc<TResult>Task<TResult>
TaskFunc<Task<TResult>>Task<TResult> (flattened)
TaskFunc<Task>Task
TaskActionTask
Map a result
int doubled = await Task.FromResult(21).Then(value => value * 2);
// → 42
Continue with another async call
Order[] orders = await _users.GetAsync(userId)
.Then(user => _orders.GetForCustomerAsync(user.CustomerId));
Steps after a task without a result
int imported = await _importer.ImportInvoicesAsync(file).Then(() => _importer.Count);

await _importer.ImportInvoicesAsync(file).Then(() => _search.ReindexAsync());
  • If the task fails or is cancelled, next does not run and the task's exception or cancellation propagates.
  • If next throws, the returned task faults with that exception.
tip

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.
Replace a failure with a value
int pending = await _orders.CountPendingAsync().Catch<TimeoutException, int>(ex => -1);
Handle a failure of a task without a result
await _webhooks.SendAsync(payload)
.Catch<HttpRequestException>(ex => _logger.LogWarning(ex, "Webhook delivery failed"));
Then and Catch together
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 a FileNotFoundException.
  • Cancellation counts as a failure only when TException is OperationCanceledException, its subclass TaskCanceledException (only when the cancelled task reports that subclass; see the note under TaskOutcome<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.
warning

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:

OverloadUse 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
Three attempts, starting at 200 ms
Order order = await Retry.ExecuteAsync(
() => _api.GetOrderAsync(orderId),
maxAttempts: 3,
initialDelay: 200.Milliseconds());
Two failures, then a success
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:

Output
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.

The last failure is the one you see
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.

Retry only transient errors
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.

  • shouldRetry is not called for the last allowed attempt, nor after the token is cancelled. The exception is thrown in both cases.
  • If shouldRetry itself 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:

  • operation is null: ArgumentNullException with ParamName "operation".
  • maxAttempts is below 1: ArgumentOutOfRangeException with ParamName "maxAttempts".
  • initialDelay is negative: ArgumentOutOfRangeException with ParamName "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".

tip

To limit each attempt rather than the whole retry, put a WithTimeout inside the operation. A TimeoutException is then retried like any other failure.

A 2-second limit per attempt
Customer customer = await Retry.ExecuteAsync(
token => _crm.GetCustomerAsync(customerId, token).WithTimeout(2.Seconds(), token),
maxAttempts: 3,
initialDelay: 250.Milliseconds(),
shouldRetry: null,
cancellationToken: cancellationToken);
info

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.

  • SelectAsync runs an async operation for every item with at most maxConcurrency running 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.
  • SingleFlight makes 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 tasks waits for a sequence or a tuple of tasks and returns the results. See Await anything.

See also​

  • Await anything: await a TimeSpan, a CancellationToken, a tuple of tasks or a SemaphoreSlim.
  • Functions: Memoize, Debounce, Throttle and SingleFlight.
  • Durations: 5.Seconds(), 200.Milliseconds() and the other TimeSpan helpers used on this page.
  • Exceptions and disposables: working with the exceptions these helpers pass on.