BrandGhost
Async Pipelines in C#: Await, Cancellation, and Completion

Async Pipelines in C#: Await, Cancellation, and Completion

Async pipelines in C# are useful when each stage depends on the previous stage but some stages must wait for I/O. The important word is "depends." Loading a document, normalizing its content, and writing the result can all be asynchronous operations, yet they still need to happen in that exact order. Correct async pipeline C# cancellation behavior preserves that order while allowing awaited operations to yield instead of blocking a thread.

That gives us a focused goal for this article: build a Task-based pipeline whose stages are awaited sequentially, receive one propagated CancellationToken, expose an unambiguous completion task, and clean up the resources they own. In the implementation below, all stage work is represented by returned tasks.

If async and await are still new territory, my article on async await in C#: 3 Beginner Tips You Need to Know provides prerequisite context. Here, we are concentrating on how those language features shape a multi-stage processing lifecycle.

An Async Pipeline Starts With Dependent Awaits

The smallest useful model has three parts:

  1. A stage accepts one declared input type.
  2. The stage returns Task<TOutput>.
  3. The pipeline awaits that task before invoking the next dependent stage.

That sequence is asynchronous, but it is not parallel. According to the .NET asynchronous programming guidance, asynchronous code can make progress without dedicating a thread to waiting. The await operator suspends the enclosing async method while an incomplete operation is pending. Neither fact means stage two may run before stage one has produced its output.

Consider the dependency chain:

ImportRequest
    -> LoadedDocument
    -> NormalizedDocument
    -> ImportReceipt

The normalizer cannot process a LoadedDocument that does not exist yet. The writer cannot persist a NormalizedDocument that has not been created. Invoking later stages before their inputs exist would violate those dependencies or force the code to hide them behind another mechanism.

Sequential composition is also not the same as saying the code is single-threaded. The await operator guidance explains that continuation behavior depends on the asynchronous environment, so an awaited operation can resume on a different thread. The guarantee we care about is stage order for one pipeline execution, not thread identity.

This distinction keeps the design honest. Async nonblocking execution solves waiting. It does not automatically make dependent stages parallel or establish any throughput guarantee.

The Complete Sequential Async Pipeline

The following example is one complete C# file targeting the stable .NET 10 support baseline as of August 2026 with C# 14 and nullable reference types enabled. It loads a text file asynchronously, normalizes its content, and writes a second file. Every stage task is awaited, and the same cancellation token crosses every cancellable boundary.

using System.Text;

namespace AsyncPipelineExample;

public sealed record ImportRequest(
    string SourcePath,
    string DestinationPath);

public sealed record LoadedDocument(
    string SourcePath,
    string DestinationPath,
    string Content);

public sealed record NormalizedDocument(
    string DestinationPath,
    string Content);

public sealed record ImportReceipt(
    string DestinationPath,
    int CharacterCount);

public interface IAsyncStage<TInput, TOutput>
{
    Task<TOutput> ExecuteAsync(
        TInput input,
        CancellationToken cancellationToken);
}

public sealed class LoadDocumentStage
    : IAsyncStage<ImportRequest, LoadedDocument>
{
    public async Task<LoadedDocument> ExecuteAsync(
        ImportRequest input,
        CancellationToken cancellationToken)
    {
        await using FileStream stream = new(
            input.SourcePath,
            FileMode.Open,
            FileAccess.Read,
            FileShare.Read,
            bufferSize: 4096,
            FileOptions.Asynchronous);

        using StreamReader reader = new(
            stream,
            Encoding.UTF8,
            detectEncodingFromByteOrderMarks: true,
            bufferSize: 4096,
            leaveOpen: true);

        string content = await reader.ReadToEndAsync(cancellationToken);

        return new LoadedDocument(
            input.SourcePath,
            input.DestinationPath,
            content);
    }
}

public sealed class NormalizeDocumentStage
    : IAsyncStage<LoadedDocument, NormalizedDocument>
{
    public Task<NormalizedDocument> ExecuteAsync(
        LoadedDocument input,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        string normalized = string.Join(
            ' ',
            input.Content.Split(
                (char[]?)null,
                StringSplitOptions.RemoveEmptyEntries));

        return Task.FromResult(
            new NormalizedDocument(
                input.DestinationPath,
                normalized));
    }
}

public sealed class WriteDocumentStage
    : IAsyncStage<NormalizedDocument, ImportReceipt>
{
    public async Task<ImportReceipt> ExecuteAsync(
        NormalizedDocument input,
        CancellationToken cancellationToken)
    {
        await File.WriteAllTextAsync(
            input.DestinationPath,
            input.Content,
            Encoding.UTF8,
            cancellationToken);

        return new ImportReceipt(
            input.DestinationPath,
            input.Content.Length);
    }
}

public sealed class DocumentImportPipeline
{
    private readonly IAsyncStage<ImportRequest, LoadedDocument> _loader;
    private readonly IAsyncStage<LoadedDocument, NormalizedDocument> _normalizer;
    private readonly IAsyncStage<NormalizedDocument, ImportReceipt> _writer;

    public DocumentImportPipeline(
        IAsyncStage<ImportRequest, LoadedDocument> loader,
        IAsyncStage<LoadedDocument, NormalizedDocument> normalizer,
        IAsyncStage<NormalizedDocument, ImportReceipt> writer)
    {
        _loader = loader;
        _normalizer = normalizer;
        _writer = writer;
    }

    public async Task<ImportReceipt> RunAsync(
        ImportRequest request,
        CancellationToken cancellationToken)
    {
        LoadedDocument loaded = await _loader.ExecuteAsync(
            request,
            cancellationToken);

        NormalizedDocument normalized =
            await _normalizer.ExecuteAsync(
                loaded,
                cancellationToken);

        ImportReceipt receipt = await _writer.ExecuteAsync(
            normalized,
            cancellationToken);

        return receipt;
    }
}

public static class Program
{
    public static async Task Main()
    {
        string sourcePath = Path.Combine(
            AppContext.BaseDirectory,
            "pipeline-input.txt");

        string destinationPath = Path.Combine(
            AppContext.BaseDirectory,
            "pipeline-output.txt");

        await File.WriteAllTextAsync(
            sourcePath,
            "Pipeline    stages\nremain ordered.",
            Encoding.UTF8);

        var pipeline = new DocumentImportPipeline(
            new LoadDocumentStage(),
            new NormalizeDocumentStage(),
            new WriteDocumentStage());

        using var cancellationSource =
            new CancellationTokenSource(TimeSpan.FromSeconds(10));

        try
        {
            ImportReceipt receipt = await pipeline.RunAsync(
                new ImportRequest(sourcePath, destinationPath),
                cancellationSource.Token);

            Console.WriteLine(
                $"Wrote {receipt.CharacterCount} characters to " +
                $"{receipt.DestinationPath}.");
        }
        catch (OperationCanceledException)
            when (cancellationSource.IsCancellationRequested)
        {
            Console.WriteLine("The import was canceled.");
        }
        finally
        {
            File.Delete(sourcePath);
            File.Delete(destinationPath);
        }
    }
}

The stage interface is intentionally small. Each implementation receives its input and the pipeline token, then returns a Task<TOutput>. The pipeline runner is the composition owner: it knows the exact order and converts each completed output into the next stage's input.

Notice that NormalizeDocumentStage does not add async merely because the interface is asynchronous. Its work is synchronous, so it checks cancellation, computes the value, and returns Task.FromResult. Wrapping that CPU work in Task.Run would not make this pipeline more correct. It would introduce scheduling behavior that this sequential model does not need.

The file stages use asynchronous file APIs because they actually wait on I/O. This is where Task-based stages help. The runner can yield while a read or write is incomplete, but it still does not call the next stage early.

Define the Async Pipeline Lifecycle Contract

A stage signature is more than a convenient delegate shape. It tells the caller what value is required, what value will be produced, how cancellation enters the operation, and which task represents completion. Keeping those elements together prevents lifecycle behavior from being hidden in mutable properties or separate signaling mechanisms.

IAsyncStage<TInput, TOutput> does not expose a separate Start method followed by WaitForCompletionAsync. Splitting one operation across those methods would create invalid states: started but never awaited, started twice, or disposed before completion. One ExecuteAsync call returns one task that covers the promised work.

The contract also avoids an optional cancellation token. A default token is convenient in some public APIs, but requiring the parameter at the stage boundary makes propagation visible during composition. The runner cannot accidentally call a tokenless overload because none exists.

Stage outputs should represent completed stage work. WriteDocumentStage returns its receipt only after WriteAllTextAsync finishes. If a stage instead returned a handle to unfinished internal work, the output type and ownership rules would need to say so explicitly. Otherwise, downstream code reasonably assumes the stage's promised operation is complete.

Finally, the runner should not create an unrelated timeout for every stage by default. The caller in this example owns the ten-second budget for the complete operation. Per-stage budgets can be useful, but they need defined ownership and should fit within the caller's overall deadline. Layering independent timeouts without a budget model produces surprising cancellation reasons rather than a clearer lifecycle.

Cancellation Is a Propagated Request

.NET cancellation is cooperative, as described in Cancellation in Managed Threads. A CancellationTokenSource requests cancellation. The source cannot forcibly unwind every method or reverse completed work. Participating code must receive the token, observe it, and stop at a safe point.

The example has one token for one pipeline execution. That same token is passed to:

  • the loader;
  • StreamReader.ReadToEndAsync;
  • the normalizer;
  • the writer; and
  • File.WriteAllTextAsync.

This token propagation matters because a stage can only respond to a request it knows about. If the pipeline passes the token to its first stage but omits it from a downstream write, cancellation may stop part of the workflow while the write continues. The caller sees a confusing lifecycle because the public operation and its internal operations no longer share the same cancellation contract.

Synchronous work should cooperate too. NormalizeDocumentStage calls ThrowIfCancellationRequested before beginning its transform. For a tiny string operation, one checkpoint is enough. A longer CPU-bound loop might need additional checkpoints, but their placement should preserve invariants. Stopping halfway through mutation of shared state is not automatically safe.

Cancellation responsiveness also depends on the underlying operation. Passing a token does not guarantee immediate termination at an arbitrary machine instruction. The operation decides where it can observe the request. That is why documentation should say "requests cancellation" rather than "kills the stage."

Finally, the CancellationTokenSource has an owner. Main creates it, passes its token into the pipeline, waits for the pipeline to finish, and disposes the source with using, following the .NET managed cancellation guidance. A stage should not dispose a token source it did not create.

Preserve Cancellation as Cancellation

Pipeline code should not turn every OperationCanceledException into a generic failure. The .NET task cancellation guidance explains that task cancellation is associated with a requested token. Preserving that token-sensitive path allows the returned task to communicate cancellation rather than an unrelated fault.

The runner in this example does not catch cancellation at all. It has no recovery action to perform, so the exception flows through the returned task. The application boundary catches OperationCanceledException only when its own source requested cancellation.

That separation is useful:

  • A normal result means all stages completed.
  • A canceled task means the cooperative cancellation path stopped the operation.
  • A faulted task means an unexpected exception escaped a stage.

Do not return a default ImportReceipt after cancellation. That would make an incomplete pipeline look successful. Do not wrap the cancellation exception in InvalidOperationException merely to add context. That would erase the distinction the caller needs.

Managed cancellation is cooperative and does not roll back completed work (Cancellation in Managed Threads). If the destination file was fully written before cancellation was requested, that file remains. If cancellation interrupts the write, the application must define whether a partial file is possible and how it should be handled. Reversal, compensation, and transactional behavior require explicit mechanisms beyond CancellationToken.

Completion Needs a Clear Owner

A direct Task-based pipeline does not need a separate "done" flag. Its completion signal is the task returned by RunAsync.

The ownership chain is straightforward:

  1. Each stage owns completing the task returned by ExecuteAsync.
  2. DocumentImportPipeline owns awaiting each stage task in order.
  3. The caller owns awaiting the task returned by RunAsync.

When the writer completes successfully, RunAsync returns the terminal ImportReceipt. If a stage faults, awaiting that stage observes and propagates its exception through the returned task (Task API). If cancellation is honored, awaiting the pipeline observes the token-sensitive canceled path described by .NET task cancellation. Every path reaches the caller through one task.

This is why fire-and-forget work is such a poor fit inside a sequential pipeline. Suppose the writer starts an internal task and immediately returns a receipt. The pipeline now reports completion before the write has completed. Exceptions from that hidden task can go unobserved, and the caller may dispose dependencies or exit the process while the write is still active.

The fix is not a polling property. The fix is to include all required work in the task the stage returns, then await it. The stage must complete that task only after its promised operation is finished. Dependent stages still wait for their declared input.

The caller has a matching responsibility: do not discard the task returned by RunAsync. An application entry point can await it directly, while a request handler can return or await it as part of the request lifecycle. Either choice keeps success, cancellation, and exceptions connected to the operation that initiated the pipeline.

If an outer component must track several pipeline invocations, it should retain and observe each returned task according to that component's lifetime. The pipeline itself should not hide those invocations in a static collection or a detached callback. Completion remains useful only when every owner can identify the task it is responsible for awaiting.

This task chain also creates a natural cleanup boundary. Once RunAsync has completed, the caller knows no stage from that invocation should still be using its request-scoped inputs. That makes disposal ordering explicit instead of relying on a delay or an assumption that background work has probably finished.

async void breaks the same ownership chain: except for genuine event handlers, .NET async guidance favors Task-returning methods because a caller cannot await an async void method, naturally observe its completion, or handle its exception through the pipeline task.

Dispose Resources at the Ownership Boundary

Asynchronous stages often touch resources with lifetimes shorter than the pipeline itself. A file stream, response stream, or asynchronous enumerator should be disposed by the code that owns it after the final awaited use.

LoadDocumentStage creates the FileStream, so it owns disposal. The stage uses await using, which follows the async disposal guidance. The reader is disposed before the underlying stream, and leaveOpen: true makes that ordering explicit.

The pipeline runner does not implement IAsyncDisposable because it does not own a long-lived asynchronous resource. It only holds stage references. Adding an empty DisposeAsync method would suggest lifecycle work that does not exist.

That changes if a runner creates and owns a long-lived async-disposable dependency. In that design, the runner should implement IAsyncDisposable, stop accepting executions, await all active work, and only then dispose the dependency. Disposal must not race with a stage that is still using the resource.

Message ownership deserves the same clarity. This example passes immutable records and strings, so no downstream disposal is necessary. If a message carried a stream, the contract would need to state whether the producing stage transfers ownership to the next stage or retains responsibility for disposal.

Common Async Pipeline Mistakes

The first mistake is assuming that an async pipeline is a parallel pipeline. Writing await between stages preserves dependency order. It does not overlap those dependent stages.

The second mistake is starting a later stage before its input is available. If the writer requires the normalizer's output, the normalizer must finish first.

The third mistake is replacing natural async I/O with Task.Run. File, HTTP, and database APIs commonly expose Task-based methods already, and .NET async guidance distinguishes asynchronous I/O from moving CPU-bound work to another thread. Wrapping existing async I/O in Task.Run adds worker-thread scheduling that this sequential model does not need.

The fourth mistake is dropping cancellation at one boundary. A public token is meaningful only when the pipeline propagates it into the cancellable operations that perform the work.

The fifth mistake is swallowing exceptions or cancellation to keep the pipeline moving. A sequential pipeline cannot safely execute stage three after stage two failed to produce its declared output. Returning a fabricated value shifts the failure to a later stage and makes diagnosis harder.

The sixth mistake is composing result-returning async stages through a multicast delegate. C# delegate guidance documents that only the final delegate return value is directly available, so invoke each result-returning stage explicitly and await its task.

When This Async Pipeline Model Fits

Direct Task-based composition is a strong starting point when one item follows a known sequence and stages mainly wait on I/O. It keeps type transitions visible and gives the caller one task representing the complete operation.

It is not a general orchestration model. One invocation moves one value through one known dependency chain. That simplicity is valuable when the operation does not require branching or durable coordination.

If the business flow branches, waits for external events, or must survive process restarts, a linear in-process pipeline may be the wrong abstraction. Those requirements need lifecycle mechanisms beyond sequential Task-based composition.

Frequently Asked Questions About Async Pipeline C# Cancellation

These questions cover the boundaries that most often become unclear when a sequential pipeline first adopts Task-based stages.

Does awaiting every stage make the pipeline synchronous?

No. The stages are sequential because each output is required by the next stage, but the await operator allows the method to yield while an incomplete I/O operation is pending. Sequential describes dependency order; synchronous describes a different execution behavior.

Should every stage method use the async keyword?

No. The Task API supports returning an already completed result with Task.FromResult; use async when the method needs to await asynchronous work or when its control flow benefits from an async state machine. Do not add artificial delay or Task.Run just to make a stage appear asynchronous.

Where should CancellationTokenSource be created?

Create it at the boundary that owns the cancellation policy, such as the request handler, command handler, or application entry point. Pass its token down. The creator disposes the source after participating work has finished, as required by the .NET managed cancellation guidance.

Should a stage catch OperationCanceledException?

Only when it has a specific responsibility, such as cleanup or translating between well-defined contracts. If the stage cannot recover, let cancellation propagate. Avoid converting it into a generic error that makes a canceled task look faulted.

Is the returned RunAsync task the completion signal?

Yes. In this direct composition model, the returned task represents the entire pipeline execution, and awaiting it observes success, cancellation, or an exception through the documented Task contract. A separate completion event would duplicate state and create opportunities for disagreement.

When should the pipeline implement IAsyncDisposable?

Implement it when the pipeline owns resources that require asynchronous cleanup and outlive one stage call, following the .NET async disposal guidance. Do not add it merely because the pipeline methods are async. Resource ownership, not the presence of await, determines the disposal contract.

Async Pipelines in C#: Keep the Lifecycle Explicit

A correct async pipeline in C# is not complicated by default. Invoke one dependent stage, await its task, pass its output forward, and propagate the same cancellation token. Let the returned pipeline task represent completion, observe every task, and dispose resources where ownership actually lives.

That model is deliberately sequential. It solves nonblocking I/O and lifecycle clarity without pretending dependent stages are parallel. If a future requirement changes the execution model, treat that as a separate architecture decision rather than an accidental consequence of async.

async void - How to Tame the Asynchronous Nightmare

Most intermediate dotnet devs writing async await code in C# will come across async void at some point. Here's a creative solution for avoiding the headaches.

async await in C#: 3 Beginner Tips You Need to Know

Dive into async await in C# with these 3 beginner tips. Learn how to write async await code, handle multiple exceptions, and avoid dreaded deadlocks!

Async Event Handlers in C#: What You Need to Know

Learn how to safely use async event handlers in C#. Understand the dangers and discover best practices for managing async event handlers in your C# code.

An error has occurred. This application may no longer respond until reloaded. Reload