Viewing Workflow 5 (Pre-release) Documentation.
Go to Workflow 4 (Latest)

Cancellation

Cancel long-running steps cooperatively using AbortSignal, or cancel entire workflow runs.

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 API you already know.

import { sleep } from "workflow";

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

  const controller = new AbortController(); 

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

  if (result === "timeout") {
    controller.abort(); 
    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, 
  });

  return response.json();
}

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

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.

To learn how AbortController works durably across workflow suspensions, replays, and step boundaries, see How Cancellation Works.

Timeout with Cancellation

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

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:

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

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:

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

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

  for (const chunk of chunks) {
    signal.throwIfAborted(); 
    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:

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([ 
    processData(dataUrl, controller.signal), 
    monitorQuota(userId, controller), 
  ]); 

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

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

async function doExpensiveWork(signal: AbortSignal) {
  "use step";
  const response = await fetch("https://api.example.com/expensive", { signal });
  return response.json();
}
app/api/cancel/route.ts
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:

UsageBehavior on Abort
fetch(url, { signal })Request is cancelled, throws AbortError
signal.throwIfAborted()Throws the abort reason
signal.aborted checkReturns true, step can exit gracefully
signal.addEventListener('abort', fn)Callback fires, step can clean up
IgnoredStep 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.

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
    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():

import { start } from "workflow/api";

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

  // Later, cancel from external code
  controller.abort(); 
}

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

For reliable external cancellation that works regardless of process lifetime, prefer the User-Triggered Cancellation with Hooks pattern. Hooks are durable and don't depend on the caller's process staying alive.

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.

app/api/cancel-run/route.ts
import { getRun } from "workflow/api";

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

  const run = getRun(runId);
  await run.cancel(); 

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

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.

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
  • All associated hooks are disposed and their tokens released
  • Streams are closed

Run cancellation does not automatically abort any outstanding AbortSignals. Steps that are currently executing will run to completion. If you need in-flight cancellation of specific operations, use AbortSignal.

AbortSignal vs. Run Cancellation

AbortSignalRun Cancellation
ScopeIndividual operations within a stepEntire workflow run
Triggered byYour code (controller.abort())External API (run.cancel())
CooperativeYes — steps must check the signalNo — workflow stops at the next suspension point
GranularityCan target specific steps or operationsAll-or-nothing
In-flight stepsAborted immediately if using the signalRun 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:

async function expensiveStep(signal: AbortSignal) {
  "use step";
  signal.throwIfAborted(); 
  // ... expensive work ...
}

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

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)) { 
      return { status: "cancelled" };
    }
    throw err;
  }
}

Use AbortSignal.any() to combine signals:

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

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

Abort after a race:

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
  return winner;
}

This is safe even if both steps have already completed — aborting a finished operation is a no-op.