Multi-step, with a rollback plan.
Some jobs are really a sequence: reserve the stock, charge the card, send the confirmation. The workflow engine runs that sequence, retries the flaky parts, resumes after a crash, and when a later step fails it undoes the earlier ones for you.
The problem it solves
Section titled “The problem it solves”Write that sequence as plain code and three things go wrong the first time it breaks in production:
- The process dies halfway. The card was charged, the confirmation never went out, and nothing remembers where it got to.
- A later step fails after an earlier one already changed the world. The stock is reserved for an order that will never exist.
- A retry runs the effect twice. Two charges, one order.
A workflow engine exists to make those three cases boring.
When send confirmation fails, the engine walks back: refund, then release stock. That automatic undo is the saga pattern, and each step declares its own inverse with a compensate handler.
The mental model
Section titled “The mental model”Three ideas carry everything else:
A workflow is a list of nodes. Steps, branches, loops, approval gates. You describe them; the engine walks them.
Each top-level node gets durable queue delivery. Finishing one writes its outcome to SQLite and enqueues the next. Inline branch, parallel and loop body steps share that node job but persist their own records. On restart, completed records short-circuit; only work whose outcome is still unknown may replay.
Failure walks the journal backwards. The engine already knows which steps completed and in what order, so it can undo them in reverse without asking your code to remember anything.
import { Workflow, Engine } from 'bunqueue/workflow';
const flow = new Workflow('checkout') .step('reserve', reserveStock, { compensate: releaseStock }) .step('charge', chargeCard, { compensate: refund }) .step('confirm', sendEmail);
const engine = new Engine({ embedded: true, dataPath: './data/wf.db' });engine.register(flow);await engine.recover();await engine.start('checkout', { orderId: 'ORD-1' });Everything runs in your process, on bunqueue’s Queue and Worker, persisted to SQLite. No extra services, no YAML, no control plane.
For a production service, four details are part of the setup rather than optional tuning:
- Pass a durable
dataPath; omitting it creates an in-memory execution store. - Register every definition, then call
recover()during startup. - Make externally visible steps idempotent and pass
ctx.idempotencyKeyto providers that support one. - Run one workflow
Engineper process and callengine.close()during shutdown.
The engine guarantees durable orchestration state, not exactly-once effects in another system. If a process dies after an API accepted a charge but before the completed record reached SQLite, the only safe recovery is to replay the call with the same provider idempotency key.
Where to go next
Section titled “Where to go next”| Quick Start | Build and run your first workflow |
| Steps & Control Flow | Context, retries, branching, parallel, loops |
| Rollback | Compensation, unwind order, the point of no return |
| Durability | Idempotency keys, crash recovery, what resumes |
| Human Approval | Pausing a run until a person decides |
| AI Agents | Durable agent loops with the Vercel AI SDK |
| API Reference | Engine methods, events, execution shape, limits |
When not to use it
Section titled “When not to use it”| Situation | Use instead |
|---|---|
| Independent jobs with no ordering | Queue + Worker |
| Parent/child fan-out without rollback | Flow Producer, lighter |
| One queue, one processor, a few routes | Simple Mode |
| Multi-region HA, exactly-once across services | Temporal |