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

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

```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();
}
```

Use the standard `AbortController` API without special imports or wrapper functions.

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

<Callout type="info">
  To learn how `AbortController` works durably across workflow suspensions, replays, and step boundaries, see [How Cancellation Works](/v5/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 canceled, 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 (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`.

```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, with 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 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).

<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 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](/v5/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. 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.

## 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 because aborting a finished operation is a no-op.

## Related documentation

* [How Cancellation Works](/v5/docs/how-it-works/cancellation): Hook and stream backing, serialization internals
* [Serialization](/v5/docs/foundations/serialization): Understanding serializable types
* [Cookbook](/cookbook): Timeout, race, and other reliability patterns
* [Hooks](/v5/docs/foundations/hooks): Pausing workflows for external events
* [Errors and Retries](/v5/docs/foundations/errors-and-retries): Handling step failures


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)