---
title: Cancellation
description: Cancel long-running steps cooperatively using AbortSignal, or cancel entire workflow runs.
type: conceptual
summary: Cancel in-flight work with AbortSignal or stop entire workflow runs.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/foundations/hooks
  - /docs/how-it-works/cancellation
---

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](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) API you already know.

```typescript lineNumbers
import { sleep } from "workflow";

export async function cancellableWorkflow() {
  "use workflow";

  const controller = new AbortController(); // [!code highlight]

  const result = await Promise.race([
    longRunningStep(controller.signal), // [!code highlight]
    sleep("30s").then(() => "timeout" as const),
  ]);

  if (result === "timeout") {
    controller.abort(); // [!code highlight]
    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, // [!code highlight]
  });

  return response.json();
}
```

No special imports, no wrapper functions — just the standard `AbortController` API.

<Callout type="info">
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.
</Callout>

<Callout type="info">
To learn how `AbortController` works durably across workflow suspensions, replays, and step boundaries, see [How Cancellation Works](/docs/how-it-works/cancellation).
</Callout>

### Timeout with Cancellation

Race a step against a timeout, and cancel the step if the timeout wins:

```typescript lineNumbers
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(); // [!code highlight]
    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 lineNumbers
export async function firstResponder(urls: string[]) {
  "use workflow";

  const controller = new AbortController();

  const result = await Promise.race( // [!code highlight]
    urls.map((url) => fetchUrl(url, controller.signal)) // [!code highlight]
  ); // [!code highlight]

  controller.abort(); // Cancel remaining fetches // [!code highlight]

  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:

```typescript lineNumbers
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(); // [!code highlight]

  const chunks = splitIntoChunks(data);
  const results = [];

  for (const chunk of chunks) {
    signal.throwIfAborted(); // [!code highlight]
    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:

```typescript lineNumbers
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([ // [!code highlight]
    processData(dataUrl, controller.signal), // [!code highlight]
    monitorQuota(userId, controller), // [!code highlight]
  ]); // [!code highlight]

  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 // [!code highlight]
      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:

```typescript lineNumbers
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([ // [!code highlight]
    workPromise.then((data) => ({ status: "completed", data })),
    cancelHook.then((payload) => { // [!code highlight]
      controller.abort(); // [!code highlight]
      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();
}
```

```typescript title="app/api/cancel/route.ts" lineNumbers
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`.

```typescript lineNumbers
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 // [!code highlight]
    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()`:

{/* @skip-typecheck: myWorkflow is not declared, this is a conceptual snippet */}
```typescript lineNumbers
import { start } from "workflow/api";

export async function POST(request: Request) {
  const controller = new AbortController();
  const run = await start(myWorkflow, [controller.signal]); // [!code highlight]

  // Later, cancel from external code
  controller.abort(); // [!code highlight]
}
```

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

<Callout type="info">
For reliable external cancellation that works regardless of process lifetime, prefer the [User-Triggered Cancellation with Hooks](#user-triggered-cancellation-with-hooks) pattern. Hooks are durable and don't depend on the caller's process staying alive.
</Callout>

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

```typescript title="app/api/cancel-run/route.ts" lineNumbers
import { getRun } from "workflow/api";

export async function POST(request: Request) {
  const { runId } = await request.json();

  const run = getRun(runId);
  await run.cancel(); // [!code highlight]

  return Response.json({ cancelled: true });
}
```

<Callout type="info">
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.
</Callout>

When a run is cancelled:
- The workflow stops at its next suspension point (step call, hook await, or sleep)
- A `run_cancelled` event is recorded in the [event log](/docs/how-it-works/event-sourcing)
- All associated hooks are disposed and their tokens released
- Streams are closed

<Callout type="info">
Run cancellation does **not** automatically abort any outstanding `AbortSignal`s. Steps that are currently executing will run to completion. If you need in-flight cancellation of specific operations, use `AbortSignal`.
</Callout>

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

```typescript lineNumbers
async function expensiveStep(signal: AbortSignal) {
  "use step";
  signal.throwIfAborted(); // [!code highlight]
  // ... expensive work ...
}
```

**Handle abort errors in the workflow.** Abort errors arrive as `FatalError` (no retries) and can be caught with a standard try/catch:

```typescript lineNumbers
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)) { // [!code highlight]
      return { status: "cancelled" };
    }
    throw err;
  }
}
```

**Use `AbortSignal.any()` to combine signals:**

```typescript lineNumbers
async function stepWithMultipleSignals(
  userSignal: AbortSignal,
  timeoutSignal: AbortSignal
) {
  "use step";

  const combined = AbortSignal.any([userSignal, timeoutSignal]); // [!code highlight]
  const response = await fetch("https://api.example.com/data", {
    signal: combined,
  });
  return response.json();
}
```

**Abort after a race:**

```typescript lineNumbers
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 // [!code highlight]
  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](/docs/how-it-works/cancellation) — Hook and stream backing, serialization internals
- [Serialization](/docs/foundations/serialization) — Understanding serializable types
- [Cookbook](/v5/cookbook) — Timeout, race, and other reliability patterns
- [Hooks](/docs/foundations/hooks) — Pausing workflows for external events
- [Errors and Retries](/docs/foundations/errors-and-retries) — Handling step failures
