---
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
