← Concepts & practices
Choice Concurrency, scheduling, and delivery

Cancellation propagation

A request can stop only what can hear it.

A user changes a search term or a request ends. The stop request has to reach every child that should end, and every child needs a place where it can observe it. Compare AbortSignal and context.Context without mistaking a cancellation request for preemption.

The judgment to keep

Cancellation is a cooperative lifetime protocol. The parent owns the request, the child receives a way to observe it, and cleanup belongs to the work that owns the resource. Detached or blocking work needs another boundary.

TypeScriptGo One parent request · four child behaviors
Start with the lifetime

The parent’s return is not the child’s stop signal.

A page starts three report requests. Before they finish, the user changes the filter. The old requests are no longer useful, but they may still consume sockets, CPU, and server work unless the new lifetime tells them to stop.

In browser code, an AbortController owns the mutable action and passes its read-only AbortSignal to fetch or child functions. In Go, a parent derives a context.Context and children select on Done().

Both are cooperative. A child must reach an observation point, and cleanup must be attached to the child’s actual lifetime. “Cancel” means “please stop at the next safe boundary,” not “the runtime can interrupt any instruction now.”

The design question is not “how do I call cancel?” It is “which work belongs to this parent, where can it observe the request, and who cleans up if it does not?”

Read the parent-owned controllerTypeScript · pass a signal, keep the controller private
cancellation.ts · parent-owned signal
// The parent owns the controller. The child receives only its read-only signal.
export function startReport(requestMs: number) {
	const controller = new AbortController();
	return {
		promise: loadReport(controller.signal, requestMs),
		cancel: () => controller.abort()
	};
}

The child receives only the signal. That keeps the authority to cancel with the parent while making the child’s observation contract visible at the function boundary.

Two models

Different mechanisms, the same ownership questions.

The mechanisms are not interchangeable spellings. AbortSignal is a notification object, and Context is a cancellation-and-deadline tree. Each still needs a child that cooperates and a parent that owns the lifetime.

JavaScript / TypeScript

AbortSignal

The controller’s owner calls abort; consumers observe the signal or an abort-aware API rejects.

Pass
Signal only.
Stop
Abort-aware fetch, listener, or loop.
Clean
finally and listener cleanup.
Go

context.Context

Derived contexts close Done; child operations select on it beside I/O or other blocking work.

Pass
Context as the first argument.
Stop
Select on ctx.Done() at boundaries.
Clean
defer and the returned cancel function.
What each model actually cancels
QuestionAbortSignalContext
Who requests stop?Controller ownerContext cancel owner
What reaches the child?Signal notificationDone channel plus deadline
What is not automatic?Custom loops, CPU, detached workIgnored Done, CPU, detached work
Where does cleanup live?finally or explicit cleanupdefer or explicit cleanup
AbortSignal · listens while it waits
cancellation.ts · AbortSignal
// The child observes the signal for the whole request, not only when it starts:
// an abort while it waits stops the timer and rejects straight away.
export function loadReport(signal: AbortSignal, requestMs: number): Promise<string> {
	return new Promise((resolve, reject) => {
		if (signal.aborted) return reject(signal.reason);
		const onAbort = () => {
			clearTimeout(timer);
			reject(signal.reason);
		};
		const timer = setTimeout(() => {
			signal.removeEventListener('abort', onAbort);
			resolve('report');
		}, requestMs);
		signal.addEventListener('abort', onAbort, { once: true });
	});
}
Context · selects on Done while it waits
cancellation.go · context.Context
// The child selects on ctx.Done() for the whole request, not only when it starts:
// a cancel while it waits returns straight away.
func loadReport(ctx context.Context, request time.Duration) (string, error) {
	timer := time.NewTimer(request)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		return "", ctx.Err()
	case <-timer.C:
		return "report", nil
	}
}
Follow the signal

Cancel the parent. Does the child have a boundary?

Choose a cancellation model and change the child behavior. The first case is cooperative. The others show why a signal or context can still fail to stop work: the child may ignore it, have a separate lifetime, or be stuck in a blocking operation.

One parent request

Cancel the parent. Does the child stop?

Runs a local propagation model
AbortSignal · Child observes cancellation

AbortController.abort() flips the shared signal; abort-aware APIs and listeners see it.

01parent requests stop
02child observes at a boundary
03child cleans up and returns
Does it stop?

The child reaches a cancellation point and stops before doing more work.

Cleanup

finally, defer, or an explicit cleanup path must release resources.

Owner

The parent owns the signal or the context cancel function.

Watch for

Cancellation is cooperative: observation must happen at every blocking or expensive boundary.

Propagation works when the child receives the parent’s cancellation and agrees to observe it.
The controls change a local model; no request, goroutine, or future is started.
Read both implementationsTypeScript and Go · same ownership contract

Start one child: pass a signal or context and observe it for the whole request, so a cancel mid-request stops it early.

TypeScriptReading
cancellation.ts
// The child observes the signal for the whole request, not only when it starts:
// an abort while it waits stops the timer and rejects straight away.
export function loadReport(signal: AbortSignal, requestMs: number): Promise<string> {
	return new Promise((resolve, reject) => {
		if (signal.aborted) return reject(signal.reason);
		const onAbort = () => {
			clearTimeout(timer);
			reject(signal.reason);
		};
		const timer = setTimeout(() => {
			signal.removeEventListener('abort', onAbort);
			resolve('report');
		}, requestMs);
		signal.addEventListener('abort', onAbort, { once: true });
	});
}

// The parent owns the controller. The child receives only its read-only signal.
export function startReport(requestMs: number) {
	const controller = new AbortController();
	return {
		promise: loadReport(controller.signal, requestMs),
		cancel: () => controller.abort()
	};
}
GoAlongside
cancellation.go
// The child selects on ctx.Done() for the whole request, not only when it starts:
// a cancel while it waits returns straight away.
func loadReport(ctx context.Context, request time.Duration) (string, error) {
	timer := time.NewTimer(request)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		return "", ctx.Err()
	case <-timer.C:
		return "report", nil
	}
}


type Result struct {
	Report string
	Err    error
}

// The parent owns the cancel function. The child receives only the context.
func startReport(ctx context.Context, request time.Duration) <-chan Result {
	result := make(chan Result, 1)
	go func() {
		defer close(result)
		report, err := loadReport(ctx, request)
		result <- Result{report, err}
	}()
	return result
}
Make the owner explicit

Choose who can stop the child, and who cleans it.

A cancellation decision is also a lifetime decision. Pass the signal or context at the boundary, observe it wherever the child waits, and make detached work announce its new owner.

A fetch belongs to a page effect that reruns when the search term changes.
A Go handler starts child work that should end when the request ends.
A Go child checks ctx.Done() once, then waits on a slow report query.
Feedback stays on this page; it is not saved.
A production request

Propagate the stop as far as the work can hear it.

Give each request or effect a lifetime owner. In UI code, abort the previous effect run during cleanup and ignore its expected abort outcome. In Go, derive a context for the request, pass it down, and call the cancel function when the owner is done.

Cancellation should also be safe around side effects. Stop before committing an email, database write, or UI update when possible; if the effect may already have happened, pair cancellation with idempotency or an outcome that tells the caller what remains uncertain.

Parent

Own the lifetime

Create the controller or derived context.

Child

Observe at safe points

Check while waiting, before expensive work, and before committing effects.

Cleanup

Release what you own

Keep finally, defer, and join behavior tied to the actual child lifetime.

A report effect creates a fresh AbortController and aborts its request when the query changes.

ReactAlready in your code
textbook.tsx · effect cleanup
import { useEffect, useState } from 'react';

export function Report({ query }: { query: string }) {
	const [status, setStatus] = useState('loading');

	useEffect(() => {
		const controller = new AbortController();
		setStatus('loading');
		fetch(`/api/report?q=${encodeURIComponent(query)}`, { signal: controller.signal })
			.then((response) => {
				if (!response.ok) throw new Error(`HTTP ${response.status}`);
				setStatus('ready');
			})
			.catch((error: unknown) => {
				if (error instanceof DOMException && error.name === 'AbortError') return;
				setStatus('failed');
			});
		return () => controller.abort();
	}, [query]);

	return <p>Report: {status}</p>;
}
Build services or UIs?Cancellation already appears in your lifetimes.

Where it already is in your components

A component effect cleanup, an HTTP request context, and a worker shutdown channel all answer the same question: what work is no longer wanted, and who tells it?

When you have to own it

When stale requests waste resources, handlers outlive clients, or tests hang after a parent exits, pass the lifetime explicitly. Then test both the cancellation signal and the cleanup it is meant to trigger.

Recognize it in UI code

Effect cleanup is a cancellation boundary.

Abort on replacement

Each query or route change gets a new controller; cleanup aborts the old request.

Ignore expected aborts

Navigation away is not a failed server operation. Keep the status from an obsolete run out of the current view.

Share the parent signal

Fan-out children should hear the same lifetime when the page no longer needs any of them.

See late results and stale UI ownership ↗
The parts to watch

A cancel call does not erase work already committed.

AbortSignal is not a kill switch

Fetch can reject on abort, but a custom promise, CPU loop, or third-party client may not observe the signal. Pass it through and check it where the work can safely yield.

Do not pass context as a bag of optional values

Go’s context carries cancellation, deadlines, and request-scoped values. Pass it explicitly as the first parameter, do not store it in a struct for later, and always call a derived cancel function when the owner is finished.

Cancellation after the side effect is uncertainty

If a payment, email, or write may have committed before the stop arrived, report that uncertainty and use idempotency or reconciliation. Cancellation cannot roll back an effect merely because the caller stopped waiting.

Make the call

Give every child a lifetime it can actually observe.

Use a signal or context when the child needs a notification plus deadlines. Detach only when the new owner, cleanup, and shutdown path are explicit. Around blocking or CPU work, add a cancellation point or a worker boundary.

Parent-owned

Pass the lifetime down.

Keep cancellation authority with the request, effect, or scope owner.

Cooperative child

Check and clean up.

Observe at safe points and release resources under the child’s real lifetime.

Detached or blocking

Make a second boundary.

Use a task handle, worker, deadline, or reconciliation path; do not imply preemption.

Take the idea with you

Cancellation is ownership over time.

AbortSignal and context.Context both answer a parent’s request to stop, but neither can make an unaware child disappear. Propagation is complete only when the child can observe the request, exits or reports a bounded outcome, and cleans up what it owned.

Why
Stop work that the parent no longer needs.
What
Propagate a cooperative lifetime and keep cleanup with the resource owner.
Constraint
Keep cancellation separate from failure, and a stop after a side effect separate from both.
Fallback
A child that cannot observe the stop gets a bounded wait and reports an uncertain outcome. Keep propagation at the work boundary.
Reconsider when
The child detaches, blocks, or commits effects before it can observe the stop.
Connections to follow nextRelated lessons
Explore more concepts & practices →