[KongchangAI]
· 2 min read· 1,214 words

Workflow SDK 5.3.1 Atomic start Enables Idempotent Launch

Workflow SDK 5.3.1 Atomic start Enables Idempotent Launch

Workflow SDK 5.3.1 makes start() atomically claim a hook, using tokens as idempotency keys to eliminate duplicate-launch races.

Workflow SDK 5.3.1 brings a key update to start(): it can now admit a run and claim a hook in a single atomic operation. The previous "getHookByToken() then start()" pattern had a time gap between query and start, potentially causing duplicate-launch race conditions. In the new approach, if the token is unclaimed the run is admitted and immediately owns the hook; if already claimed, a HookConflictError is thrown carrying conflictingRunId. Developers can catch this error in an API route, use resumeHook to route the duplicate request's payload to the existing run, and return the same runId for idempotency. The token is the idempotency key and should come from business unique identifiers such as order IDs or invoice IDs; calling createHook with the same token inside the run binds to the claimed hook, adding no steps and returning null from getConflict(). Setting experimental_minRetention also keeps the token unavailable after the run ends, rejecting late duplicate requests.

The Workflow SDK's start() function has received a key update: it can now admit a run and claim a hook simultaneously in a single atomic step. This means duplicate workflow launches fail safely instead of triggering race conditions. Developers just need to upgrade to workflow 5.3.1 and consult the official idempotency docs to use it.

Workflow SDK idempotent launch update illustration

From getHookByToken to atomic start: eliminating the race window

Previously, implementing "check whether the hook exists, then decide whether to start the workflow" typically involved calling getHookByToken() and then start(). This pattern has a flaw explicitly noted in the docs: a time gap exists between the query and the start, which can lead to race conditions from duplicate launches. The new atomic start() merges "admit the run" and "claim the hook" into an indivisible operation, eliminating this window at its root.

The core logic of atomic start is: when calling start(), pass experimental_hook: { token }. If the token has not yet been claimed by any run, the run is admitted and the hook immediately belongs to it; if the token is already claimed, start() throws HookConflictError carrying the owner's conflictingRunId, and the duplicate launch request never becomes a new run.

Race Condition refers to a situation where multiple requests or threads concurrently access shared state, and the final result depends on the order in which they execute, potentially producing unexpected outcomes. In this article's scenario, two duplicate requests arriving almost simultaneously might both query getHookByToken() and find the hook unclaimed, then both call start(), causing the same business action to be launched twice. Such problems are hard to reproduce but can genuinely occur under traffic spikes or retry mechanisms. Atomic Operation refers to an uninterruptible sequence of operations that either completes entirely or has no effect at all, with other concurrent requests unable to observe intermediate states. After merging "admit the run" and "claim the hook" into one atomic operation, only one of two concurrent requests can successfully claim the token while the other necessarily fails, structurally eliminating the race window rather than relying on the developer's checking order.

Code pattern: routing duplicate requests to an existing run

The official example shows how to use this feature in an API route. The following code lives in app/api/orders/route.ts:

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

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

  try {
    const run = await start(processOrder, [orderId, note], {
      experimental_hook: { token },
    });
    return Response.json({ runId: run.runId, started: true });
  } catch (error) {
    if (!HookConflictError.is(error)) throw error;
    // Route the payload of the duplicate request to the run that already owns the token
    const hook = await resumeHook(token, { note });
    return Response.json({ runId: hook.runId, started: false });
  }
}

This code clearly expresses the intent of idempotent launch: the first request successfully starts the run; a duplicate request catches HookConflictError and then uses resumeHook to send the carried data to the existing run, returning the same runId (with started marked as false). The whole process requires no extra distributed locks or database unique constraints.

Idempotency means that performing the same operation once versus multiple times has the same effect on system state. In HTTP APIs and distributed systems, client timeout retries, network jitter, and users clicking repeatedly all cause the same request to be delivered multiple times; if the endpoint is not idempotent, it can lead to duplicate orders or duplicate charges. Common implementation approaches include database unique constraints, distributed locks, and idempotency key tables, but all require extra storage and coordination logic. The approach in this article uses a business unique identifier (such as order:${orderId}) as the token, with the SDK internally guaranteeing that "exactly one run owns the token"; duplicate requests are explicitly rejected and routed to the existing run. This is effectively building the idempotency key mechanism into the workflow launch step, so developers no longer need to maintain their own deduplication tables or locks.

In-run subscription: createHook binds to the claimed hook

The claimed hook exists from the moment the run is admitted, so resumeHook can work even before the run starts executing, with early payloads buffered and delivered in order. Inside the workflow, calling createHook({ token }) with the same token binds to the claimed hook—this is guaranteed: no new hook is registered, no extra steps are added, and getConflict() returns null.

The workflows/process-order.ts in the example demonstrates the subscription pattern:

import { createHook } from "workflow";

type OrderUpdate = { note: string };

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

  await recordNote(orderId, note);

  // Binds to the hook claimed at start: no conflict, no extra steps generated
  const updates = createHook<OrderUpdate>({
    token: `order:${orderId}`,
  });
  for await (const update of updates) {
    await recordNote(orderId, update.note);
  }
}

This pattern lets the workflow consume payloads in order without worrying that the hook has not yet been created or has already been claimed.

Hook here refers to a suspension point in a workflow used to receive external events or data: the workflow can pause waiting for a hook to be triggered, while external systems deliver payloads to that hook via the token. In the traditional model, hooks are created inside the workflow by createHook, and external systems must first know it exists before delivering; this creates a timing problem of "delivery before creation." Atomic start changes this model: the hook is created and owned by the run the moment the run is admitted, and externally delivered data is buffered and queued in arrival order until the workflow reaches the subscription point and consumes it. This "hook first, run body later" ordering ensures early payloads are not lost and makes createHook({ token }) a pure binding operation rather than a creation operation.

Token as idempotency key: a unique identifier from the business domain

The official guidance emphasizes that the token is the idempotency key and should come from your own business domain—for example, an order ID, invoice ID, import ID, or request ID. The token alone guarantees idempotency: exactly one run owns it, and a duplicate start() throws HookConflictError carrying the owner's conflictingRunId, never becoming a new run. The workflow does not even have to open this hook; if it never opens it, the token serves only as an idempotency key and the run completes normally.

This design shifts the responsibility for idempotency from external storage into the Workflow SDK, reducing the boilerplate developers need to write. For common scenarios such as order processing, payment callbacks, and data imports, developers can directly use business unique identifiers as tokens, eliminating extra deduplication logic.

Retention policy and upgrade recommendations

Setting experimental_minRetention keeps the token unavailable after the run ends (counted from start()), so late duplicate requests are still rejected. This is very useful for scenarios that need to prevent duplicate submissions within a certain time window.

To use these features, upgrade to workflow 5.3.1 and read the official idempotency documentation. The introduction of atomic start marks a pragmatic step forward for the Workflow SDK in simplifying idempotency for distributed workflows: leave race conditions to the SDK and business logic to the developer.

#Workflow SDK#idempotent launch#atomic start#HookConflictError#hook token#race condition#distributed workflow
Share:

Related articles