UNPKG

workflow

Version:

Workflow SDK - Build durable, resilient, and observable workflows

149 lines (105 loc) • 6.2 kB
--- title: Serializable AbortController and AbortSignal description: AbortController and AbortSignal now work across workflow and step boundaries using the standard Web API. type: overview --- # Serializable AbortController and AbortSignal <span className="text-sm text-fd-muted-foreground">March 12, 2026</span> `AbortController` and `AbortSignal` now work natively in workflow functions. Create a controller, pass its signal to steps, and call `abort()`; no special imports or wrapper functions needed. ## What's new - **Standard API, zero boilerplate.** `new AbortController()` works inside `"use workflow"` functions. The controller and its signal are automatically serialized across workflow and step boundaries. - **Dual hook + stream backing for durability.** Under the hood, each controller is backed by a durable [hook](/docs/foundations/hooks) (for replay correctness) and a [stream](/docs/foundations/streaming) (for real-time propagation to running steps). This means aborts survive cold starts, replays, and scale events. - **Cooperative cancellation.** Steps receive the abort in real time and can respond by checking `signal.aborted`, calling `signal.throwIfAborted()`, or passing the signal to APIs like `fetch`. - **Abort errors skip retries.** When a step throws due to an abort (e.g., `fetch` throws `AbortError`), the error is automatically wrapped in `FatalError` so it skips retries and bubbles up immediately. - **`AbortSignal.timeout()` blocked in workflow VM.** Because it relies on real-time timers that break deterministic replay, `AbortSignal.timeout()` throws a helpful error pointing to the `sleep()` + `AbortController` pattern instead. - **`Request.signal` preserved when it carries abort state.** A `Request`'s `.signal` is serialized when it's already aborted (so the cancellation that happened pre-serialization is preserved) or when it's a workflow-managed signal (so its hook + stream backing carries through). Plain non-aborted native signals (including the auto-generated signal on `new Request(url)`) are dropped to avoid minting stream infrastructure for every `Request`. To get cross-boundary cancellation through a `Request`, build it with the signal from a workflow-context `AbortController`. - **Pending queue items drain on completion.** If you call `abort()` (or `dispose` a hook, or kick off a `void sleep('1d')`, or fire a `void someStep()`) without a suspension point between that call and the workflow's return, the runtime now treats end-of-run as a final suspension and commits all pending operations before the run is marked terminal. This matches normal JS semantics: `setTimeout` etc. continue running after the surrounding function returns. The most important case: `controller.abort()` called as the last statement of a workflow now actually propagates to in-flight steps on other compute instances. ## Timeout with cancellation Race a step against a durable `sleep()`, and cancel the step if the timeout wins: ```typescript 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: ```typescript declare function fetchUrl(url: string, signal: AbortSignal): Promise<{ url: string; data: unknown }>; // @setup 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; } ``` ## User-triggered cancellation with hooks Combine hooks with abort controllers to let users cancel work from an external API: ```typescript declare function doExpensiveWork(signal: AbortSignal): Promise<unknown>; // @setup 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; } ``` ## Step-initiated abort A step can receive the full `AbortController` and call `abort()` to cancel parallel work, useful for watchdog patterns like quota monitoring: ```typescript declare function processData(url: string, signal: AbortSignal): Promise<{ processed: boolean }>; // @setup export async function processWithQuotaCheck(userId: string, dataUrl: string) { "use workflow"; const controller = new AbortController(); const [result] = await Promise.all([ processData(dataUrl, controller.signal), monitorQuota(userId, controller), ]); return result; } async function monitorQuota(userId: string, controller: AbortController) { "use step"; 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)); } } ``` ## Learn more - [Cancellation](/docs/foundations/cancellation): Full guide with all usage patterns - [How Cancellation Works](/docs/how-it-works/cancellation): Hook and stream internals - [AbortSignal.timeout() in Workflow](/docs/errors/abort-signal-timeout-in-workflow): Why `AbortSignal.timeout()` is blocked and what to use instead