High-Performance .NET: Async, Multithreading, and Parallel Programming Tasks in .Net Created: 27 Jul 2026 Updated: 28 Aug 2026

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:

antecedent --> continuation --> continuation --> ...

Continuations solve three everyday problems:

  1. Ordering. Step B must not start before step A has produced its value.
  2. Data flow. The continuation receives the finished antecedent task, so it can read Result and pass a new value to the next link in the chain.
  3. 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:

  1. Core concept example (Run()) – a single continuation, multi-task continuations (ContinueWhenAny / ContinueWhenAll) and conditional continuations with TaskContinuationOptions.
  2. 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:

  1. ContinueWith(Action<Task>) – the continuation produces no value and returns a plain Task.
  2. ContinueWith<TResult>(Func<Task, TResult>) – the continuation returns a value and therefore produces a Task<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:

  1. 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.
  2. 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:

  1. OnlyOnRanToCompletion – runs only if the antecedent completed successfully.
  2. NotOnFaulted – runs only if the antecedent did not throw.
  3. OnlyOnCanceled – runs only if the antecedent was cancelled.

Options that allow a failed antecedent:

  1. None (the default) – runs no matter what happened.
  2. OnlyOnFaulted – runs only if the antecedent threw an exception.
  3. NotOnRanToCompletion – runs only if the antecedent did not succeed.
  4. NotOnCanceled – runs only if the antecedent was not cancelled.

Options that do not decide execution but influence scheduling:

  1. ExecuteSynchronously – try to run the continuation on the thread that completed the antecedent instead of queueing it.
  2. LongRunning – hint that the continuation will occupy a thread for a long time.
  3. AttachedToParent / DenyChildAttach – control the parent/child task relationship.
  4. PreferFairness and HideScheduler – 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:

  1. Check antecedent.IsFaulted or antecedent.Status before reading Result.
  2. Or, better, let TaskContinuationOptions do the filtering: one continuation with OnlyOnRanToCompletion for the happy path and one with OnlyOnFaulted for 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

/// <summary>
/// Core concept example.
/// A continuation is simply a task that the scheduler starts after its antecedent task
/// has completed. The continuation receives the antecedent task itself as its input
/// parameter, so it can read the antecedent's Result, Status or Exception.
/// </summary>
public static void Run()
{
Console.WriteLine("=== Task continuations: core concept ===");
Console.WriteLine();

// ----------------------------------------------------------------------------
// Part 1: a single continuation that consumes the antecedent's result.
// ----------------------------------------------------------------------------
Console.WriteLine("--- Part 1: ContinueWith on a single antecedent ---");

// Task.Run<int> starts work on a thread pool thread and returns the produced value.
// 200 milliseconds is used only to make the work visible; it is short enough that
// the demo still finishes quickly.
Task<int> countLetters = Task.Run(() =>
{
Thread.Sleep(200);
const string word = "continuation";
Console.WriteLine($"[antecedent] Counted the letters of '{word}'.");
return word.Length;
});

// ContinueWith(Action<Task<TResult>>) registers the follow-up work.
// The parameter named "antecedent" IS the finished task, not its value, which is why
// the value has to be read from antecedent.Result.
// Reading Result here never blocks, because the continuation only starts after the
// antecedent has already completed.
Task describeCount = countLetters.ContinueWith(antecedent =>
{
Console.WriteLine($"[continuation] The antecedent returned {antecedent.Result}.");
Console.WriteLine($"[continuation] Antecedent status: {antecedent.Status}.");
});

// Wait() blocks the current thread until the chain is finished. It is used here only
// so that the console output of the three parts does not get mixed up.
describeCount.Wait();
Console.WriteLine();

// ----------------------------------------------------------------------------
// Part 2: multi-task continuations.
// ----------------------------------------------------------------------------
Console.WriteLine("--- Part 2: continuations after several antecedents ---");

// Three independent tasks with different durations. The different delays make the
// completion order predictable enough to explain, while still being asynchronous.
Task<string> slowSensor = Task.Run(() =>
{
Thread.Sleep(600);
return "slow sensor";
});

Task<string> mediumSensor = Task.Run(() =>
{
Thread.Sleep(400);
return "medium sensor";
});

Task<string> fastSensor = Task.Run(() =>
{
Thread.Sleep(100);
return "fast sensor";
});

// ContinueWhenAny runs as soon as the FIRST antecedent completes.
// The delegate receives that single finished task, so the winner can be reported.
// Typical use: take the answer of the fastest replica and ignore the rest.
Task reportWinner = Task.Factory.ContinueWhenAny(
[slowSensor, mediumSensor, fastSensor],
winner => Console.WriteLine($"[ContinueWhenAny] First one done: {winner.Result}."));

// ContinueWhenAll runs after EVERY antecedent has completed.
// The delegate receives the whole array of antecedents, but it does not merge their
// results automatically, so the values are read one by one.
// Conditional options such as OnlyOnFaulted are not allowed here, because there is no
// single antecedent whose state could be tested.
Task reportAll = Task.Factory.ContinueWhenAll(
[slowSensor, mediumSensor, fastSensor],
antecedents =>
{
string values = string.Join(", ", antecedents.Select(sensor => sensor.Result));
Console.WriteLine($"[ContinueWhenAll] All done: {values}.");
});

// Task.WhenAll(...).ContinueWith(...) is the modern equivalent of ContinueWhenAll.
// It is shown here so both spellings of the same idea are visible side by side.
Task modernStyle = Task.WhenAll(slowSensor, mediumSensor, fastSensor)
.ContinueWith(antecedent =>
Console.WriteLine($"[WhenAll + ContinueWith] Received {antecedent.Result.Length} sensor values."));

// WaitAll blocks until all three continuations are finished.
Task.WaitAll(reportWinner, reportAll, modernStyle);
Console.WriteLine();

// ----------------------------------------------------------------------------
// Part 3: conditional continuations with TaskContinuationOptions.
// ----------------------------------------------------------------------------
Console.WriteLine("--- Part 3: TaskContinuationOptions ---");

// This task always fails, so the difference between the two options is easy to see.
// The Func<int> is written explicitly because a lambda that only throws cannot be
// matched to a single Task.Run overload on its own.
Func<int> brokenSensorReading = () =>
{
Thread.Sleep(150);
throw new InvalidOperationException("The sensor did not answer.");
};

Task<int> readBrokenSensor = Task.Run(brokenSensorReading);

// OnlyOnRanToCompletion: the continuation starts only when the antecedent finished
// successfully. Because the antecedent faults, this continuation is cancelled and
// never runs. Other useful values: NotOnFaulted (also covers cancellation),
// OnlyOnCanceled, NotOnCanceled, NotOnRanToCompletion and None (the default, which
// runs no matter what happened).
Task onSuccess = readBrokenSensor.ContinueWith(
antecedent => Console.WriteLine($"[OnlyOnRanToCompletion] Result was {antecedent.Result}."),
TaskContinuationOptions.OnlyOnRanToCompletion);

// OnlyOnFaulted: the continuation starts only when the antecedent threw an exception.
// A faulted task always stores an AggregateException, even for a single error, so
// Flatten().InnerException is used to reach the exception that was really thrown.
Task onFailure = readBrokenSensor.ContinueWith(
antecedent =>
{
Exception? original = antecedent.Exception?.Flatten().InnerException;
Console.WriteLine($"[OnlyOnFaulted] Antecedent failed with: {original?.Message}");
},
TaskContinuationOptions.OnlyOnFaulted);

// WaitAll throws an AggregateException here for two reasons: the antecedent faulted,
// and the skipped continuation ends up in the Canceled state. Both are expected in
// this demo, so the exception is caught and only the states are printed.
try
{
Task.WaitAll(readBrokenSensor, onSuccess, onFailure);
}
catch (AggregateException)
{
// Swallowed on purpose: the interesting information is the status of each task.
}

Console.WriteLine($"Antecedent status : {readBrokenSensor.Status}");
Console.WriteLine($"onSuccess status : {onSuccess.Status}");
Console.WriteLine($"onFailure status : {onFailure.Status}");
Console.WriteLine();
}

How It Works

Part 1 – a single continuation.

  1. countLetters is 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.
  2. countLetters.ContinueWith(...) registers the follow-up work. The lambda parameter antecedent is the finished Task<int> object, so the produced number is read as antecedent.Result.
  3. Printing antecedent.Status shows RanToCompletion, which proves that the antecedent was already finished before the continuation started.
  4. describeCount.Wait() is used only for teaching purposes: it keeps the console output of the three parts in order.

Part 2 – several antecedents.

  1. Three tasks are started with different sleep times (600, 400 and 100 milliseconds). The different durations make the winner of ContinueWhenAny predictable, which is helpful when learning.
  2. Task.Factory.ContinueWhenAny fires as soon as fastSensor finishes, after roughly 100 milliseconds. The other two tasks keep running; they are simply ignored.
  3. Task.Factory.ContinueWhenAll waits for all three. Its parameter is Task<string>[], and the values are collected manually with antecedents.Select(sensor => sensor.Result).
  4. Task.WhenAll(...).ContinueWith(...) does the same job in modern style. Here antecedent.Result is already a string[], so only its length has to be printed.

Part 3 – conditional continuations.

  1. readBrokenSensor always throws InvalidOperationException, so it always ends in the Faulted state.
  2. The onSuccess continuation uses OnlyOnRanToCompletion. Because the antecedent faulted, this continuation never executes; the runtime marks it as Canceled.
  3. The onFailure continuation uses OnlyOnFaulted and therefore always runs. It reads antecedent.Exception?.Flatten().InnerException, because the Exception property is always an AggregateException wrapper.
  4. Task.WaitAll() throws an AggregateException – 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

=== Task continuations: core concept ===

--- Part 1: ContinueWith on a single antecedent ---
[antecedent] Counted the letters of 'continuation'.
[continuation] The antecedent returned 12.
[continuation] Antecedent status: RanToCompletion.

--- Part 2: continuations after several antecedents ---
[ContinueWhenAny] First one done: fast sensor.
[WhenAll + ContinueWith] Received 3 sensor values.
[ContinueWhenAll] All done: slow sensor, medium sensor, fast sensor.

--- Part 3: TaskContinuationOptions ---
[OnlyOnFaulted] Antecedent failed with: The sensor did not answer.
Antecedent status : Faulted
onSuccess status : Canceled
onFailure status : RanToCompletion

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

/// <summary>
/// Real-world example.
/// A very small ticket pricing pipeline built from continuations:
/// look up the base price, apply the member discount, then print the receipt.
/// One extra continuation is attached to report any failure in the chain.
/// </summary>
public static void RunTicketPriceScenario()
{
Console.WriteLine("=== Real-world scenario: a ticket pricing pipeline ===");

// Step 1 of the pipeline: pretend to read the base price from a remote service.
// 300 milliseconds stands for the network call; it is long enough to show that the
// steps really run one after another.
Task<decimal> lookUpBasePrice = Task.Run(() =>
{
Thread.Sleep(300);
decimal basePrice = 120m;
Console.WriteLine($"Step 1: base ticket price is {basePrice} EUR.");
return basePrice;
});

// Step 2: a continuation that RETURNS a value, so ContinueWith<TResult> is used.
// The discounted price becomes the input of the next step, which is how data flows
// through a continuation pipeline.
Task<decimal> applyDiscount = lookUpBasePrice.ContinueWith(antecedent =>
{
// A fixed 25 percent member discount keeps the output deterministic.
decimal discounted = antecedent.Result * 0.75m;

// InvariantCulture is used so the decimal separator is always a dot, no matter
// which regional settings the machine uses.
Console.WriteLine(
$"Step 2: after the 25% member discount the price is {discounted.ToString("0.00", CultureInfo.InvariantCulture)} EUR.");
return discounted;
});

// Step 3: the final continuation only prints, so it returns nothing.
// OnlyOnRanToCompletion protects this step: if either earlier step failed, the
// receipt is not printed at all, and no half-finished output reaches the user.
Task printReceipt = applyDiscount.ContinueWith(
antecedent =>
{
Console.WriteLine("Step 3: receipt");
Console.WriteLine(
$" Total to pay: {antecedent.Result.ToString("0.00", CultureInfo.InvariantCulture)} EUR");
},
TaskContinuationOptions.OnlyOnRanToCompletion);

// The error branch of the pipeline. It is attached to the last value-producing step,
// because a fault in step 1 is automatically carried over to step 2.
Task reportFailure = applyDiscount.ContinueWith(
antecedent =>
{
Exception? original = antecedent.Exception?.Flatten().InnerException;
Console.WriteLine($"Pricing failed: {original?.Message}");
},
TaskContinuationOptions.OnlyOnFaulted);

// Waiting on both branches keeps the console output ordered. In a real application
// the pipeline would normally be awaited instead of blocking a thread.
try
{
Task.WaitAll(printReceipt, reportFailure);
}
catch (AggregateException)
{
// The branch that does not apply is left in the Canceled state, which makes
// WaitAll throw. Nothing has gone wrong, so the exception is ignored here.
}

Console.WriteLine("Pipeline finished.");
Console.WriteLine();
}

How It Works

  1. Step 1 – the source. lookUpBasePrice sleeps for 300 milliseconds to stand for a network call and returns 120m. A fixed value is used so the whole demo stays deterministic and students can compare their output line by line.
  2. Step 2 – a value-producing continuation. Because the lambda returns a decimal, the compiler picks ContinueWith<decimal> and the result is a Task<decimal>. That new task is the antecedent of the next step, which is exactly how a pipeline is chained. The discount factor 0.75m represents a fixed 25 % member discount.
  3. Step 3 – the consumer. printReceipt only prints, so it returns a plain Task. It is guarded with OnlyOnRanToCompletion so that a broken price lookup can never produce a half-printed receipt.
  4. The error branch. reportFailure is attached to the same antecedent as step 3 but with OnlyOnFaulted. Exactly one of the two branches runs: the receipt on success, the error message on failure. It is attached to applyDiscount because a fault in step 1 is automatically propagated to step 2, so a single guard covers both earlier steps.
  5. The try/catch around WaitAll. The branch that does not apply ends in the Canceled state, and Task.WaitAll() reports that as an AggregateException. Nothing is actually wrong, so it is caught and ignored.
  6. CultureInfo.InvariantCulture is used when formatting the money values so that the decimal separator is always a dot, regardless of the machine's regional settings.

Output

=== Real-world scenario: a ticket pricing pipeline ===
Step 1: base ticket price is 120 EUR.
Step 2: after the 25% member discount the price is 90.00 EUR.
Step 3: receipt
Total to pay: 90.00 EUR
Pipeline finished.

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

  1. Use ContinueWith() when you need fine control – conditional execution based on the antecedent's state, custom schedulers, or long-running hints.
  2. Prefer async/await when you do not need that control. It reads like normal sequential code and uses ordinary try/catch instead of AggregateException handling.
  3. 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.
  4. Use Task.WaitAny() when you can continue as soon as the first result arrives.
  5. Handle errors deliberately. With ContinueWith(), either check IsFaulted/Status before reading Result, or split the chain into an OnlyOnRanToCompletion branch and an OnlyOnFaulted branch. Remember to call Flatten() on the AggregateException.
  6. Avoid deep nesting of tasks inside tasks; keep the chain flat and easy to read.
  7. Pass a CancellationToken to Task.Run() and ContinueWith() so the chain can be stopped gracefully.

When to Use

Use continuations when:

  1. You need a fixed order of asynchronous steps without blocking a thread between them.
  2. The result of one step is the input of the next step (a processing pipeline).
  3. You want different follow-up code for success, failure and cancellation.
  4. You must react to a group of tasks with ContinueWhenAll or ContinueWhenAny.
  5. You are working in a codebase that targets an older style of TPL code, or you need a scheduling option that await does not expose.

Prefer alternatives when:

  1. Plain async/await would express the same chain more clearly – this is the common case in modern C#.
  2. The steps are independent and can run in parallel; then use Task.WhenAll() without a continuation chain.
  3. The work is CPU-bound data processing over a collection; then Parallel.For() or Parallel.ForEach() is a better fit.

Running the Examples

Both methods are called one after another from Program.cs:

TaskContinuationExample.Run();
TaskContinuationExample.RunTicketPriceScenario();


Share this lesson: