ContinueWith, TaskContinuationOptions, synchronous versus asynchronous continuations, RunContinuationsAsynchronously, Unwrap and why await is usually better.
A continuation is code registered to run when a task completes. await creates continuations for you, with context capture, unwrapped results and normal exception flow. ContinueWith is the lower-level API: it runs regardless of outcome unless you pass options such as OnlyOnRanToCompletion or OnlyOnFaulted, uses TaskScheduler.Current by default, and returns Task<Task> for async lambdas (needs Unwrap). Continuations may run synchronously on the completing thread (ExecuteSynchronously, or by default for await when safe), so producers should use RunContinuationsAsynchronously to avoid inline re-entrancy.
"A continuation is code registered to run when a task completes.
awaitis the modern way to create continuations: it captures the context, gives you the result directly, and makes exceptions flow like normal synchronous code.ContinueWithis the lower-level TPL API. It runs on every outcome unless you restrict it withTaskContinuationOptions; it usesTaskScheduler.Currentby default; you have to inspectantecedent.Exceptionyourself; and with anasynclambda it returns aTask<Task>that needsUnwrap(). The subtle part is where continuations run: when a task completes, its continuations may run inline on the completing thread, which is fast but dangerous if that thread holds a lock or is an I/O or timer thread. That's why producers of tasks, especiallyTaskCompletionSource, should useRunContinuationsAsynchronouslyand complete outside locks."
await vs ContinueWithawait |
ContinueWith |
|
|---|---|---|
| Readability | Sequential code, try/catch/finally |
Callback chains |
| Exceptions | Rethrown at the await (original type) |
Inspect antecedent.Exception (AggregateException); reading .Result rethrows wrapped |
| Runs on fault/cancel? | Throws into your code instead | Runs by default unless options restrict it |
| Scheduler | Captured SynchronizationContext → non-default Current → pool |
TaskScheduler.Current unless specified |
| Async lambda | Natural | Returns Task<Task>; needs Unwrap() |
Unmet OnlyOn... condition |
N/A | Continuation task becomes Canceled |
| Allocation | State machine box on suspension | Continuation task + delegate (+ closure) |
TaskContinuationOptions worth knowing| Option | Effect |
|---|---|
OnlyOnRanToCompletion / OnlyOnFaulted / OnlyOnCanceled |
Run only for that outcome (otherwise the continuation is canceled) |
NotOnRanToCompletion / NotOnFaulted / NotOnCanceled |
Exclusions |
ExecuteSynchronously |
Prefer running inline on the completing thread (only for tiny continuations) |
RunContinuationsAsynchronously |
Force queueing, never inline |
LazyCancellation |
Don't complete as canceled until the antecedent completes |
DenyChildAttach, HideScheduler |
Same as for StartNew |
LongRunning |
Dedicated thread for a blocking continuation (rare) |
| Hazard | Cause | Fix |
|---|---|---|
| Deadlock | Completer calls SetResult inside a lock; continuation runs inline and needs the same lock |
Complete outside locks; RunContinuationsAsynchronously |
| Re-entrancy | Continuation mutates state the completer is in the middle of updating | Same |
| Stalled I/O/timer thread | Heavy continuation runs inline on a completion thread | Let continuations queue to the pool |
| Stack dives | Long chains of synchronous completions | Runtime guards stack depth; avoid ExecuteSynchronously for heavy work |
await continuations run inline only when it's considered safe (no captured context to honour, not a forced-async task), which is one reason they perform well, and why producers must think about it.
ContinueWithwork.ContinueWith(static t => Log.Error(t.Exception!, "Background work failed"),
CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
Our [custom database connection pool] completed waiting requests with TaskCompletionSource.SetResult(connection) while holding its pool lock. Under load, requests hung forever with idle CPU and a flat thread count. A dump showed a thread inside SetResult whose inline continuation, the awaiting request, had finished quickly and was trying to return a connection, which needed the same pool lock that this thread already held through the SetResult call. I changed the waiters to new TaskCompletionSource<Connection>(TaskCreationOptions.RunContinuationsAsynchronously) and moved SetResult after the lock was released. The hang disappeared; we added a stress test with [500] concurrent waiters and a review rule that every TaskCompletionSource must be created with that option.
Checks deep TPL understanding and awareness of continuation hazards.
Green flags
await and ContinueWith on exceptions, scheduler and unwrapping.TaskContinuationOptions values.RunContinuationsAsynchronously.Red flags
ContinueWith chains for normal code.Task.m_continuationObject:
null -> no continuations
single object -> one continuation (common case for await)
List<object> -> several
On completion (FinishContinuations):
for each continuation:
if it may run inline (and stack depth OK) -> run now on this thread
else -> queue to its scheduler / sync context
The completing thread decides. If the task was created with RunContinuationsAsynchronously, nothing runs inline.
Task failing = Task.Run(() => throw new InvalidOperationException());
Task onSuccess = failing.ContinueWith(_ => Console.WriteLine("ok"),
CancellationToken.None, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default);
await onSuccess; // throws TaskCanceledException, not InvalidOperationException!
The continuation didn't run, so its task is canceled. The original failure is only visible on failing. Code that awaits the continuation chain silently loses the real error.
UnwrapTask<Task> outer = first.ContinueWith(async t => { await Task.Delay(100); Save(t.Result); }, TaskScheduler.Default);
await outer; // completes at the inner lambda's first await!
await outer.Unwrap(); // completes when the inner async work completes
Task.Run performs this unwrapping automatically for async lambdas; StartNew and ContinueWith don't.
ContinueWith chain with await// Old
LoadAsync().ContinueWith(t =>
{
if (t.IsFaulted) { Log(t.Exception); return; }
Render(t.Result);
}, TaskScheduler.FromCurrentSynchronizationContext());
// New
try { Render(await LoadAsync()); } // resumes on UI context by default in UI apps
catch (Exception ex) { Log(ex); }
Shorter, correct exception flow, correct scheduler by default.
If you complete tasks (TCS, custom awaitables, IValueTaskSource) |
Why |
|---|---|
Use RunContinuationsAsynchronously (or ManualResetValueTaskSourceCore.RunContinuationsAsynchronously = true) |
Consumers' code won't run inside your critical section |
| Never complete while holding a lock | Even with async continuations, keeps lock hold times short |
| Complete on every path (success, error, cancel) | Prevent hung awaiters |
| Version | Change |
|---|---|
| .NET Framework 4.0 | ContinueWith, TaskContinuationOptions |
| .NET 4.5 | await; LazyCancellation, DenyChildAttach, HideScheduler |
| .NET Framework 4.6 | RunContinuationsAsynchronously |
| .NET Core 2.1+ | Lower-allocation await continuations (state machine box registered directly) |
| .NET 10 | Same semantics |
OnlyOnRanToCompletion on a faulting antecedent and await the continuation; observe TaskCanceledException.lock (gate) while the continuation also does lock (gate) from another method on the same thread path... then fix it.ContinueWith chain as async/await and compare exception output.ContinueWith vs await for a completed task with [MemoryDiagnoser].Task<int> work = Task.Run(() => 21 * 2);
// ContinueWith done properly: explicit options and scheduler.
Task logged = work.ContinueWith(
t => Console.WriteLine($"Completed with {t.Result}"),
CancellationToken.None,
TaskContinuationOptions.OnlyOnRanToCompletion | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
// Async lambda with ContinueWith needs Unwrap.
Task chained = work.ContinueWith(async t =>
{
await Task.Delay(50);
Console.WriteLine($"Async continuation saw {t.Result}");
}, TaskScheduler.Default).Unwrap();
await Task.WhenAll(logged, chained);
// Inline completion demo.
var inline = new TaskCompletionSource();
var queued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Task a = Report("inline", inline.Task);
Task b = Report("queued", queued.Task);
var completer = new Thread(() =>
{
Console.WriteLine($"Completer thread {Environment.CurrentManagedThreadId}");
inline.SetResult(); // continuation may run right here
queued.SetResult(); // continuation is queued to the pool
});
completer.Start();
completer.Join();
await Task.WhenAll(a, b);
static async Task Report(string name, Task t)
{
await t;
Console.WriteLine($"{name} continuation on thread {Environment.CurrentManagedThreadId}");
}
ContinueWith without options, reading t.ResultWhat happens: the continuation runs after a fault and t.Result throws AggregateException.
Why it is wrong: continuations run on every outcome by default.
Do instead: await, or OnlyOnRanToCompletion plus a separate fault handler.
What happens: await task.ContinueWith(..., OnlyOnRanToCompletion) after a failure.
Why it is wrong: the continuation is canceled; you get TaskCanceledException and lose the real error.
Do instead: await the original task, or use await + try/catch.
ContinueWith without UnwrapWhat happens: code awaits a Task<Task>.
Why it is wrong: it completes at the inner lambda's first await.
Do instead: .Unwrap() or rewrite with await.
TaskCompletionSource inside a lockWhat happens: lock (_gate) { tcs.SetResult(x); }.
Why it is wrong: continuations can run inline inside the lock: deadlocks and re-entrancy.
Do instead: RunContinuationsAsynchronously and complete after releasing the lock.
What happens: ContinueWith(action) in library code.
Why it is wrong: uses TaskScheduler.Current, possibly a UI or custom scheduler.
Do instead: pass TaskScheduler.Default (CA2008).
ExecuteSynchronously on heavy continuationsWhat happens: a long-running continuation is marked synchronous "for speed". Why it is wrong: it runs on the completing thread, possibly an I/O or timer thread, blocking others. Do instead: use it only for tiny, non-blocking continuations.
ContinueWith chains in application codeWhat happens: nested callbacks with manual error checks.
Why it is wrong: hard to read, easy to drop exceptions, wrong schedulers.
Do instead: async/await.
What happens: lambdas capture request objects in long-lived continuations.
Why it is wrong: keeps object graphs alive and allocates closures per call.
Do instead: use the state overloads with static lambdas, or await.
await continuations always run on a new threadWhat happens: code relies on the continuation not running inline.
Why it is wrong: await continuations can run synchronously on the completing thread when safe.
Do instead: make producers async-completing; don't depend on thread identity.
What happens: a TCS is completed only on success.
Why it is wrong: awaiters hang forever on errors or cancellation.
Do instead: complete with TrySetException / TrySetCanceled in all failure paths.
In modern code, await is the continuation API. I know ContinueWith so I can review older code and write tight infrastructure, and I care most about inline continuations, because they cause deadlocks that look impossible in a stack trace.
| Use | Prefer |
|---|---|
| Application flow | await |
| Cheap logging on a detached task | ContinueWith(..., OnlyOnFaulted, Default) |
| Completing promises | TaskCompletionSource with async continuations |
| Composing async lambdas | await, or Unwrap() if forced |
A TaskScheduler decides where and when queued delegate tasks run. TaskScheduler.Default queues to the ThreadPool. TaskScheduler.Current is the scheduler of the task currently executing, or Default if none. Task.Run always uses Default, but Task.Factory.StartNew and ContinueWith use Current unless you pass one, so code called from inside a task on a custom or UI scheduler can unexpectedly run there. Pass TaskScheduler.Default explicitly. Custom schedulers (such as ConcurrentExclusiveSchedulerPair) can limit concurrency or serialise work.
An exception inside a task or async method is captured and stored in the task, which becomes Faulted. await rethrows the first stored exception with its original stack; .Wait()/.Result throw an AggregateException. await Task.WhenAll(...) also throws only the first, so inspect each task (or the WhenAll task's Exception.InnerExceptions) to see all failures. OperationCanceledException for the task's token makes it Canceled, not Faulted. Exceptions in tasks nobody observes raise TaskScheduler.UnobservedTaskException later and are otherwise ignored. Exceptions in async void go to the SynchronizationContext or the ThreadPool and crash the process.
Task<T> derives from Task and adds a Result of type T. The safe way to get it is await, which unwraps the value and rethrows the original exception. .Result blocks the calling thread and wraps failures in AggregateException; GetAwaiter().GetResult() also blocks but throws the original exception. Because Task<T> is a class, it is not covariant: a Task<string> is not a Task<object>. Return Task.FromResult for synchronous results and use TaskCompletionSource<T> to turn callbacks into a Task<T>.
How to tell it (STAR)
Our [connection pool] hung under load with idle CPU. The dump showed the continuation running inline inside `SetResult` and blocking on the pool lock that the same thread held. I made the waiters complete asynchronously and released the lock before signalling. The hang disappeared and we added a stress test for it.
Last updated 31 Oct 2026.