Agora
Transports

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.

Every transport carries the same RemoteTask / StepResult JSON, and the engine's durable machinery (checkpoints, replay, retry backoff, timers, flow-control admission, dead-lettering) runs on top of any of them. The transports differ in everything around that contract: how fast a task is picked up, how many run at once, how a crashed worker is detected, and which optional Transport capabilities they implement. The engine turns some cluster features on only when the transport implements them.

This page compares the five drivers side by side. Every cell reflects what the transport's source does today.

Capability matrix

✅ supported · ⚠️ partial or conditional (see the numbered note) · ❌ not supported · n/a does not apply

queue is transports.queue() over @adonisjs/queue, on its Redis adapter or a database (Knex) adapter. The two adapters implement the same contract the transport uses, so a cell differs between them only where it says so. memory is the in-process test driver (see Testing); it's listed for completeness, not for production.

Delivery

bullmqqueuedb (Lucid)eventEmittermemory (tests)
How a task reaches the workerPush: a BullMQ Worker per step name, blocking on RedisPolling: one popFrom loop per step name, every pollIntervalMs (default 200 ms)Polling: one claim query for every served step name, every pollIntervalMs (default 200 ms)In-process emitHandler runs inside dispatch
Idle backoffn/a, the worker blocks instead of polling✅ task, heartbeat, control, step-event and tenant-protocol loops double their sleep up to idleBackoffMax × pollIntervalMs (800 ms); the results, decisions and run-reply loops never back off✅ same rule and defaults as queuen/an/a
Pickup latency on an idle systemMillisecondsUp to 800 ms for the task after a quiet spell, then up to 200 ms for the resultUp to 800 ms for the task after a quiet spell, then up to 200 ms for the resultSame tickInline
Cost while idleHeld Redis connections (a blocking one per BullMQ worker), a liveness key write per served step name every 10 s, a PING per pub/sub subscriber connection every 30 s (pingIntervalMs)One adapter read per loop per backed-off interval, plus the 200 ms results loop, a reclaim sweep every 30 s and a liveness write per served step name every 10 s to the worker-key table. On the Knex adapter all of it is a query against your database¹One claim query per loop per backed-off interval against your database, plus the 200 ms results loop and a liveness upsert per served step name every 10 s¹NoneNone

Throughput

bullmqqueuedb (Lucid)eventEmittermemory (tests)
Steps run concurrently per process✅ concurrency per step-name queue (default 1)✅ concurrency per step-name loop (default 1)✅ concurrency across every served step name (default 1)⚠️ unbounded²⚠️ inline in dispatch
Adaptive concurrency (concurrency: 'adaptive')✅✅✅❌❌
Results settled concurrently (engine side)✅ at least 512 at once (resultConcurrency raises it)✅ at least 512 at once (resultConcurrency raises it)✅ at least 512 at once (resultConcurrency raises it), claimed 256 a round⚠️ unbounded²⚠️ unbounded
Broker-level step priority✅ mapped onto BullMQ job priority, full range⚠️ mapped onto the adapter's 1–10 scale³✅ priority column, 32-bit range, claimed highest first❌❌

Reliability

bullmqqueuedb (Lucid)eventEmittermemory (tests)
Delivery guaranteeAt-least-onceAt-least-onceAt-least-onceAt-most-once (lost with the process)At-most-once
Detects a crashed worker viaBullMQ job lock + a peer worker's stalled check⁴Claim renewal + the reclaim sweep⁴Lease expiry on the next claim poll⁴❌❌
Default time to re-deliver a crashed worker's stepAbout 30–60 s (30 s lock, stalled check every 30 s)About 2–2.5 min (stalledThresholdMs 120 s, plus up to one 30 s sweep)About 30 s (leaseMs), plus one poll❌❌
Claim or lock renewed while a long step runs✅ BullMQ extends the job lock✅ renewJobs every half stalledThresholdMs, on 0.6 and 0.7⁵✅ every held claim, every leaseMs / 3⁵n/an/a
Poison bound (worker keeps dying ➜ failed StepResult ➜ durable retry)✅ the failed bridge, once a job stalls past BullMQ's maxStalledCount (default 1, so on the second stall)⚠️ maxStalledCount (3), @adonisjs/queue 0.7 only⁶✅ maxStalledCount (3)❌❌
Result redelivered when the engine's handler throws✅ 5 attempts, exponential backoff from 500 ms, then kept 24 h and reported✅ redelivered one poll interval later, with no cap⚠️ redelivered after leaseMs, with no cap✅ in-memory, backoff 100 ms → 5 s, until close()⁷❌
Step heartbeats (timeoutMs liveness) across engine instances✅ <P>-heartbeat pub/sub reaches every instance⚠️ one instance takes each beat; the others read the persisted beat⁸⚠️ one instance takes each beat; the others read the persisted beat⁸✅ in-process❌ no beats
Per-step wall-clock ceiling on the worker✅ stepTimeoutMs (opt-in): a wedged handler is failed retryable❌❌❌❌
Clean shutdown hands unfinished work back❌ left to BullMQ's stalled check after closeTimeoutMs; liveness keys expire after 35 s✅ unfinished jobs re-queued at once after the closeTimeoutMs drain; liveness keys withdrawn at once✅ claims cleared at once after closeTimeoutMs, uncounted; liveness keys withdrawn at oncen/an/a

Cluster features

bullmqqueuedb (Lucid)eventEmittermemory (tests)
Steps in another process✅✅✅❌❌
Runs spread over the engine instances (run queue)✅ <P>-runs, batched with addBulk✅ <prefix>:runs, batched with pushManyOn when the adapter has it✅ run rows, claimed with a lease❌ the triggering instance❌ the triggering instance
Python and NestJS workers on the same wire✅❌❌❌❌
Remote workflows (dispatchWorkflowTask / onDecision / onStepEvent)✅ including Python @workflow✅ TypeScript worker runtime✅ TypeScript worker runtime❌❌
A turn's decision is applied whichever engine replica consumes it✅ any replica applies it⁹✅ any replica applies it⁹✅ addressed to the dispatching instance; any replica could apply it⁹n/an/a
Store-less tenant pods (dispatchStartRun, RunRequest / RunReply, TenantEvent)✅ over Redis pub/sub✅ per-instance inbox queues¹⁰✅ rows addressed per instance; the pod needs the database❌❌
Worker health in the dashboard (groupHealth)✅ depth = waiting + active + delayed + prioritized⚠️ depth = pending only; needs a worker-key store¹¹✅ depth = pending + claimed rows❌❌
Worker descriptors (handshake routing guard, workflow directory)✅ TTL'd Redis keys⚠️ needs a worker-key store¹¹✅ durable_transport_worker_keys⚠️ in-process only❌
consumers deferral ('auto' console/REPL, 'never')✅ no BullMQ worker is built and no liveness key is stamped; pub/sub still subscribes✅ loops parked; its own reply and event inboxes still run✅ loops parked; rows addressed to this instance still consumedn/an/a

Operations

bullmqqueuedb (Lucid)eventEmittermemory (tests)
Infrastructure to runRedis, plus the bullmq and ioredis packages@adonisjs/queue and its backend (Redis, or a database through Knex), plus the app database's worker-key table for health and advertisementThe database you already run, through @adonisjs/lucid: Postgres, MySQL or SQLite, with no FOR UPDATE SKIP LOCKED needed (transport tables auto-created or migrated)NoneNone
Several engine instances✅✅✅⚠️ only when every step is ctx.localStep¹²❌
Built-in control plane (cross-pod live-tail and cancel fan-out)❌ pair with controlPlanes.redis¹³❌ pair with controlPlanes.redis¹³❌ pair with controlPlanes.redis¹³⚠️ local to the process⚠️ local to the process
Bounded shutdown✅ closeTimeoutMs (default 5 s)✅ closeTimeoutMs (default 5 s)✅ closeTimeoutMs (default 5 s)Nothing to closeNothing to close

Notes

  1. Polling is the cost of a backend-agnostic transport. A queue worker runs one task loop per step name it serves, so a worker serving 30 names runs 30 loops; db runs one task loop for all of them. Every engine also polls the results table or queue every 200 ms, and the step-event and protocol loops it starts. On the Knex adapter, or on db, that load lands on your database.
  2. Every emit starts its handler at once, so there is no limit. A burst of steps runs all at once in the same process.
  3. @adonisjs/queue runs the lowest number first on a 1–10 scale (default 5). The transport sends round(5 − priority), clamped to 1–10. An unprioritised step gets no priority, so the adapter's default 5 applies, the same slot as priority: 0. Every priority of 4 or more lands on 1 and every priority of −5 or less lands on 10, so only the integers from −5 to 4 stay distinct.
  4. Recovery needs a live consumer of that step name. BullMQ's stalled check runs on a worker of the same queue, the queue reclaim sweep walks only the queues its instance pops from, and a db worker claims only rows for the step names it serves. A restarted worker counts. If the only worker for a step dies and never comes back, nothing settles the step; the engine's opt-in remoteRedispatchMs covers that case on every transport (see Lost remote dispatch).
  5. Renewal runs on a timer, so a step that blocks the event loop longer than the threshold (stalledThresholdMs on queue, leaseMs on db) looks dead and is re-delivered while it still runs. Both transports report the lost claim to onError. Keep heavy work async, or raise the threshold. An adapter without renewJobs (only a custom one) gets no renewal on queue, and the threshold defaults to 30 minutes there.
  6. On @adonisjs/queue 0.7 the adapter hands a job that stalled too often back to the transport, which fails it and publishes a retryable failed result. On 0.6 the adapter deletes such a job inside its own sweep and returns only a count, so no result is published and the step waits. Set the step's timeoutMs or the engine's remoteRedispatchMs there. See Poison jobs.
  7. A failing result handler (a store timeout while settling, say) is reported to onError and redelivered from the in-memory copy with backoff. The copy dies with the process — an emitter result is never on a broker — so a crash between the failure and the retry still loses it; recovery then re-drives the step from its checkpoint.
  8. On bullmq every engine instance hears every beat. On queue and db a beat is a job or a row that one instance takes. Before a timeoutMs step's window expires, the dispatching instance reads the last beat persisted on the checkpoint, and keeps waiting if another instance recorded a recent one. That needs a store that persists beats (recordStepHeartbeat, which the Lucid store implements) and allows up to 10 s of extra silence once beats land elsewhere (the engine persists a beat at most every 10 s; config/durable.ts doesn't expose that throttle). Each instance writes the beats it persists one at a time: a newer beat of the same step replaces one still waiting, and a step whose result arrives first never has its beat written. A wide fan, where every step beats once on pickup, therefore costs a trickle of UPDATEs rather than one per step, and the store's connections stay free for turns and results.
  9. <P>-decisions (bullmq) and <prefix>:decisions (queue) are point-to-point queues that every engine consumes, so with several engine replicas a decision is often taken by a replica that didn't dispatch the turn. That is fine: before enqueueing a turn the engine records its taskId on the run and releases the lease, and whichever replica takes the decision applies it by run id, durably, only if the run still awaits that taskId (a stale or duplicate decision is dropped). A decision that never comes (the worker died mid-turn) is covered by the turn's liveness window, remoteAdvanceSilenceMs; see Remote workflow turns.
  10. Live-tail events need a worker-key store on the control plane. Replies and events are polled, and a new subscription can miss the first second or so of events. See Tenant pods over the queue transport.
  11. The worker advertisement and liveness keys live in a key store beside the queues, by default the app database's durable_transport_worker_keys table. With workerKeys: false, or no database, the transport doesn't implement groupHealth and the panels stay empty; a tenant pod without one forwards its advertisement to the control plane. depth counts only pending jobs because the @adonisjs/queue adapter contract has no count for jobs in flight or delayed retries. See Worker health.
  12. Replicas sharing eventEmitter and one Lucid store never run the same run, because leases, checkpoints, signals and cancellation all go through the store. A ctx.step is still handled only by the process that dispatched it. See Pick a topology.
  13. bullmq doesn't implement ControlPlane at all. queue and db implement publishControl / onControl, but over a single-consumer queue (<prefix>:control) or table (durable_transport_control), so each message reaches one instance, not every one. The engine only uses a control plane you set in controlPlane: selecting a transport never makes it the control plane. Use controlPlanes.redis for cross-pod fan-out with any of them. Without a control plane, events and cancellation still reach subscribers in the same process.

The same on every transport, because the engine owns it: checkpoints and replay, retry backoff (a durable wakeAt on the failed checkpoint), run leases and run recovery, timers, flow-control admission priority and fairness, dead-lettering, remoteRedispatchMs, namespaces, whether a process drives its runs (drive), and the shutdown drain of in-flight runs (drainTimeoutMs, before the transport's own closeTimeoutMs). They behave the same whichever transport carries the step.

Limitations per transport

bullmq. It needs Redis and two extra packages. Crash recovery depends on a live worker of the same queue, and a clean shutdown doesn't hand unfinished jobs back: after closeTimeoutMs they wait for BullMQ's stalled check, and the process's liveness keys stay up to 35 s. The failure bridge exists only in consumers that implement it (this transport, the NestJS one and the Python durable-worker). There is no control plane in the transport, so add controlPlanes.redis.

queue. It's polling-based, so a first task after a quiet spell can wait up to idleBackoffMax × pollIntervalMs, and every served step name costs one poll loop. Crash detection is the slowest of the three (about 2 minutes by default). The poison bound needs @adonisjs/queue 0.7. Priority is squeezed onto a 1–10 scale. Health depth counts pending jobs only, and health and descriptors need a worker-key store. Heartbeats and decisions are single-consumer (notes 8 and 9), there's no control plane, and there's no cross-language wire.

db (Lucid). It's polling-based, and every loop, renewal and liveness write is a query against the database your store also uses, so throughput is bounded by polling and row contention. A result whose engine-side handling throws waits out leaseMs before it's redelivered. Heartbeats are single-consumer (note 8). Payload columns are longtext on MySQL, so a large step input, output or event log fits (see Column types). There's no control plane, and no cross-language wire: Python and NestJS workers speak bullmq only.

eventEmitter. Single process for steps. There's no persistence of in-flight steps (a crash loses them; the run itself resumes from its checkpoints), no concurrency limit and no worker health. A dispatched step with no registered handler is ignored silently, because another subscriber might own it, so the run waits on it.

memory. For tests. It runs the handler inside dispatch, needs onResult registered first, and carries no heartbeats.

Which one to pick

Recommended for production: bullmq

transports.bullmq() is the most complete transport:

  • Push delivery. A BullMQ worker blocks on Redis instead of polling, so pickup is milliseconds and an idle fleet costs a few held connections, not a stream of queries.
  • The only cross-language wire. It is byte-compatible with the NestJS BullMQ transport and the Python durable-worker, so Python steps, Python @workflow bodies and NestJS workers share one Redis with your Adonis engine. See Python interop and interop.
  • Native lock renewal and stall handling. BullMQ extends the job lock while a step runs, and a peer's stalled check re-queues a crashed worker's job. A job that keeps stalling past BullMQ's limit is failed, and the transport bridges that into a retryable StepResult. A result whose engine-side handling throws is retried 5 times with backoff. Recovery takes about 30–60 seconds.
  • The broadest dashboard depth. Worker health counts waiting, active, delayed and prioritized jobs, and step heartbeats reach every engine instance over pub/sub.

Pair it with controlPlanes.redis for cross-pod live-tail and cancellation. If you can run Redis, use bullmq.

queue makes sense when the app already runs @adonisjs/queue and you want durable steps on the same backend and the same config/queue.ts, or when you want a database-backed queue through its Knex adapter with Adonis-style configuration. Run @adonisjs/queue 0.7 for the poison bound, keep the default worker-key table for health and descriptors, and add controlPlanes.redis when you run more than one engine instance.

db makes sense when you have no Redis at all and want the database you already run to be the queue. It fits a modest dispatch rate (workflow and pipeline scale rather than a high-fanout firehose) and idempotent steps. It is also the only transport that addresses a remote workflow's decision to the engine that dispatched it.

eventEmitter is for a single process: a monolith, local development or a first deploy. It needs no infrastructure. Several replicas can share it with a Lucid store when every step is a ctx.localStep (see Pick a topology). Moving to a broker later is a config change, not a workflow-code change.

memory is for tests only.

config/durable.ts builds the transport you select, plus any you list in transportPool: together they form an ordered pool of transports with failover and per-step pinning by name. A WorkflowEngine you construct yourself takes the same pool as transports: [{ id, transport }, …].

On this page

Partner

Your product as the official integration for Durable, right here.

Become a partner →