When and How to Use Task Continuations
A continuation is a task that the task scheduler starts automatically after another task – called the antecedent – has completed. Instead of blocking a thread with Wait() and then writing the next step below it, you hand the next step to the runtime and say: "run this when the first task is done".
This gives you an ordered chain of work while the calling thread stays free. The classic shape is:
Continuations solve three everyday problems:
- Ordering. Step B must not start before step A has produced its value.
- Data flow. The continuation receives the finished antecedent task, so it can read
Resultand pass a new value to the next link in the chain. - Conditional reaction. A continuation can be configured to run only when the antecedent succeeded, only when it failed, or only when it was cancelled.
This article explains the concept with two examples:
- Core concept example (
Run()) – a single continuation, multi-task continuations (ContinueWhenAny/ContinueWhenAll) and conditional continuations withTaskContinuationOptions. - Real-world example (
RunTicketPriceScenario()) – a three-step ticket pricing pipeline where each step is a continuation of the previous one.
Features and Design
ContinueWith
Task.ContinueWith() is the basic way to attach a continuation to one antecedent. The delegate you pass in receives the antecedent task object, not its value. That is an important detail for beginners: you must write antecedent.Result to reach the produced value. Reading Result inside a continuation never blocks, because the continuation only starts after the antecedent has already finished.
Two overload families exist:
ContinueWith(Action<Task>)– the continuation produces no value and returns a plainTask.ContinueWith<TResult>(Func<Task, TResult>)– the continuation returns a value and therefore produces aTask<TResult>, which can be the antecedent of the next continuation. This is how a pipeline is built.
By default continuations are scheduled asynchronously and are not guaranteed to run on the same thread as the antecedent. A continuation is a task itself and does not block the thread that created it.
Multi-task continuations
ContinueWith() follows exactly one antecedent. When you need to wait for several tasks, the TaskFactory class offers two multi-task continuations:
Task.Factory.ContinueWhenAll(tasks, delegate)– runs after all antecedents have completed. The delegate receives the array of antecedents, but it does not merge their results automatically; you read them one by one.Task.Factory.ContinueWhenAny(tasks, delegate)– runs as soon as the first antecedent completes. The delegate receives only that winning task.
The modern equivalents are Task.WhenAll(...).ContinueWith(...) and Task.WhenAny(...).ContinueWith(...). They achieve the same result and are usually easier to read, because Task.WhenAll<TResult> already collects the results into an array.
Conditional options such as OnlyOnFaulted are not valid for multi-task continuations, because there is no single antecedent whose state could be tested.
TaskContinuationOptions
TaskContinuationOptions is an optional parameter of ContinueWith() that controls whether and how the continuation runs. The values fall into a few groups.
Options that require a successful antecedent:
OnlyOnRanToCompletion– runs only if the antecedent completed successfully.NotOnFaulted– runs only if the antecedent did not throw.OnlyOnCanceled– runs only if the antecedent was cancelled.
Options that allow a failed antecedent:
None(the default) – runs no matter what happened.OnlyOnFaulted– runs only if the antecedent threw an exception.NotOnRanToCompletion– runs only if the antecedent did not succeed.NotOnCanceled– runs only if the antecedent was not cancelled.
Options that do not decide execution but influence scheduling:
ExecuteSynchronously– try to run the continuation on the thread that completed the antecedent instead of queueing it.LongRunning– hint that the continuation will occupy a thread for a long time.AttachedToParent/DenyChildAttach– control the parent/child task relationship.PreferFairnessandHideScheduler– fine scheduling hints.
Options may be combined with the | operator, as long as they do not contradict each other (for example OnlyOnFaulted | OnlyOnRanToCompletion is invalid).
An important side effect: when a conditional continuation is skipped, it does not simply disappear. It ends in the Canceled state. That is why Task.WaitAll() over a success branch and a failure branch always throws an AggregateException – one of the two branches is always cancelled.
Errors in continuations
An exception that escapes the delegate body makes the task faulted. With the default option None, the continuation still runs after a faulted antecedent. If that continuation touches antecedent.Result, the original exception is rethrown wrapped in an AggregateException, which can cascade down the chain.
There are two safe strategies:
- Check
antecedent.IsFaultedorantecedent.Statusbefore readingResult. - Or, better, let
TaskContinuationOptionsdo the filtering: one continuation withOnlyOnRanToCompletionfor the happy path and one withOnlyOnFaultedfor the error path.
The Exception property of a faulted task is always an AggregateException, even when only one exception was thrown. Call Flatten() and then read InnerException to reach the real error.
Example 1 – Core Concept: ContinueWith, Multi-Task Continuations and Options
The first example is split into three short parts so that each idea is visible on its own. Part 1 chains a single continuation to one antecedent and reads its result. Part 2 reacts to three tasks at the same time with ContinueWhenAny and ContinueWhenAll. Part 3 attaches a success continuation and a failure continuation to a task that always throws, so the effect of TaskContinuationOptions is easy to observe.
Code Example
How It Works
Part 1 – a single continuation.
countLettersis the antecedent. It sleeps for 200 milliseconds to simulate work and returns the length of the word"continuation". The delay is intentionally small: long enough to prove the work is asynchronous, short enough to keep the demo fast.countLetters.ContinueWith(...)registers the follow-up work. The lambda parameterantecedentis the finishedTask<int>object, so the produced number is read asantecedent.Result.- Printing
antecedent.StatusshowsRanToCompletion, which proves that the antecedent was already finished before the continuation started. describeCount.Wait()is used only for teaching purposes: it keeps the console output of the three parts in order.
Part 2 – several antecedents.
- Three tasks are started with different sleep times (600, 400 and 100 milliseconds). The different durations make the winner of
ContinueWhenAnypredictable, which is helpful when learning. Task.Factory.ContinueWhenAnyfires as soon asfastSensorfinishes, after roughly 100 milliseconds. The other two tasks keep running; they are simply ignored.Task.Factory.ContinueWhenAllwaits for all three. Its parameter isTask<string>[], and the values are collected manually withantecedents.Select(sensor => sensor.Result).Task.WhenAll(...).ContinueWith(...)does the same job in modern style. Hereantecedent.Resultis already astring[], so only its length has to be printed.
Part 3 – conditional continuations.
readBrokenSensoralways throwsInvalidOperationException, so it always ends in theFaultedstate.- The
onSuccesscontinuation usesOnlyOnRanToCompletion. Because the antecedent faulted, this continuation never executes; the runtime marks it asCanceled. - The
onFailurecontinuation usesOnlyOnFaultedand therefore always runs. It readsantecedent.Exception?.Flatten().InnerException, because theExceptionproperty is always anAggregateExceptionwrapper. Task.WaitAll()throws anAggregateException– partly because of the faulted antecedent, partly because the skipped continuation is cancelled. The exception is caught and ignored on purpose, so the final three lines can print the exact state of each task.
Output
The two ContinueWhenAll-style lines in Part 2 may swap places between runs, because both continuations become ready at the same moment and the scheduler decides their order. Everything else is deterministic.
Example 2 – Real-World Scenario: A Ticket Pricing Pipeline
The second example shows the everyday use of continuations: a small pipeline. A ticket price is looked up from a (simulated) remote service, a member discount is applied, and finally a receipt is printed. Each step is a continuation of the previous one, and the value flows from step to step. One extra continuation is attached as the error branch of the pipeline.
Code Example
How It Works
- Step 1 – the source.
lookUpBasePricesleeps for 300 milliseconds to stand for a network call and returns120m. A fixed value is used so the whole demo stays deterministic and students can compare their output line by line. - Step 2 – a value-producing continuation. Because the lambda returns a
decimal, the compiler picksContinueWith<decimal>and the result is aTask<decimal>. That new task is the antecedent of the next step, which is exactly how a pipeline is chained. The discount factor0.75mrepresents a fixed 25 % member discount. - Step 3 – the consumer.
printReceiptonly prints, so it returns a plainTask. It is guarded withOnlyOnRanToCompletionso that a broken price lookup can never produce a half-printed receipt. - The error branch.
reportFailureis attached to the same antecedent as step 3 but withOnlyOnFaulted. Exactly one of the two branches runs: the receipt on success, the error message on failure. It is attached toapplyDiscountbecause a fault in step 1 is automatically propagated to step 2, so a single guard covers both earlier steps. - The try/catch around
WaitAll. The branch that does not apply ends in theCanceledstate, andTask.WaitAll()reports that as anAggregateException. Nothing is actually wrong, so it is caught and ignored. CultureInfo.InvariantCultureis used when formatting the money values so that the decimal separator is always a dot, regardless of the machine's regional settings.
Output
This output is fully deterministic: the steps always run in the same order, because each one is a continuation of the one before it.
Best Practices for Chaining Tasks
- Use
ContinueWith()when you need fine control – conditional execution based on the antecedent's state, custom schedulers, or long-running hints. - Prefer
async/awaitwhen you do not need that control. It reads like normal sequential code and uses ordinarytry/catchinstead ofAggregateExceptionhandling. - Use
Task.WaitAll()to block until several independent tasks are done, but be careful: it blocks the calling thread and can cause deadlocks in UI or asynchronous contexts. - Use
Task.WaitAny()when you can continue as soon as the first result arrives. - Handle errors deliberately. With
ContinueWith(), either checkIsFaulted/Statusbefore readingResult, or split the chain into anOnlyOnRanToCompletionbranch and anOnlyOnFaultedbranch. Remember to callFlatten()on theAggregateException. - Avoid deep nesting of tasks inside tasks; keep the chain flat and easy to read.
- Pass a
CancellationTokentoTask.Run()andContinueWith()so the chain can be stopped gracefully.
When to Use
Use continuations when:
- You need a fixed order of asynchronous steps without blocking a thread between them.
- The result of one step is the input of the next step (a processing pipeline).
- You want different follow-up code for success, failure and cancellation.
- You must react to a group of tasks with
ContinueWhenAllorContinueWhenAny. - You are working in a codebase that targets an older style of TPL code, or you need a scheduling option that
awaitdoes not expose.
Prefer alternatives when:
- Plain
async/awaitwould express the same chain more clearly – this is the common case in modern C#. - The steps are independent and can run in parallel; then use
Task.WhenAll()without a continuation chain. - The work is CPU-bound data processing over a collection; then
Parallel.For()orParallel.ForEach()is a better fit.
Running the Examples
Both methods are called one after another from Program.cs: