Cancellation gets awkward once asynchronous work fans out. The Context interface in @plnkr/std/context wraps an AbortSignal in a hierarchy: cancel a parent and its child operations stop too; give one child a shorter deadline without changing its siblings.

Crop of a DALL-E image I created in an attempt to represent the hierarchical nesting of Contexts.
What is Context?
Context carries an AbortSignal and creates child contexts with their own cancellation, timeout, or deadline.
Here is the interface:
export interface Context {
readonly signal: AbortSignal;
// ... (Other methods and properties)
withCancel(): ContextController;
withDeadline(deadlineEpochMs: number): ContextController;
withTimeout(timeoutMs: number, message?: string): ContextController;
withAbortSignal(signal: AbortSignalLike): ContextController;
}
Creating a basic context
The withCancel() method returns a child Context and the function that cancels it.
import { createRootContext } from "@plnkr/std/context";
const programCtx = createRootContext();
const { ctx, cancel } = programCtx.withCancel();
// Use ctx.signal for your asynchronous operations
// Manually cancel the context when needed
cancel("Operation aborted");
Handling timeouts and deadlines
A child context can carry its own timeout or deadline.
const { ctx, cancel } = programCtx.withTimeout(1000, "Operation timed out");
// Use ctx.signal for asynchronous operations with a timeout
You can also set a deadline for a specific epoch time:
const { ctx, cancel } = programCtx.withDeadline(Date.now() + 10000);
// Use ctx.signal for asynchronous operations with a deadline
Triggering an HTTP API call with a timeout
Here is a server request with a five-second outer timeout and a one-second timeout for its API call:
import { finished } from "node:stream";
// Triggering an HTTP API call within the server-side request handling
async function handleRequest(req, res) {
const { ctx, cancel } = programCtx.withTimeout(5000, "Server timed out");
ctx.onDidCancel(() => {
if (!res.headersSent) {
// If the context is cancelled before the headers are sent, abort the request.
res.writeHead(504);
res.end("Cancelled");
}
});
finished(req, (err) => {
cancel(err ?? "Response finished");
});
try {
const { ctx: apiCtx } = ctx.withTimeout(1000, "API request failed");
const data = await myApiCall(apiCtx, req.query.id);
// Handle returned data and serve it to the user
} catch (error) {
console.error(`Error: ${error.message}`);
// Handle the error appropriately
}
}
The API timeout belongs to its child context. Finishing the request cancels the outer context, which also cancels the API call if it is still running.
Passing a child context to another function
The child context can travel through the call chain:
import { createRootContext } from "@plnkr/std/context";
// Simulating an asynchronous operation
async function performAsyncOperation(ctx) {
await new Promise((resolve) => setTimeout(resolve, 6000));
ctx.throwIfCancelled();
console.log("Operation completed successfully");
}
// Pass child context to another function
async function startOperationWithContext(ctx) {
try {
await performAsyncOperation(ctx);
} catch (error) {
console.error(`Error: ${error.message}`);
}
}
const programCtx = createRootContext();
const { ctx: childCtx, cancel: cancelChild } = programCtx.withTimeout(
5000,
"Operation timed out"
);
startOperationWithContext(childCtx);
// Later, if needed, cancel the child context to propagate cancellation
// cancelChild("Operation aborted");
The timeout follows childCtx into startOperationWithContext() and performAsyncOperation(). Each layer observes the same cancellation.
Reacting to cancellation
Registering callbacks
Use onDidCancel() when code needs a callback as soon as the context is canceled.
ctx.onDidCancel((reason) => {
console.log(`Operation canceled: ${reason}`);
});
The onDidCancel() method returns a Disposable object in case you want to stop listening for cancellation.
Throwing cancellation errors
Use throwIfCancelled() to immediately throw a cancellation error if the Context is canceled.
try {
ctx.throwIfCancelled();
// Continue with your asynchronous operation
} catch (error) {
console.error(`Operation aborted: ${error.message}`);
}
Wiring process failures and signals into cancellation
A root context can also turn process failures and stop signals into one cancellation path:
import { createRootContext } from "@plnkr/std/context";
import { logger } from "./your-logger-library"; // Replace with your logger library
const programCtx = createRootContext();
const ctl = programCtx.withCancel();
const onStopSignal = (signal) => {
logger.info({ signal }, "Stop signal received");
ctl.cancel(`Received signal ${signal}`);
};
process.once("SIGINT", onStopSignal);
process.once("SIGTERM", onStopSignal);
process.once("uncaughtException", (err) => {
logger.fatal({ err }, "Uncaught exception");
ctl.cancel(new Error("Uncaught exception", { cause: err }));
});
process.once("unhandledRejection", (err) => {
logger.fatal({ err }, "Unhandled rejection");
ctl.cancel(new Error("Unhandled rejection", { cause: err }));
});
Two rules
- Pass the narrowest relevant
Contextthrough each layer of async work. - Clean up resources and cancel contexts when their work ends.
Conclusion
Context does not perform cleanup by itself. It gives related async work a common cancellation signal and lets that signal narrow as calls fan out. Pair it with explicit disposal when a resource outlives one operation.
This article began as an experiment in using an LLM to draft from the library’s TypeScript interfaces. I edited it for this site.
Originally published on Medium.