How to Build Durable Workflows for Bun and Node.js Apps
Bun and Node.js apps are great at serving requests. They are less great at remembering what happened after the request is gone.
That 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.
Tako workflows 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.
Start with a real workflow file
A 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.
Here is a checkout workflow with step checkpoints, retry policy, and a human-review pause:
// src/workflows/fulfill-order.ts
import { defineWorkflow } from "tako.sh";
import { signalFraudTeam } from "../fraud";
import { db } from "../db";
import { mailer } from "../mailer";
import { payments } from "../payments";
import { shipping } from "../shipping";
type Payload = {
orderId: string;
};
export default defineWorkflow<Payload>("fulfill-order", {
retries: 4,
backoff: { base: 5_000, max: 10 * 60_000 },
handler: async (payload, ctx) => {
const order = await ctx.run("load-order", () => db.orders.find(payload.orderId));
const charge = await ctx.run(
"charge-card",
() =>
payments.charge({
amount: order.total,
token: order.paymentToken,
idempotencyKey: `charge:${order.id}`,
}),
{ retries: 2, backoff: { base: 1_000, max: 30_000 } },
);
if (order.total > 50_000) {
await ctx.run("notify-fraud-team", () => signalFraudTeam(order.id));
const decision = await ctx.waitFor<{ approved: boolean; by: string }>(
`fraud-review:${order.id}`,
{ timeout: 3 * 24 * 60 * 60 * 1000 },
);
if (decision === null) ctx.bail("fraud review timed out");
if (!decision.approved) ctx.bail(`fraud review rejected by ${decision.by}`);
}
await ctx.run("buy-label", () =>
shipping.createLabel({
orderId: order.id,
address: order.shippingAddress,
idempotencyKey: `label:${order.id}`,
}),
);
await ctx.run("send-receipt", () =>
mailer.sendReceipt(order.email, {
orderId: order.id,
chargeId: charge.id,
}),
);
},
});
The 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.
That 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.
Enqueue from a Bun or Node handler
The 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.
For a plain fetch app:
// src/index.ts
import fulfillOrder from "./workflows/fulfill-order";
export default {
async fetch(req: Request) {
if (req.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const order = await req.json();
const runId = await fulfillOrder.enqueue(
{ orderId: order.id },
{ uniqueKey: `fulfill:${order.id}` },
);
return Response.json({ ok: true, runId });
},
};
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.
The fraud review signal can come from another handler:
// src/admin-review.ts
import { signal } from "tako.sh";
export async function approveFraudReview(orderId: string, approverId: string) {
await signal(`fraud-review:${orderId}`, {
approved: true,
by: approverId,
});
}
When 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.
The same shape works for delayed work:
await ctx.sleep("cooldown-before-reminder", 24 * 60 * 60 * 1000);
Short sleeps run inline. Longer sleeps defer the run until the wake time, so a one-day wait costs a row, not a process.
Run it locally, then deploy it
The local loop is deliberately boring:
tako init
tako dev
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 cover the local .test routes and daemon behavior.
For production, the workflow ships with the rest of the app:
tako deploy
Tako 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 and the command details are in the CLI reference.
Most 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.
When you want worker lanes, add named groups:
[workflows]
workers = 0
concurrency = 10
[workflows.email]
workers = 1
concurrency = 20
[workflows.fulfillment]
workers = 0
concurrency = 4
Then assign a workflow:
export default defineWorkflow<Payload>("fulfill-order", {
worker: "fulfillment",
retries: 4,
handler: async (payload, ctx) => {
// ...
},
});
This 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.
That 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.
What to use workflows for
Use a workflow when the work has state you care about after the request ends.
| Need | Plain async code | Tako workflow |
|---|---|---|
| Retry a flaky API | Catch and loop in memory | Run-level and step-level retries |
| Survive deploys | Hope the process finishes | Completed steps are checkpointed |
| Avoid duplicate starts | Hand-roll a DB lock | uniqueKey on enqueue |
| Wait for days | Poll or split the job | ctx.sleep or ctx.waitFor |
| Separate heavy work | Add another process manager | Named worker groups in tako.toml |
| Run scheduled jobs | Cron plus queue glue | schedule on defineWorkflow |
For 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.
The 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, logs through the same server, and runs on the same VPS you already picked for HTTP traffic.
Start 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.