Overview
How remote steps travel to workers. Transports are config-driven drivers selected by name in config/durable.ts — the in-process memory and eventEmitter drivers, the queue driver over @adonisjs/queue, a broker-less SQL driver on the database you already run, and the BullMQ driver shared with NestJS and Python workers.
Comparing the drivers
For a capability-by-capability matrix of every driver, its limitations and which one to pick, see Choosing a transport.
A transport is how a dispatched ctx.step reaches a step handler and how the result comes back. It's a pluggable driver (dispatch, onResult, onHeartbeat), independent of the state store — mix any transport with any store.
Transports are config-driven drivers: list the ones you use under transports in config/durable.ts, built with the transports factory, and pick the active one by name with transport. Each driver lazily imports its optional peer (@adonisjs/queue, @adonisjs/lucid, bullmq + ioredis) only when selected, so nothing extra is loaded for the drivers you don't use. Only the selected entry is built: a process runs exactly one transport.
The drivers
| Driver | Infrastructure | Crosses a process boundary? | Use it for |
|---|---|---|---|
transports.memory() | none | No (in-process) | Tests. Drives a dispatch straight into the handler, so a test is deterministic with no polling and no timers. |
transports.eventEmitter() | none | No (in-process) | Single-process production. Handlers run in this process over a Node EventEmitter, but dispatch → worker → result is decoupled across the event loop, the way a real broker behaves. |
transports.queue(...) | whatever config/queue.ts uses | Yes | True cross-process steps over @adonisjs/queue — a separate worker fleet, on Redis or a database adapter. Also carries remote workflow turns and store-less tenant pods. |
transports.db(...) | the database you already run | Yes (shared DB) | Broker-less, DBOS-style: steps are rows in your own database, via @adonisjs/lucid. No new infrastructure at all. Also carries remote workflow turns and tenant pods that share the database. |
transports.bullmq(...) | Redis + the bullmq package | Yes | Cross-ecosystem: byte-compatible with the NestJS nestjs-durable BullMQ transport and its Python worker, so an AdonisJS engine and a Python or NestJS worker interoperate on one Redis. See Python interop. |
memory is for tests; eventEmitter is the single-process production driver
The two look interchangeable and are not. transports.memory() runs the handler inside the dispatch call (the engine's dispatch awaits it, and throws if no result handler is registered yet), which is exactly what makes a test deterministic — and a poor model of production, where a dispatch returns before any worker runs. It also drops heartbeats. transports.eventEmitter() returns from dispatch at once, runs the handler concurrently, delivers heartbeats, and retries a result whose settling failed, so a workflow that behaves correctly on it behaves correctly on a broker too. Reach for eventEmitter for any single-process app you actually deploy.
Selecting a transport in config
transport names a key of the transports map; that driver is what the engine dispatches every remote step over. Omit transport (or pick memory) for the in-process default.
import { defineConfig, transports } from '@adonis-agora/durable'
export default defineConfig({
transport: 'queue',
transports: {
memory: transports.memory(),
// `connection` names an adapter from config/queue.ts; `db` uses a Lucid connection.
queue: transports.queue({ connection: 'redis' }),
db: transports.db(),
},
})Switching transports is a one-line change to transport — the workflow code never changes.
Single-process production — eventEmitter
transports.eventEmitter() runs real durable workflows with nothing else to deploy: no broker, no Redis, no extra database. Step handlers under app/steps are served in this same process, and a dispatch travels to them over a Node EventEmitter:
export default defineConfig({
transport: 'event-emitter',
transports: {
'event-emitter': transports.eventEmitter(),
},
store: 'lucid',
stores: { lucid: stores.lucid() },
})| Option | Default | Description |
|---|---|---|
group | — | Accepted for parity with the broker drivers. Handlers match by step name in-process, so it does not affect routing. |
instanceId | random | Stable id for this process, stamped on control messages. |
| (namespace) | the engine's | Not a factory option: the engine pushes its namespace onto the transport, which folds it into its internal channel names. |
onError | console.error | Where a failing result or heartbeat handler is reported. A failed result is redelivered from memory with backoff (100ms doubling to 5s) until close(); a failed heartbeat is dropped. |
Pair it with the Lucid store and the durability guarantees are the real ones: a crash mid-run resumes from the last checkpoint, because the checkpoints are in Postgres even though the steps never left the process.
What you do not get is a second process serving your steps: a ctx.step dispatched over eventEmitter is handled only by the process that dispatched it. A dispatched step with no registered handler is ignored silently (another subscriber on the same emitter might own it), so the run waits on it. Lifecycle events and cancellation reach subscribers in this process with no control plane at all; they do not fan out across pods.
Several replicas can still share eventEmitter + a shared Lucid store when every step is a ctx.localStep: leases, checkpoints, signals and cancellation all go through the store, so two replicas never execute the same run. What you lose without a control plane is cross-pod fan-out — live-tail, onCancel listeners, schedule pause and a waitForRun (or a blocking start()) that waits on a run resumed by another pod. Add controlPlanes.redis for those, and move to queue, db or bullmq once steps must run in another process. See Pick a topology.
In-process for tests — memory
transports.memory() is the default when config/durable.ts sets no transport, and it is what createTestEngine() uses. It routes a dispatch straight into the handler registered for that name, so a test never waits on a poll interval.
1@Workflow({ name: 'process-doc', version: '1' })2export default class ProcessDocumentWorkflow {3 constructor(4 private intake: IntakeSteps,5 private render: RenderSteps,6 ) {}7 8 async run(ctx: WorkflowCtx, doc: Document) {9 const clean = await ctx.step(this.intake.validate, doc)10 const pdf = await ctx.step(this.render.toPdf, clean)11 const summary = await ctx.step<Summary>('python:enrich', pdf)12 return { pdf, summary }13 }14}Transport pools and failover
A single instance can dispatch over an ordered pool of named transports and fail over between them; a step pins one with ctx.step(step, input, { transport: id }). In config/durable.ts, only transport is built by default (the rest of transports is a menu); list the others to build into the pool in transportPool, in failover order. Each one gets the same consumer deferral, namespace and shutdown as transport:
export default defineConfig({
transport: 'queue',
transportPool: ['db'], // built alongside 'queue'; the engine falls over to it on a dispatch error
transports: {
queue: transports.queue({ connection: 'redis' }),
db: transports.db(),
},
})When you construct a WorkflowEngine yourself, pass transports: [{ id, transport }, …] instead of transport. Either way the engine dispatches each step on the first transport and fails over to the next on a dispatch error, throwing only when every one fails. A step pins one by id, tried first with the rest as failover:
// 'db' must be in the pool (transportPool, or transports: [...] on a hand-built engine)
const result = await ctx.step(heavyStep, input, { transport: 'db' })A pin names a transport by its key in transports (default still means the primary transport). Pinning one the engine didn't build fails the step with an error that lists the transports it has and points at transportPool.
The id of the transport that accepted the dispatch is stamped on the task as task.transport, so a worker consuming several transports replies on the matching one, and the engine registers its result and heartbeat handlers on every transport of the pool. Failover is symmetric. Worker health (groupHealth) and the worker directory are merged across every transport of the pool.
Control plane (separate from the transport)
The transport is point-to-point: it carries a task to one worker and a result back. But some things every engine instance needs regardless of who runs a given run — a dashboard-only process must live-tail a run executing on a worker, and the process actually running a run must learn it was cancelled elsewhere. That cross-instance fan-out is a second channel, the controlPlane, modelled separately from the task transport:
import { controlPlanes } from '@adonis-agora/durable'
export default defineConfig({
transport: 'queue',
transports,
controlPlane: controlPlanes.redis({ connection: 'main' }),
})Omit it and the engine is local-only: events and cancellation reach subscribers on this instance but don't fan out to other processes (fine for a single-instance app). Selecting a transport never makes it the control plane, even one that implements publishControl / onControl. Supply one and lifecycle events broadcast to every process and a cancel reaches the process running the run. See Control plane for the controlPlanes.redis driver (multi-replica, NestJS-interoperable).
Wide fans: results and timers
A ctx.all fan of thousands of steps sends thousands of results back at once, and two knobs decide how fast an engine-side instance works through them:
- Steps. On
bullmq,queueanddb,concurrencysets how many steps a worker runs at once (per task queue onbullmqandqueue, across the one task loop ondb): a number (fixed, with no control loop), or'adaptive'to let the worker raise and lower it from step latency, errors, memory and CPU; see the BullMQ, queue and db options. - Results. The
bullmq,queueanddbtransports hand each instance at least 512 results at once (resultConcurrencyonly raises it); see the BullMQ options and the queue options.dbclaims result rows 256 a round and deletes each round's in one statement. The engine settles one run's results in hand as a batch, skips the fan's barrier count while more of that fan's results are queued behind the batch, and resumes the workflow once per settled wave, whichever transport carries it. A fan's tasks go out in bulk where the transport can (dispatchMany: BullMQaddBulk, multi-row inserts ondb,pushManyOnonqueue), and grouping them by queue is linear in the fan's size. - Timers. Each timer pass loads at most
timerBatchSizedue runs, most overdue first, and walks them in a shuffled order. It is an engine option inconfig/durable.ts(default500), not a transport one; a non-finite or sub-1 value falls back to500:
export default defineConfig({
timerBatchSize: 1000,
// … transport and store
})See multiple instances for why the batch is shuffled.
Worker advertisement
A thin worker publishes a descriptor (what it serves, including the onError routes of its workflows) and a heartbeat, each with an expiry, and the control plane reads them back for capability-aware routing and to serve workflows by convention. Where they live depends on the transport:
| Transport | Advertisement |
|---|---|
bullmq | TTL'd Redis keys, shared with NestJS and Python workers |
db | the durable_transport_worker_keys table |
eventEmitter | in memory, shared by every transport over the same emitter |
queue | a key store beside the queues — by default the app database's durable_transport_worker_keys table (see queue); a tenant pod with none forwards it to the control plane's |
memory | none |
A worker runtime that stops cleanly removes its keys, and so do the queue and db transports for their own per-step-name liveness keys; the bullmq transport's liveness keys expire 35 s after the last beat. A worker that dies drops out when its keys expire.
Wire protocol
Every transport carries the same JSON RemoteTask (dispatch) and StepResult (reply). The task also carries an optional transport (the pool id it was dispatched on) and an optional traceparent (see distributed tracing). The JSON is the same everywhere, but the wire around it is not: only bullmq shares its queue names and job shapes with the NestJS and Python workers. The queue and db layouts are this package's own.
Several engine instances can share one transport safely: an engine settles a result with a conditional write on the step's checkpoint that succeeds only while it is still pending (settleCheckpointsIfPending on the Lucid store), so a duplicate or redelivered result does nothing, and over one store only one engine's write lands.
A worker process is the durable:work command plus the step handlers it serves (from app/steps, discovered automatically). Run one engine-side process, which consumes results, and one or more worker processes.
Note that a node ace console or REPL process deliberately does not consume from the broker — see consumers.
Delivery under multiple instances
Web and worker instances compete for the SAME result and decision queues (and, on queue and db, heartbeats) — each delivery lands on exactly one of them. What that means for steps with and without timeoutMs, how a heartbeat that lands on another instance still keeps a timeoutMs step alive, how a remote workflow turn's decision is applied by whichever replica takes it, and when to run a pure producer with consumers: 'never'.
Choosing a transport
A capability matrix of every transport driver (bullmq, queue, db, eventEmitter, memory) checked against the source, the known limitations of each, and which one to pick.