Cancellation
Cancel long-running steps cooperatively using AbortSignal, or cancel entire workflow runs.
Workflow DevKit supports two cancellation mechanisms: AbortSignal for fine-grained, cooperative cancellation of individual operations, and run cancellation for stopping an entire workflow. This guide covers both.
AbortSignal
AbortController and AbortSignal work across workflow and step boundaries. Create an AbortController with new AbortController() in a workflow function, pass its signal to steps, and call abort() — using the standard AbortController API you already know.
import { sleep } from "workflow";
export async function cancellableWorkflow() {
"use workflow";
const controller = new AbortController();
const result = await Promise.race([
longRunningStep(controller.signal),
sleep("30s").then(() => "timeout" as const),
]);
if (result === "timeout") {
controller.abort();
return { status: "timed out" };
}
return { status: "completed", result };
}
async function longRunningStep(signal: AbortSignal) {
"use step";
const response = await fetch("https://api.example.com/slow-operation", {
signal,
});
return response.json();
}No special imports, no wrapper functions — just the standard AbortController API.
Cancellation is cooperative. Aborting a signal doesn't forcefully kill a step — it's up to the step's code to check signal.aborted or pass the signal to APIs like fetch that respect it. If a step ignores the signal, it runs to completion.
To learn how AbortController works durably across workflow suspensions, replays, and step boundaries, see How Cancellation Works.
Timeout with Cancellation
Race a step against a timeout, and cancel the step if the timeout wins:
import { sleep } from "workflow";
export async function fetchWithTimeout(url: string) {
"use workflow";
const controller = new AbortController();
const result = await Promise.race([
fetchUrl(url, controller.signal),
sleep("10s").then(() => null),
]);
if (result === null) {
controller.abort();
throw new Error(`Request to ${url} timed out after 10s`);
}
return result;
}
async function fetchUrl(url: string, signal: AbortSignal) {
"use step";
const response = await fetch(url, { signal });
return response.json();
}Cancelling Parallel Work
When racing multiple steps, cancel the losers:
export async function firstResponder(urls: string[]) {
"use workflow";
const controller = new AbortController();
const result = await Promise.race(
urls.map((url) => fetchUrl(url, controller.signal))
);
controller.abort(); // Cancel remaining fetches
return result;
}
async function fetchUrl(url: string, signal: AbortSignal) {
"use step";
const response = await fetch(url, { signal });
return { url, data: await response.json() };
}Passing Signal Through a Pipeline
Pass the same signal to a chain of steps. Aborting cancels whichever step is currently running:
declare function splitIntoChunks(data: ArrayBuffer): ArrayBuffer[]; // @setup
declare function processChunk(chunk: ArrayBuffer): Promise<Uint8Array>; // @setup
export async function pipelineWorkflow(dataUrl: string) {
"use workflow";
const controller = new AbortController();
try {
const raw = await downloadData(dataUrl, controller.signal);
const transformed = await transformData(raw, controller.signal);
const result = await uploadData(transformed, controller.signal);
return result;
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
return { status: "cancelled" };
}
throw err;
}
}
async function downloadData(url: string, signal: AbortSignal) {
"use step";
const response = await fetch(url, { signal });
return response.arrayBuffer();
}
async function transformData(data: ArrayBuffer, signal: AbortSignal) {
"use step";
signal.throwIfAborted();
const chunks = splitIntoChunks(data);
const results = [];
for (const chunk of chunks) {
signal.throwIfAborted();
results.push(await processChunk(chunk));
}
return Buffer.concat(results);
}
async function uploadData(data: ArrayBuffer, signal: AbortSignal) {
"use step";
await fetch("https://storage.example.com/upload", {
method: "POST",
body: data,
signal,
});
return { status: "uploaded" };
}Step-Initiated Abort
A step can receive the full AbortController and call abort() to cancel parallel work. This is useful for watchdog/monitor patterns where one step observes an external condition and cancels other in-flight steps:
export async function processWithQuotaCheck(userId: string, dataUrl: string) {
"use workflow";
const controller = new AbortController();
// Run the work and a quota monitor in parallel
const [result] = await Promise.all([
processData(dataUrl, controller.signal),
monitorQuota(userId, controller),
]);
return result;
}
async function processData(url: string, signal: AbortSignal) {
"use step";
const response = await fetch(url, { signal });
const data = await response.arrayBuffer();
// ... expensive processing ...
return { processed: true };
}
async function monitorQuota(userId: string, controller: AbortController) {
"use step";
// Poll quota status while the other step is running
while (!controller.signal.aborted) {
const quota = await fetch(`https://api.example.com/quota/${userId}`);
const { exceeded } = await quota.json();
if (exceeded) {
controller.abort("Quota exceeded"); // Cancels processData
return;
}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}User-Triggered Cancellation with Hooks
Combine hooks with abort controllers to let users cancel in-flight work from an external API:
import { createHook } from "workflow";
export async function userCancellableWorkflow(jobId: string) {
"use workflow";
using cancelHook = createHook<{ reason: string }>({
token: `cancel:${jobId}`,
});
const controller = new AbortController();
const workPromise = doExpensiveWork(controller.signal);
const result = await Promise.race([
workPromise.then((data) => ({ status: "completed", data })),
cancelHook.then((payload) => {
controller.abort();
return { status: "cancelled", reason: payload.reason };
}),
]);
return result;
}
async function doExpensiveWork(signal: AbortSignal) {
"use step";
const response = await fetch("https://api.example.com/expensive", { signal });
return response.json();
}import { resumeHook } from "workflow/api";
export async function POST(request: Request) {
const { jobId, reason } = await request.json();
await resumeHook(`cancel:${jobId}`, { reason });
return Response.json({ cancelled: true });
}How Steps Handle Abort
When an AbortSignal is aborted, the behavior depends on how the step uses it:
| Usage | Behavior on Abort |
|---|---|
fetch(url, { signal }) | Request is cancelled, throws AbortError |
signal.throwIfAborted() | Throws the abort reason |
signal.aborted check | Returns true, step can exit gracefully |
signal.addEventListener('abort', fn) | Callback fires, step can clean up |
| Ignored | Step runs to completion (abort is cooperative) |
Abort Errors Skip Retries
When a step throws due to an abort (e.g., fetch throws AbortError, or signal.throwIfAborted() throws), the error is automatically wrapped in a FatalError. This means the step skips retries and the error bubbles up to the workflow immediately.
This is the correct behavior because an abort is an intentional cancellation — retrying the step would just result in another abort. You don't need to manually wrap abort errors in FatalError.
import { sleep } from "workflow";
export async function workflow() {
"use workflow";
const controller = new AbortController();
try {
const result = await Promise.race([
cancellableStep(controller.signal),
sleep("5s").then(() => null),
]);
if (result === null) controller.abort();
return result;
} catch (err) {
// AbortError arrives as FatalError — no retries attempted
return { status: "cancelled" };
}
}
async function cancellableStep(signal: AbortSignal) {
"use step";
// If this throws AbortError, it's automatically wrapped in FatalError
const response = await fetch("https://api.example.com/slow", { signal });
return response.json();
}Passing AbortSignal as Workflow Input
You can pass an AbortSignal from external code into a workflow via start():
import { start } from "workflow/api";
export async function POST(request: Request) {
const controller = new AbortController();
const run = await start(myWorkflow, [controller.signal]);
// Later, cancel from external code
controller.abort();
}When the signal is serialized at the start() boundary, an event listener is attached to the external signal that writes the cancellation packet to the backing stream. This means the external abort() propagates into the workflow — but only while the originating process is still alive (same constraint as passing a ReadableStream as input).
For reliable external cancellation that works regardless of process lifetime, prefer the User-Triggered Cancellation with Hooks pattern. Hooks are durable and don't depend on the caller's process staying alive.
Run Cancellation
Run cancellation stops an entire workflow at the next suspension point. Unlike AbortSignal, it is not cooperative — the workflow does not continue executing after cancellation.
import { getRun } from "workflow/api";
export async function POST(request: Request) {
const { runId } = await request.json();
const run = getRun(runId);
await run.cancel();
return Response.json({ cancelled: true });
}Calling run.cancel() is the same action as clicking the Cancel button on a run in the observability UI — both produce identical run_cancelled events in the event log.
When a run is cancelled:
- The workflow stops at its next suspension point (step call, hook await, or sleep)
- A
run_cancelledevent is recorded in the event log - All associated hooks are disposed and their tokens released
- Streams are closed
Run cancellation does not automatically abort any outstanding AbortSignals. Steps that are currently executing will run to completion. If you need in-flight cancellation of specific operations, use AbortSignal.
AbortSignal vs. Run Cancellation
| AbortSignal | Run Cancellation | |
|---|---|---|
| Scope | Individual operations within a step | Entire workflow run |
| Triggered by | Your code (controller.abort()) | External API (run.cancel()) |
| Cooperative | Yes — steps must check the signal | No — workflow stops at the next suspension point |
| Granularity | Can target specific steps or operations | All-or-nothing |
| In-flight steps | Aborted immediately if using the signal | Run to completion |
Use AbortSignal when you need fine-grained, in-flight cancellation of specific operations. Use run cancellation when you want to stop the entire workflow.
Best Practices
Use throwIfAborted() before expensive work. This throws the signal's abort reason if the signal is already aborted, preventing wasted compute:
async function expensiveStep(signal: AbortSignal) {
"use step";
signal.throwIfAborted();
// ... expensive work ...
}Handle abort errors in the workflow. Abort errors arrive as FatalError (no retries) and can be caught with a standard try/catch:
declare function cancellableStep(signal: AbortSignal): Promise<void>; // @setup
import { FatalError } from "workflow";
export async function workflow() {
"use workflow";
const controller = new AbortController();
try {
await cancellableStep(controller.signal);
} catch (err) {
if (FatalError.is(err)) {
return { status: "cancelled" };
}
throw err;
}
}Use AbortSignal.any() to combine signals:
async function stepWithMultipleSignals(
userSignal: AbortSignal,
timeoutSignal: AbortSignal
) {
"use step";
const combined = AbortSignal.any([userSignal, timeoutSignal]);
const response = await fetch("https://api.example.com/data", {
signal: combined,
});
return response.json();
}Abort after a race:
declare function stepA(signal: AbortSignal): Promise<string>; // @setup
declare function stepB(signal: AbortSignal): Promise<string>; // @setup
export async function workflow() {
"use workflow";
const controller = new AbortController();
const winner = await Promise.race([
stepA(controller.signal),
stepB(controller.signal),
]);
controller.abort(); // Clean up whichever step is still running
return winner;
}This is safe even if both steps have already completed — aborting a finished operation is a no-op.
Related Documentation
- How Cancellation Works — Hook and stream backing, serialization internals
- Serialization — Understanding serializable types
- Common Patterns — Timeout and race patterns
- Hooks — Pausing workflows for external events
- Errors and Retries — Handling step failures