Idempotency

Make step retries safe and coordinate duplicate workflow starts with hook tokens.

Idempotency is a property of an operation that ensures repeated attempts have the same effect as a single attempt.

In Workflow, idempotency shows up in two related places: step idempotency makes external calls safe when a step retries, and run idempotency coordinates duplicate requests that try to start the same workflow.

Step Idempotency

In distributed systems (calling external APIs), it is not always possible to ensure an operation has only been performed once just by seeing if it succeeds. Consider a payment API that charges the user $10, but due to network failures, the confirmation response is lost. When the step retries (because the previous attempt was considered a failure), it will charge the user again.

To prevent this, many external APIs support idempotency keys. An idempotency key is a unique identifier for an operation that can be used to deduplicate requests.

Every step invocation has a stable stepId that stays the same across retries. Use it as the idempotency key when calling third-party APIs.

import { getStepMetadata } from "workflow";

async function chargeUser(userId: string, amount: number) {
  "use step";

  const { stepId } = getStepMetadata(); 

  // Example: Stripe-style idempotency key
  // This guarantees only one charge is created even if the step retries
  await stripe.charges.create(
    {
      amount,
      currency: "usd",
      customer: userId,
    },
    {
      idempotencyKey: stepId, 
    }
  );
}

Why this works:

  • Stable across retries: stepId does not change between attempts.
  • Globally unique per step: Fulfills the uniqueness requirement for an idempotency key.

Run idempotency

Step idempotency protects side effects inside a workflow run. Run idempotency answers a different question: if the same API request is sent twice, should it create one workflow run or two?

Because hooks already ensure globally unique active tokens, Workflow can use the same mechanism to coordinate duplicate requests while a run is active.

Use a hook token as the idempotency key for an active workflow run. Hook tokens are globally unique while they are active: if another run tries to create a hook with the same token, the runtime records a conflict, hook.getConflict() resolves with { runId } identifying the run that owns the token, and the hook rejects with HookConflictError when the workflow awaits or iterates its payload.

The token should come from your domain, such as an order ID, invoice ID, import ID, or request ID. Create the hook near the beginning of the workflow and check await hook.getConflict() before doing duplicate-sensitive work that depends on owning the active token. Calling createHook() alone does not register the hook — awaiting getConflict() suspends the workflow to commit the registration.

import { createHook } from "workflow";

type OrderRequest = { confirmed: boolean };
type OrderResult =
  | { status: "processed" | "cancelled" }
  | { status: "duplicate"; runId: string };
declare function chargeOrder(orderId: string): Promise<void>; // @setup

export async function processOrder(orderId: string): Promise<OrderResult> {
  "use workflow";

  using request = createHook<OrderRequest>({ 
    token: `order:${orderId}`, 
  }); 

  const conflict = await request.getConflict(); 
  if (conflict) { 
    // Another active run already owns this order's token.
    return { status: "duplicate" as const, runId: conflict.runId }; 
  } 

  const { confirmed } = await request;

  if (!confirmed) {
    return { status: "cancelled" as const };
  }

  await chargeOrder(orderId);
  return { status: "processed" as const };
}

The runtime creates the hook atomically. At most one active hook can own order:${orderId}, so duplicate workflow runs converge on one active owner. A duplicate run observes getConflict() resolving with { runId } and returns before it reaches chargeOrder(). To act on the owner, pass conflict.runId to getRun() inside a step — the duplicate run can do more than report the owner; see conflict-handling strategies below.

Outside the workflow, try to resume the hook first. If the hook is not registered yet, start the workflow and retry the resume until the new run creates the hook:

import { resumeHook, start } from "workflow/api";
import { HookNotFoundError } from "workflow/errors";
import { processOrder } from "./workflows/process-order";

type OrderRequest = { confirmed: boolean };

async function resumeOrder(token: string, payload: OrderRequest) {
  for (let attempt = 0; attempt < 5; attempt++) {
    try {
      return await resumeHook(token, payload); 
    } catch (error) {
      if (!HookNotFoundError.is(error)) throw error;
      await new Promise((resolve) => setTimeout(resolve, 100));
    }
  }

  throw new Error("Order workflow did not register its hook in time");
}

export async function POST(request: Request) {
  const { orderId, confirmed } = await request.json();
  const token = `order:${orderId}`;
  const payload = { confirmed };

  try {
    const hook = await resumeHook(token, payload); 
    return Response.json({ runId: hook.runId, reused: true });
  } catch (error) {
    if (!HookNotFoundError.is(error)) throw error;
  }

  const run = await start(processOrder, [orderId]); 
  const resumed = await resumeOrder(token, payload);

  // A concurrent request's run may have won the race between `start()`
  // and hook registration. The resume always reaches the actual active
  // owner, so compare run IDs instead of waiting for this run to finish.
  return Response.json({ 
    runId: resumed.runId, 
    reused: resumed.runId !== run.runId, 
  }); 
}

This avoids creating a new run only after the first run has registered its hook. Because start() returns before the run body executes and calls createHook(), two concurrent requests can both observe "no hook yet" and each call start(). The race is resolved inside the workflow body, where the losing run observes getConflict() resolving with the active owner and returns without doing duplicate-sensitive work — and the route detects it by comparing the resumed hook's runId against the run it just started, without waiting for either run to finish. A native API for atomically starting a run and registering a hook is in the works. Until then, model recovery inside the workflow by checking hook.getConflict().

This is active-run coordination. When the workflow completes and disposes the hook, the token can be used again. If a duplicate request after completion must return the original result instead of starting fresh work, persist that completed result under the same domain key.

Conflict-handling strategies

Some workflow systems resolve duplicate IDs with a fixed, pre-declared policy — typically a static choice between rejecting the new execution, deferring to the existing one, or terminating it. Workflow has no policy enum. hook.getConflict() hands the duplicate run the conflicting run's ID, and the policy is ordinary code — including policies that inspect state before deciding, which static configuration can't express. Retrieve a Run handle for the owner with getRun() inside a step:

import { getRun } from "workflow/api";

async function getOwnerStatus(runId: string) {
  "use step";
  return await getRun(runId).status;
}

async function getOwnerResult(runId: string) {
  "use step";
  return await getRun(runId).returnValue;
}

async function cancelOwner(runId: string) {
  "use step";
  await getRun(runId).cancel();
}

The example above implements reject the duplicate: return the owner's runId and let the caller decide. Other common strategies:

Adopt the owner's result. Wait for the active run to finish and return its result, so callers cannot tell which run did the work:

import { createHook } from "workflow";

type OrderRequest = { confirmed: boolean };
declare function getOwnerResult(runId: string): Promise<unknown>; // @setup
declare function processOwnedOrder(orderId: string): Promise<{ status: string }>; // @setup

export async function processOrder(orderId: string) {
  "use workflow";

  using request = createHook<OrderRequest>({
    token: `order:${orderId}`,
  });

  const conflict = await request.getConflict();
  if (conflict) {
    // Callers get the same result regardless of which run did the work.
    return await getOwnerResult(conflict.runId); 
  }

  return await processOwnedOrder(orderId);
}

Inspect the owner before deciding. Branch on the owner's live state:

import { createHook } from "workflow";

type OrderRequest = { confirmed: boolean };
declare function getOwnerStatus(runId: string): Promise<string>; // @setup
declare function processOwnedOrder(orderId: string): Promise<{ status: string }>; // @setup

export async function processOrder(orderId: string) {
  "use workflow";

  using request = createHook<OrderRequest>({
    token: `order:${orderId}`,
  });

  const conflict = await request.getConflict();
  if (conflict) {
    const status = await getOwnerStatus(conflict.runId); 
    if (status === "running") {
      return { status: "duplicate" as const, runId: conflict.runId };
    }
    // Owner already reached a terminal state; its hook will be released.
  }

  return await processOwnedOrder(orderId);
}

Signal the owner instead of doing the work. The duplicate run knows the token, so it can deliver this run's input to the owner's hook from a step:

import { createHook } from "workflow";
import { resumeHook } from "workflow/api";

type OrderRequest = { confirmed: boolean };

async function forwardToOwner(token: string, payload: OrderRequest) {
  "use step";
  await resumeHook(token, payload); 
}

export async function processOrder(orderId: string, confirmed: boolean) {
  "use workflow";

  const token = `order:${orderId}`;
  using request = createHook<OrderRequest>({ token });

  const conflict = await request.getConflict();
  if (conflict) {
    await forwardToOwner(token, { confirmed }); 
    return { status: "forwarded" as const, runId: conflict.runId };
  }

  // ... own the token and do the work
}

Supersede the owner. Newest-wins: cancel the active run, then claim the released token. Cancellation disposes the owner's hooks; the retry loop covers the window where that disposal has not propagated yet:

import { createHook } from "workflow";
import { getRun } from "workflow/api";

type OrderRequest = { confirmed: boolean };
declare function chargeOrder(orderId: string): Promise<void>; // @setup

async function cancelOwner(runId: string) {
  "use step";
  await getRun(runId).cancel();
}

export async function processOrderNewestWins(orderId: string) {
  "use workflow";

  const token = `order:${orderId}`;

  for (let attempt = 0; attempt < 3; attempt++) {
    using request = createHook<OrderRequest>({ token });

    const conflict = await request.getConflict();
    if (!conflict) {
      // Token claimed — this run is now the owner.
      const { confirmed } = await request;
      if (confirmed) {
        await chargeOrder(orderId);
      }
      return { status: "processed" as const };
    }

    await cancelOwner(conflict.runId); 
  }

  throw new Error(`Could not claim ${token} after cancelling the owner`);
}

If duplicate requests should only reuse the active run without sending data, use getHookByToken() as an advisory pre-check before calling start(). The workflow should still check hook.getConflict(), because the lookup and start() are not atomic.

Because this pattern uses hooks for idempotency, duplicate requests can also inject additional data and steer the existing run. The route example above uses resumeHook() for that: if the hook already exists, the duplicate request resumes the active workflow; if the hook is not registered yet, the route starts the workflow and retries resumeHook() so the payload is not dropped.

On this page

Edit this page on GitHub