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.
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.
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();}
Use the standard AbortController API without special imports or wrapper functions.
Cancellation is cooperative. Aborting a signal doesn't forcefully kill a step. The step's code must 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.
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)); }}
When a step throws due to an abort (for example, fetch throws AbortError, or signal.throwIfAborted() throws), the runtime wraps the error in a FatalError. The step skips retries, and the error immediately bubbles up to the workflow.
An abort is an intentional cancellation, so retrying the step would 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, with 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();}
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 attaches to the external signal and writes the cancellation packet to the backing stream. The external abort() propagates into the workflow only while the originating process is still alive (the 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 stops an entire workflow at the next suspension point. Unlike AbortSignal, it is not cooperative. The workflow does not continue executing after cancellation.
app/api/cancel-run/route.ts
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 canceled:
The workflow stops at its next suspension point (step call, hook await, or sleep)
A run_cancelled event 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.
No. The 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.