PRO

Vercel Workflow Step Idempotency

Durable execution journals a step's RESULT, not its side effect. The documented step lifecycle runs step_created, step_started, step_completed, and a provider call lands somewhere between started and completed, so a crash in that window leaves a step with no recorded result and the runtime retries it: the guarantee is that the result is recorded once, never that the effect happened once. That gap is the double charge, and closing it is the caller's job. This reserves an idempotency key through a single-winner atomic claim (INSERT ON CONFLICT DO NOTHING, a conditional put, SET NX, or a unique index all qualify; a read-then-write in application code does not, and its gap is exactly wide enough for the second charge), then handles the case most implementations get wrong: a retry that finds a reservation with no receipt cannot distinguish an attempt that died before charging from one that charged and died before writing the receipt, so it asks the provider what it holds under that key instead of guessing, since guessing is wrong half the time in whichever direction it picks. A lease deadline separates a concurrent second attempt still alive on a stalled network call, where the loser must refuse and be retried later, from a genuinely dead one where recovery is meaningful. Pure logic, no SDK imports, with a demo that runs under bun. Pinned to [email protected].

Install
npx shadcn@latest add https://ui.aryank.space/r/vercel-workflow-step-idempotency.json

Installs from ui.aryank.space. To add it by hand, copy the files in Files below, or register the @compronents namespace via the docs.

Visualization
reservation
attempt 1
ChargeIntent
charged cents
0
replay
await step(async () => provider.charge(card, 4999)) // journals the result, not the effect

The step charges, then the process dies before the result is journaled. The runtime sees a step that started and never completed, so it retries it, and the retry has no way to know a charge already landed. Two charges, one invoice. The journal did its job perfectly: it records results, and there was never a result to record.

Files

Dependencies