{"slug":"deduplicate-workflow-runs-with-stable-ids","url":"https://tako.sh/blog/deduplicate-workflow-runs-with-stable-ids/","canonical":"https://tako.sh/blog/deduplicate-workflow-runs-with-stable-ids/","title":"How to deduplicate workflow runs with stable IDs","date":"2026-09-30T15:30","description":"Use stable run IDs in Tako to deduplicate webhook retries, checkout requests, monthly payouts, and reports. Learn how conflicts and retention work.","author":null,"image":"61f30323ca01","imageAlt":"An octopus sorts identical envelopes into one outgoing parcel.","headings":[{"depth":2,"slug":"one-business-operation-one-run","text":"One business operation, one run"},{"depth":2,"slug":"choose-ids-that-match-your-use-case","text":"Choose IDs that match your use case"},{"depth":2,"slug":"keep-the-history-and-the-side-effects-in-mind","text":"Keep the history and the side effects in mind"}],"markdown":"A webhook arrives twice. Someone double-clicks checkout. Your app queues a payout, but the connection drops before the caller receives the response. The caller retries. Should that create another job?\n\nTako lets you give a workflow run an ID derived from the operation it represents:\n\n```ts\nawait fulfillOrder.enqueue({ orderId: \"ord_123\" }, { id: \"fulfill-order:ord_123\" });\n```\n\nRepeat that call with the same workflow and payload, and Tako returns the existing run ID. That works while the job is waiting, while it is running, and after it finishes, as long as its history remains in storage.\n\nThe useful question is what the ID should mean. Let's choose a few.\n\n## One business operation, one run\n\nIf you're new to [Tako workflows](/docs/workflows/), each workflow is a named handler in `<app_root>/workflows/`. The default app root is `src`. Its default export gives server-side code a typed `.enqueue()` method. We'll use that method throughout this post.\n\nThe second argument accepts `id`. This is the actual run ID, so the value you send is also the string `.enqueue()` returns:\n\n```ts\nimport fulfillOrder from \"./workflows/fulfill-order\";\n\nconst payload = { orderId: \"ord_123\" };\nconst options = { id: \"fulfill-order:ord_123\" };\n\nconst first = await fulfillOrder.enqueue(payload, options);\nconst second = await fulfillOrder.enqueue(payload, options);\n\n// Both strings are \"fulfill-order:ord_123\".\n```\n\nTako stores the run before acknowledging enqueue. Concurrent callers supplying this ID create one run. If a response gets lost, the caller can repeat the same request without creating another run.\n\n| Enqueue request                            | Result                              |\n| ------------------------------------------ | ----------------------------------- |\n| No `id`                                    | Generate an ID and create a new run |\n| New `id`                                   | Create a run with that exact ID     |\n| Retained ID, same workflow and payload     | Return the existing ID              |\n| Retained ID, different workflow or payload | Reject the enqueue with a conflict  |\n\nIDs are unique across workflow names within an app and environment. Include the operation name, such as `fulfill-order:`, so fulfillment and refunds don't accidentally compete for the same order ID. IDs must contain 1–255 bytes and no NUL characters.\n\nReusing a run preserves its original schedule, retry budget, status, and completed steps. Passing a new `runAt` or `retries` value alongside the existing ID does not edit the run. A successful `.enqueue()` means you have a run ID; it does not mean the work has finished.\n\n```d2\ndirection: right\n\nrequest: \"Repeated enqueue calls\\nsame ID and payload\"\nstore: \"Workflow storage\" {shape: cylinder}\nrun: \"One retained run\"\n\nrequest -> store: \"insert if ID is new\"\nstore -> run: \"create once or reuse\"\n```\n\n## Choose IDs that match your use case\n\nA random UUID generated for every HTTP attempt defeats deduplication. Derive the ID from something that survives retries, or persist a generated operation ID before making the first call. The same intended work needs the same ID; different intended work needs a different one.\n\n| Use case                          | Example ID                          | Meaning                              |\n| --------------------------------- | ----------------------------------- | ------------------------------------ |\n| Process a webhook event           | `process-event:evt_456`             | One run per event                    |\n| Fulfill an order                  | `fulfill-order:ord_123`             | One fulfillment run per order        |\n| Pay a vendor for a billing period | `vendor-payout:vendor_42:2026-09`   | One payout run per vendor and period |\n| Generate a report for a revision  | `sales-report:team_7:2026-09:rev_3` | One report run for those inputs      |\n| Send an onboarding email          | `welcome:user_89`                   | One welcome-email run per user       |\n\nFor webhook delivery retries, use the source event's stable identifier. Verify the webhook and select the event you want to process in your request handler, then enqueue from that server-side code:\n\n```ts\nimport processEvent from \"./workflows/process-event\";\n\nawait processEvent.enqueue({ eventId: event.id }, { id: `process-event:${event.id}` });\n```\n\nTwo deliveries of one event reuse its run. Two distinct events about one order get separate runs. If your business rule is instead \"fulfill this order once,\" enqueue the fulfillment workflow with the order ID, as in the opening example. Pick the identity of the work rather than the identity of the delivery.\n\nCheckout has the same distinction. Two HTTP requests can refer to one persisted order, so both should derive the fulfillment ID from that order. Generating a request ID inside each handler creates two runs. If your app creates the order during checkout, make order creation idempotent too, so a repeated submission resolves to the same order before enqueue.\n\nKeep the payload stable too. Adding `receivedAt: Date.now()` means a retry sends a different payload and conflicts. Store delivery diagnostics separately. JSON object field order does not affect the comparison, but changed field values do.\n\nMonthly payouts need a period as well as a recipient. Here is a complete workflow definition. `payments` is your application's payment adapter, and its transfer method should pass the idempotency key through to your provider:\n\n```ts\n// src/workflows/vendor-payout.ts\nimport { defineWorkflow } from \"tako.sh\";\nimport { payments } from \"../payments\";\n\ntype Payout = {\n  vendorId: string;\n  period: string;\n  amountMinor: number;\n  currency: string;\n};\n\nexport default defineWorkflow<Payout>(\"vendor-payout\", {\n  retries: 4,\n  handler: async (payload, ctx) => {\n    await ctx.run(\"transfer\", () =>\n      payments.transfer({\n        recipientId: payload.vendorId,\n        amountMinor: payload.amountMinor,\n        currency: payload.currency,\n        idempotencyKey: `${ctx.runId}:transfer`,\n      }),\n    );\n  },\n});\n```\n\nEnqueue the approved payout from your billing code:\n\n```ts\nimport vendorPayout from \"./workflows/vendor-payout\";\n\nawait vendorPayout.enqueue(\n  {\n    vendorId: \"vendor_42\",\n    period: \"2026-09\",\n    amountMinor: 50000,\n    currency: \"JPY\",\n  },\n  { id: \"vendor-payout:vendor_42:2026-09\" },\n);\n```\n\nGet the period and amount from the approved billing record. Recomputing \"this month\" during each retry can turn a request sent across midnight into two different operations. Agree on your billing timezone and store the period explicitly. A daily digest uses the same approach with a date instead of a month.\n\nIf the amount changes from 50000 to 60000 under that same retained ID, enqueue conflicts. Tako does not replace the queued payment. Resolve the changed obligation in your billing records before deciding whether a separate adjustment is needed.\n\nReports have a different rule. You may want to generate the September report again after correcting its source data. Include the revision in both the ID and payload. Retrying revision 3 reuses revision 3; requesting revision 4 creates another run. Each caller should derive the revision from the same stored report request.\n\nFor onboarding, a user ID usually identifies the welcome-email operation. A later product announcement is separate work, so give it its own operation prefix and campaign ID. That lets you suppress repeated welcome requests while still sending intentional future messages to the same user.\n\nFor scheduled work, see our [TypeScript cron walkthrough](/blog/self-hosted-cron-jobs-in-typescript-without-redis/). Cron triggers and business operations can have different identities, especially when one scheduled run queues work for many recipients.\n\n## Keep the history and the side effects in mind\n\nDeduplication lasts while Tako retains the run. A repeated enqueue of a `succeeded`, `cancelled`, or `dead` run returns its existing ID without restarting it. If a failed operation needs another attempt, enqueue with a new ID only when you intend to create separate work, after checking what the previous run already did.\n\nFinished runs and their saved steps become eligible for cleanup after 184 days by default. That covers six calendar months, measured from completion. Cleanup happens in bounded batches while the workflow runtime is active. Pending and running runs are never pruned.\n\nYou can change this in [`tako.toml`](/docs/tako-toml/#workflows):\n\n```toml\n[workflows]\nretention = \"forever\"\n```\n\nUse `\"forever\"` when you want to retain the deduplication record indefinitely. Once cleanup removes a finished run, its ID is available again, and a later enqueue starts fresh without the old checkpoints. Business rules that must outlive workflow history also need durable application records, such as a payout ledger or a unique fulfilled-order record.\n\nRun deduplication protects enqueue. Inside a run, `ctx.run()` saves completed step results, but a worker can stop after a payment succeeds and before the checkpoint reaches storage. The next attempt can execute that step again. This is why the payout example uses `${ctx.runId}:transfer` as a provider idempotency key. Your adapter must implement that protection; the option belongs to the payment integration.\n\nStorage scope matters too. Global workflows across multiple servers use shared Postgres state. Workflows marked `local: true` have separate queues on each server, so their IDs do not deduplicate across servers. The [deployment docs](/docs/deployment/) explain environment setup, and the [workflow storage guide](/docs/workflows/#storage-and-multiple-servers) covers that choice.\n\nTry one workflow through [`tako dev`](/docs/development/). Enqueue identical work twice, check that both calls return your ID, then change the payload and observe the conflict. Start with a business identifier you already have. Add the period or revision when the operation calls for it, keep retries consistent, and let Tako reuse the retained run."}