{"slug":"how-to-build-durable-workflows-for-bun-and-nodejs-apps","url":"https://tako.sh/blog/how-to-build-durable-workflows-for-bun-and-nodejs-apps/","canonical":"https://tako.sh/blog/how-to-build-durable-workflows-for-bun-and-nodejs-apps/","title":"How to Build Durable Workflows for Bun and Node.js Apps","date":"2026-06-11T01:37","description":"Build durable Bun and Node.js workflows with Tako: typed enqueue, retries, step checkpoints, waits, signals, cron, and scale-to-zero workers.","author":null,"image":"f04f3eee79d4","imageAlt":null,"headings":[{"depth":2,"slug":"start-with-a-real-workflow-file","text":"Start with a real workflow file"},{"depth":2,"slug":"enqueue-from-a-bun-or-node-handler","text":"Enqueue from a Bun or Node handler"},{"depth":2,"slug":"run-it-locally-then-deploy-it","text":"Run it locally, then deploy it"},{"depth":2,"slug":"what-to-use-workflows-for","text":"What to use workflows for"}],"markdown":"Bun and Node.js apps are great at serving requests. They are less great at remembering what happened after the request is gone.\n\nThat becomes a problem as soon as one button click turns into five pieces of work: charge the card, write the order, call a fulfillment API, wait for fraud review, send email, retry the flaky webhook, and do none of it twice. A plain `async function` is easy to write, but if the process restarts halfway through, the function forgets everything.\n\n[Tako workflows](/blog/durable-workflows-are-here/) let a Bun or Node app keep that code-shaped workflow while moving progress into durable app infrastructure. You write TypeScript in `src/workflows/`, enqueue from a route handler, and Tako stores runs, completed steps, retries, sleeps, and signals beside the deployed app. No Redis queue, no separate worker platform, no second deploy path.\n\n## Start with a real workflow file\n\nA workflow is a default export from `<app_root>/workflows/<name>.ts`. The default `app_root` is `src`, and the full config surface lives in the [`tako.toml` reference](/docs/tako-toml/).\n\nHere is a checkout workflow with step checkpoints, retry policy, and a human-review pause:\n\n```ts\n// src/workflows/fulfill-order.ts\nimport { defineWorkflow } from \"tako.sh\";\nimport { signalFraudTeam } from \"../fraud\";\nimport { db } from \"../db\";\nimport { mailer } from \"../mailer\";\nimport { payments } from \"../payments\";\nimport { shipping } from \"../shipping\";\n\ntype Payload = {\n  orderId: string;\n};\n\nexport default defineWorkflow<Payload>(\"fulfill-order\", {\n  retries: 4,\n  backoff: { base: 5_000, max: 10 * 60_000 },\n  handler: async (payload, ctx) => {\n    const order = await ctx.run(\"load-order\", () => db.orders.find(payload.orderId));\n\n    const charge = await ctx.run(\n      \"charge-card\",\n      () =>\n        payments.charge({\n          amount: order.total,\n          token: order.paymentToken,\n          idempotencyKey: `charge:${order.id}`,\n        }),\n      { retries: 2, backoff: { base: 1_000, max: 30_000 } },\n    );\n\n    if (order.total > 50_000) {\n      await ctx.run(\"notify-fraud-team\", () => signalFraudTeam(order.id));\n\n      const decision = await ctx.waitFor<{ approved: boolean; by: string }>(\n        `fraud-review:${order.id}`,\n        { timeout: 3 * 24 * 60 * 60 * 1000 },\n      );\n\n      if (decision === null) ctx.bail(\"fraud review timed out\");\n      if (!decision.approved) ctx.bail(`fraud review rejected by ${decision.by}`);\n    }\n\n    await ctx.run(\"buy-label\", () =>\n      shipping.createLabel({\n        orderId: order.id,\n        address: order.shippingAddress,\n        idempotencyKey: `label:${order.id}`,\n      }),\n    );\n\n    await ctx.run(\"send-receipt\", () =>\n      mailer.sendReceipt(order.email, {\n        orderId: order.id,\n        chargeId: charge.id,\n      }),\n    );\n  },\n});\n```\n\nThe important habit is putting every side effect behind `ctx.run(\"stable-name\", fn)`. Tako stores the returned value for each completed step. If the worker restarts after `charge-card`, the next attempt returns the saved charge result and resumes at the first unfinished step instead of charging again.\n\nThat does not remove idempotency from your app. Workflows are still at-least-once: if the process dies after a side effect succeeds but before the checkpoint RPC completes, that side effect can run again. Use provider idempotency keys, upserts, and stable business identifiers. The difference is that you write the durable progress model once, in the workflow, instead of hand-rolling progress rows around every background job.\n\n## Enqueue from a Bun or Node handler\n\nThe workflow's default export is also the typed enqueue handle. You import it anywhere server-side code runs under Tako: a fetch handler, Hono route, Next.js route, TanStack Start server function, webhook handler, admin action, or another workflow.\n\nFor a plain fetch app:\n\n```ts\n// src/index.ts\nimport fulfillOrder from \"./workflows/fulfill-order\";\n\nexport default {\n  async fetch(req: Request) {\n    if (req.method !== \"POST\") {\n      return new Response(\"Method not allowed\", { status: 405 });\n    }\n\n    const order = await req.json();\n\n    const runId = await fulfillOrder.enqueue(\n      { orderId: order.id },\n      { uniqueKey: `fulfill:${order.id}` },\n    );\n\n    return Response.json({ ok: true, runId });\n  },\n};\n```\n\n`uniqueKey` is the small line that saves you from duplicate POSTs, webhook retries, and impatient double-clicks. If a non-terminal run already has that key, enqueue returns the existing run id instead of inserting another run.\n\nThe fraud review signal can come from another handler:\n\n```ts\n// src/admin-review.ts\nimport { signal } from \"tako.sh\";\n\nexport async function approveFraudReview(orderId: string, approverId: string) {\n  await signal(`fraud-review:${orderId}`, {\n    approved: true,\n    by: approverId,\n  });\n}\n```\n\nWhen the workflow reaches `ctx.waitFor`, the worker does not keep a timer open for three days. Tako parks the run in durable storage, indexes the event name, and lets the worker exit. When `signal()` arrives, the run becomes runnable again and a worker resumes after the wait. The longer walkthrough is in [Pause a Workflow Until a Human Clicks Approve](/blog/pause-a-workflow-until-a-human-clicks-approve/).\n\nThe same shape works for delayed work:\n\n```ts\nawait ctx.sleep(\"cooldown-before-reminder\", 24 * 60 * 60 * 1000);\n```\n\nShort sleeps run inline. Longer sleeps defer the run until the wake time, so a one-day wait costs a row, not a process.\n\n## Run it locally, then deploy it\n\nThe local loop is deliberately boring:\n\n```bash\ntako init\ntako dev\n```\n\n`tako init` detects the JavaScript runtime and writes the project config. `tako dev` starts the local HTTPS proxy, your HTTP app, and workflow workers using the same environment contract as production. The [development docs](/docs/development/) cover the local `.test` routes and daemon behavior.\n\nFor production, the workflow ships with the rest of the app:\n\n```bash\ntako deploy\n```\n\nTako discovers `src/workflows/*.ts`, deploys the release, stores workflow state per app, and supervises workers next to HTTP instances. The deployment flow is documented in [Deployment](/docs/deployment/) and the command details are in the [CLI reference](/docs/cli/).\n\nMost apps can start with the default workflow config. If a workflow directory exists and you do not configure workers, Tako treats the app as scale-to-zero: no worker process is kept running until enqueue, signal, cron, delayed retry, sleep wakeup, or lease reclaim makes work runnable.\n\nWhen you want worker lanes, add named groups:\n\n```toml\n[workflows]\nworkers = 0\nconcurrency = 10\n\n[workflows.email]\nworkers = 1\nconcurrency = 20\n\n[workflows.fulfillment]\nworkers = 0\nconcurrency = 4\n```\n\nThen assign a workflow:\n\n```ts\nexport default defineWorkflow<Payload>(\"fulfill-order\", {\n  worker: \"fulfillment\",\n  retries: 4,\n  handler: async (payload, ctx) => {\n    // ...\n  },\n});\n```\n\nThis gives latency-sensitive email a warm worker while fulfillment stays scale-to-zero until it has work. The worker lifecycle details are in [Workflow Workers That Scale to Zero](/blog/workflow-workers-scale-to-zero/).\n\n```d2\ndirection: right\n\nroute: \"Bun / Node handler\" {style.fill: \"#9BC4B6\"; style.font-size: 14}\nsocket: \"Tako internal socket\" {style.fill: \"#FFF9F4\"; style.stroke: \"#2F2A44\"; style.font-size: 14}\nserver: \"tako-server\" {style.fill: \"#E88783\"; style.font-size: 14}\ndb: \"workflow storage\" {shape: cylinder; style.fill: \"#FFF9F4\"; style.stroke: \"#2F2A44\"; style.font-size: 14}\nworker: \"workflow worker\" {style.fill: \"#E88783\"; style.font-size: 14}\n\nroute -> socket: \".enqueue() / signal()\"\nsocket -> server: \"RPC\"\nserver -> db: \"runs, steps, waits\"\nserver -> worker: \"spawn or wake\"\nworker -> server: \"claim / save / complete\"\n```\n\nThat division is the reason the SDK stays simple. Your app imports `defineWorkflow`, `.enqueue()`, and `signal()`. Tako owns the queue database, cron ticker, worker supervision, retries, and recovery.\n\n## What to use workflows for\n\nUse a workflow when the work has state you care about after the request ends.\n\n| Need                   | Plain async code            | Tako workflow                      |\n| ---------------------- | --------------------------- | ---------------------------------- |\n| Retry a flaky API      | Catch and loop in memory    | Run-level and step-level retries   |\n| Survive deploys        | Hope the process finishes   | Completed steps are checkpointed   |\n| Avoid duplicate starts | Hand-roll a DB lock         | `uniqueKey` on enqueue             |\n| Wait for days          | Poll or split the job       | `ctx.sleep` or `ctx.waitFor`       |\n| Separate heavy work    | Add another process manager | Named worker groups in `tako.toml` |\n| Run scheduled jobs     | Cron plus queue glue        | `schedule` on `defineWorkflow`     |\n\nFor simple fire-and-forget work, a direct `await` is fine. For a one-line nightly task, cron might still be enough. But once the job needs retries, checkpoints, human approval, webhook callbacks, or a clean deploy story, durable workflows are the better primitive.\n\nThe nice part is that the Bun or Node code still looks like code. A checkout workflow is a TypeScript file, not a YAML state machine. It deploys with the app, reads the same [secrets](/blog/secrets-without-env-files/), logs through the same server, and runs on the same VPS you already picked for HTTP traffic.\n\nStart with one workflow. Put every side effect behind `ctx.run`. Add `uniqueKey` anywhere an enqueue might repeat. Use `ctx.waitFor` when the outside world needs to answer. Then let Tako remember where the work left off."}