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
// 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.
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.
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.
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.
| Question | AbortSignal | Context |
|---|---|---|
| Who requests stop? | Controller owner | Context cancel owner |
| What reaches the child? | Signal notification | Done channel plus deadline |
| What is not automatic? | Custom loops, CPU, detached work | Ignored Done, CPU, detached work |
| Where does cleanup live? | finally or explicit cleanup | defer or explicit cleanup |
// 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 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
}
} 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.
Cancel the parent. Does the child stop?
AbortController.abort() flips the shared signal; abort-aware APIs and listeners see it.
The child reaches a cancellation point and stops before doing more work.
finally, defer, or an explicit cleanup path must release resources.
The parent owns the signal or the context cancel function.
Cancellation is cooperative: observation must happen at every blocking or expensive boundary.
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.
// 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()
};
} // 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
} 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.
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.
Own the lifetime
Create the controller or derived context.
Observe at safe points
Check while waiting, before expensive work, and before committing effects.
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.
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.
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.
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.
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.
Pass the lifetime down.
Keep cancellation authority with the request, effect, or scope owner.
Check and clean up.
Observe at safe points and release resources under the child’s real lifetime.
Make a second boundary.
Use a task handle, worker, deadline, or reconciliation path; do not imply preemption.
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
- Promises vs. goroutines & channels for execution and message ownership.
- Event loop vs. scheduler for the safe points where a child can notice the stop.
- Structured concurrency for the scope that owns, cancels, and joins the children.
- Idempotency & at-least-once delivery when cancellation arrives after an effect may have happened.