workflow
Version:
Workflow SDK - Build durable, resilient, and observable workflows
149 lines (105 loc) • 6.2 kB
text/mdx
---
title: Serializable AbortController and AbortSignal
description: AbortController and AbortSignal now work across workflow and step boundaries using the standard Web API.
type: overview
---
<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.
- **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();
}
```
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;
}
```
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;
}
```
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));
}
}
```
- [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