Running in production
The operational checklist for taking nestjs-durable from a single dev process to a real deployment — durable store and transport, multi-replica crash recovery, the dead-letter cap, graceful shutdown, replay-safe deploys, flow control, tenancy isolation, retention, observability, and schema management.
Everything below works out of the box in a single dev process with zero infrastructure — that's the point of the durability model. This page is the other half: what to change, and what to double-check, before that process becomes a real deployment with multiple replicas, real deploys, and someone else's uptime on the line. Each item says why it matters, shows the config, and links to the page with the full detail.
1. Swap the dev defaults
Two defaults exist purely to make getting-started zero-setup: InMemoryStateStore and
EventEmitterTransport. Neither survives a process restart.
- Store.
InMemoryStateStoreis exactly what it says — an in-memoryMap. A crash or redeploy loses every run. Swap in a real adapter (MikroORM, TypeORM, Prisma, or Drizzle) before anything durability-sensitive ships. See Stores. - Transport.
EventEmitterTransportdispatches steps in-process — fine for one process, but a@Stephandler never runs anywhere else, so there's no cross-process or cross-language worker. Swap inBullMQTransport(Redis) once steps need to run in a separate worker (or a Python worker). See BullMQ / Redis.
DurableModule.forRootAsync({
inject: [MikroORM],
useFactory: (orm) => ({
store: new MikroOrmStateStore(orm),
transport: new BullMQTransport({ connection: { host: 'redis', port: 6379 } }),
}),
});2. Multi-replica correctness
Running more than one replica is safe by design, not by convention — but it's worth knowing why before you rely on it:
- Crash recovery is a lease, not a boot-time sweep. While a run executes, its worker renews a
recovery lease every
leaseMs / 2. A crashed worker stops renewing, so the lease expires andengine.recoverIncomplete()reclaims the orphaned run — on any instance, not only on the next boot.leaseMsdefaults to 30 000 — set it above your longest single resume step, since the lease renews for the run's whole duration, not just its start. recoverIncompleteneeds a heartbeat to run periodically, not just at boot. The NestJSTimerPollercalls it every tick (alongsideresumeDueTimers/sweepTimeouts) — this is what turns a 30-second lease into a real self-healing loop instead of a recovery that only fires once per deploy. Make sure every operator instance runs the module (and isn't started withdrive: false) so the poller is actually ticking somewhere.- Exactly one instance per run, by an atomic lease.
StateStore.tryLockRunis how the engine picks a single owner for arunningrun even with several replicas racing to recover it — there's no split-brain double-resume.
By default each replica takes a random instanceId — the identity stamped on recovery leases and
worker heartbeats. That's fine, but a stable, human-readable id makes leases and the
dashboard's per-worker health legible (you can tell which pod holds
a run's lease), so pin it to the orchestrator's pod identity:
DurableModule.forRoot({
store,
transport,
leaseMs: 60_000, // above your longest single resume step
instanceId: process.env.HOSTNAME, // e.g. the k8s pod name — stable, unique per replica
});None of this needs configuration beyond picking leaseMs — it's the default behavior of a durable
store + DurableModule. Full mechanism: Durability & replay
and Reliability.
3. Cap the blast radius: dead-letter poison pills
Self-healing recovery is great until a run is a genuine poison pill — a deserialization bug, a non-deterministic change, an infinite loop — that crashes the process every single time recovery picks it up. Left unbounded, that's a crash loop that takes every other run down with it on every boot.
DurableModule.forRoot({
store,
transport,
maxRecoveryAttempts: 5, // after 5 crash-recoveries, dead-letter the run instead of looping
deadLetterWorkflow: 'global-dlq', // fallback DLQ handler for workflows without their own
});maxRecoveryAttempts is unlimited by default — recovery
retries forever unless you set it. Once a run exceeds the cap, the engine moves it to the terminal
dead status instead of resuming it again: inspectable and retriable from the dashboard, but no
longer crash-looping the process. Route dead runs to a handler with engine.onDead, an inline
@DeadLetter() method, or the module's deadLetterWorkflow default. See
Dead-letter queue.
4. Graceful shutdown
On SIGTERM you want the process to stop picking up new runs and let in-flight ones finish — not
kill them mid-step and rely on the lease timeout to recover them a leaseMs later.
// main.ts
app.enableShutdownHooks(); // required for Nest's OnApplicationShutdown to fire at allDurableModule.forRoot({
store,
transport,
shutdownTimeoutMs: 15_000, // wait up to 15s for in-flight runs to settle before exiting
});On shutdown the module calls engine.drain(shutdownTimeoutMs) — shutdownTimeoutMs defaults to
10 000. drain turns recovery and the pending-run poll into no-ops, so no new run starts, then
waits for every currently in-flight execution to settle — up to the timeout — before returning.
Transports are closed after the drain, deliberately, so an in-flight run can still dispatch to and
await a remote step while it drains. Only after
drain resolves (or times out) does the process actually exit.
What this means for a run that's still executing when the timeout is hit: drain just stops
waiting — it doesn't cancel anything. The run keeps running until the process is actually killed;
if that happens mid-step, the step's result is simply never checkpointed, its recovery lease expires
on schedule, and another instance's recoverIncomplete() picks the run back up and replays it from
its last completed checkpoint — the same crash-recovery path from #2, just triggered by a deploy
instead of an unplanned crash. Set shutdownTimeoutMs above your typical in-flight run duration to
make that the rare case rather than the common one.
5. Deploys: replay-safety discipline
A workflow body is replayed positionally — the same rule from Durability & replay applies with extra force once real traffic is in flight when you deploy. Three layers catch a breaking change at a different point:
- Lint at author time.
@dudousxd/nestjs-durable-eslint-pluginflagsDate.now(),Math.random(),new Date(),crypto.randomUUID()andperformance.now()inside a@Workflowrun— the most common way a workflow body accidentally goes non-deterministic — before it's ever committed. Wire the rule (or Biome's GritQL plugin) into CI so a slipped call fails the build, not a production replay. See Linting for non-determinism. - Regression-test replay before you ship.
assertReplayable(register, history)(from@dudousxd/nestjs-durable-testing) replays a captured real run's history against your current workflow code and throwsNonDeterminismErrorif they diverge — catch a shape-breaking change in CI against a committed fixture, instead of an in-flight run in production. See Testing. - Version bumps for breaking shape changes. A workflow's
@Workflow({ version })pins every run that starts under it; a run always replays against the version it started on. When a change reorders, inserts, or removes steps, register the new version alongside the old one and keep both until every run that started on the old version has reached a terminal state. See Versioning & determinism.
Deploy workers and the operator/API together when a workflow or its steps changed. Once steps run
in a separate process — a BullMQ worker, or a Python worker — that worker's code
must agree with the operator's on step names and workflow shape for the run it's replaying. A worker
left running an old build after a step was renamed (without a matching @Workflow version bump) can
pick up a run and surface a NonDeterminismError, or just hang — the same failure mode documented as
a pitfall of a stale local worker in Tenancy. The lint
rule and assertReplayable catch the code-level mistake; keeping workers and operator in lockstep on
deploy is what keeps a correct version bump from still tripping over a stale process that hasn't
picked it up yet.
6. Concurrency and backpressure
Two independent knobs, easy to conflate:
- Worker/transport concurrency — how many dispatched tasks this process runs at once. On
BullMQTransport,concurrencydefaults to1(one task at a time); pass a fixed number, or'adaptive'to let anAdaptiveControllerself-tune the limit from a latency/backpressure gradient and a RAM ceiling instead of you guessing it. See BullMQ / Redis. - Admission (durable queues) — a cap or rate limit that
ctx.stepcalls opt into viaengine.registerQueue/ the module'squeuesoption, independent of how many workers you run. A blocked call re-suspends and the timer poller retries admission later, so the limit survives a crash. See Flow control.
const transport = new BullMQTransport({
connection: { host: 'redis', port: 6379 },
concurrency: { mode: 'adaptive', min: 2, max: 32, ramCeilingPct: 85 },
});
DurableModule.forRoot({
store,
transport,
queues: [{ name: 'emails', concurrency: 10, rateLimit: { limit: 1000, periodMs: 3_600_000 } }],
});By default, admission accounting is per engine instance — a concurrency: 5 queue admits up to 5
per replica, not 5 fleet-wide. For a true cross-instance cap, pass a RedisAdmissionBackend (from
@dudousxd/nestjs-durable-admission-redis) as the module's admission option so every replica shares
the same Redis-backed accounting. See
Local vs. global admission.
For a worker role (a store-less thin worker, or a co-located store + connection process), the
concurrency field is the worker consumer's own per-queue limit — it can also be 'adaptive' (see
Node worker → Concurrency):
DurableModule.forRoot({
topology: { role: 'tenant', tenant: 'acme-corp' },
connection: process.env.REDIS_URL,
concurrency: { mode: 'adaptive', min: 2, max: 32, ramCeilingPct: 85 },
concurrencyByHandler: {
'reports.generate': 2, // a heavy step: keep it low regardless of the global limit
},
});concurrencyByHandler (a Record<string, ConcurrencyOption>) is accepted as a per-handler override
but is not yet wired through runRedisWorker — today every subscribed queue uses the single
concurrency value. Set it to declare intent, but rely on concurrency for the effective limit until
per-handler routing lands.
Remote-worker liveness
When steps or workflows run in a separate (or polyglot) worker, the engine can't tell a slow worker
from a dead one by default — it just waits. Set remoteAdvanceSilenceMs on the operator so a remote
workflow advance that goes silent longer than the deadline is treated as lost and re-driven by
recoverIncomplete; each worker heartbeat rearms the deadline, so an alive-but-slow worker is never
re-driven:
DurableModule.forRoot({
store,
transport, // BullMQ — remote/polyglot workers
remoteAdvanceSilenceMs: 30_000, // no decision/heartbeat for 30s ⇒ presume the turn lost, recover it
});This is the workflow-turn counterpart to a dispatched step's timeoutMs. Leave it unset and a remote
advance waits unbounded (correct, but a genuinely dead worker's run stays running until its lease
expires). See Cross-ecosystem interop and the
Node worker for the worker side.
7. Isolation between environments
If a dev cluster, a staging environment, and developers' local instances ever point at the same
store/Redis, an un-namespaced instance is not an isolated observer — it's on the exact same queues and
polling the exact same rows as everyone else, and it will steal or drive someone else's runs.
namespace (DurableModule.forRoot({ namespace })) partitions both the store's poll paths and the
transport's queue/stream/key names, so non-interchangeable pools can safely share one backing
database and Redis:
DurableModule.forRoot({
store,
transport,
namespace: process.env.DURABLE_TENANT, // e.g. 'staging', or a developer's own name
});Omitting namespace makes the instance an operator that drives/recovers/resumes runs of every
namespace — that's the shared cluster's control plane, not something to set on a random local
process. See Tenancy for the full operator/tenant model and its
pitfalls.
8. Retention
Without a policy, completed/failed/cancelled runs accumulate in durable_workflow_runs (and its
child tables) forever, and the timer poller's per-tick status scans get linearly slower as that table
grows. Set a retention policy on a driving instance to hard-prune terminal history on an interval:
DurableModule.forRoot({
store,
transport,
retention: {
policies: [
{ statuses: ['completed', 'cancelled'], maxAge: '14d', maxCount: 200 },
{ statuses: ['failed'], maxAge: '90d' }, // keep failures longer for debugging
],
},
});This requires a store adapter that implements bulk pruning (the MikroORM adapter does; others no-op with a warning). See Retention for the full policy shape, the sweep interval/batch-size defaults, and how "keep" bounds combine.
9. Observability
Three views of the same engine lifecycle events, for different jobs:
- The dashboard (
@dudousxd/nestjs-durable-dashboard) is the operating surface — run list, the step graph, retry/cancel/continue actions. It mounts at/durablewith no auth of its own: front the base route with your own guard, or foldbasePathinto a prefix your existing auth/proxy rules already cover. See Control plane. - OpenTelemetry (
@dudousxd/nestjs-durable-otel) turns runs into traces — one root span per run, one child span per step — for the tracing stack you already run.attachDurableOtel(engine)is aimed at debugging production latency and correlation, not at operating a run. Note that the root span ends on suspend and does not reopen on resume, so a workflow that sleeps or waits on a signal shows post-resume steps as detached spans — use the Telescope watcher instead if you need one trace per run across suspends. See OpenTelemetry. The same package also exportsattachDurableMetrics(engine)— a dependency-free counter/percentile collector (run/step counts by outcome,p50/p95/max duration) meant to back a/metricsroute or a scheduled exporter. - Telescope (
@dudousxd/nestjs-durable-telescope), if you already run@dudousxd/nestjs-telescope, surfaces durable runs/steps alongside your app's requests and jobs, and itsdurableTelescopeExtension()adds a health dashboard (success rate, current-state gauges, top failing workflows) driven partly from recent history and partly from live store reads, so the current-state gauges aren't bounded by Telescope's prune window. See Telescope.
10. Schema management
By default the module calls store.ensureSchema() on boot for the MikroORM/TypeORM adapters — zero
setup in dev. For MikroORM specifically, this runs ensureMikroOrmDurableSchema, which is
fingerprint-gated: a marker table (durable_schema_meta) records a SHA-256 hash of the durable
entity metadata last applied, and a steady-state boot does two cheap round-trips (a
CREATE TABLE IF NOT EXISTS plus one PK read) to confirm nothing changed, skipping the expensive
whole-database information_schema introspection entirely. Only a fresh database, an actual entity
change, or a hand-bumped internal revision constant triggers the full heal — and that heal runs under
a best-effort cross-pod advisory lock (MySQL GET_LOCK, Postgres pg_advisory_lock)
so concurrent pods don't race the same DDL.
If your environment restricts DDL to migrations (no runtime CREATE TABLE/ALTER TABLE at all), turn
auto-schema off and own it explicitly:
DurableModule.forRoot({ store, transport, autoSchema: false });// in a migration
import { ensureMikroOrmDurableSchema } from '@dudousxd/nestjs-durable-store-mikro-orm';
export class AddDurableTables extends Migration {
async up() {
await ensureMikroOrmDurableSchema(this.getEntityManager().getOrm());
}
}autoSchema defaults to true. The same additive,
non-destructive update runs either way — the auto-boot path and the migration helper call the exact
same function, so turning it off changes when it runs, not what it does. See
MikroORM store for the full
mechanism, including the MySQL/MariaDB collation auto-convergence it also performs during a full heal.
11. Guard the run: input validation & a lifetime cap
Two @Workflow options catch a whole class of production incidents at the boundary rather than deep in
a replay — a malformed start payload, and a run that never ends.
Validate start input. A run created with garbage input fails on its first step (or worse, halfway
through), long after the caller has moved on. inputSchema validates the payload against a
class-validator DTO before the run is created, so a
bad request is rejected synchronously at start:
import { IsString, IsInt, Min } from 'class-validator';
class CheckoutInput {
@IsString() orderId!: string;
@IsInt() @Min(1) amountCents!: number;
}
@Workflow({ name: 'checkout', version: '1', inputSchema: CheckoutInput })
export class CheckoutWorkflow {
async run(ctx: WorkflowCtx, input: CheckoutInput) {
/* input is guaranteed shape-valid here */
}
}inputSchema needs class-validator/class-transformer installed. For a rule that a DTO can't
express (a cross-field invariant, an async lookup) use validateInput instead — a plain
(input) => void | Promise<void> that throws to reject, and which takes precedence over inputSchema:
@Workflow({
name: 'transfer',
validateInput: (input: TransferInput) => {
if (input.from === input.to) throw new Error('cannot transfer to the same account');
},
})
export class TransferWorkflow { /* … */ }Cap the lifetime. A workflow that waits on a signal or a child that never arrives will sit
suspended forever, holding whatever it holds. executionTimeout bounds a run's total wall-clock life;
the timer poller cancels an overrunning run (status cancelled, reason execution_timeout):
@Workflow({ name: 'approval', executionTimeout: '72h' }) // auto-cancel if not settled within 3 days
export class ApprovalWorkflow { /* … */ }A timed-out run takes its children with it, the same way an explicit cancel does — the whole
subtree, depth-first, skipping any child that already finished. Otherwise a child outlives the parent
that spawned it with nothing left pointing at it, and the only way to find it is to read the runs
table by hand.
The cap is enforced by engine.sweepTimeouts, which the NestJS TimerPoller calls every tick — so a
driving operator instance must be running (not drive: false) for it to fire, exactly like durable
timers and retention. See Scheduling for the poller, and
Event-driven workflows for validateInput on an event-triggered start.
Patterns
A cookbook of common durable-workflow shapes — human approval, webhook confirmation, batch fan-out, cron digests, booking sagas, rate-limited APIs, and long-lived pollers — each a short, self-contained recipe with a link to the full page.
Troubleshooting
Symptom-first answers to the errors and stuck states you'll actually hit — NonDeterminismError, a caught suspend that corrupts a run via a missing isWorkflowControlFlowSignal guard, a run wedged in pending or suspended, SignalTimeoutError, dead-lettered runs, MySQL collation clashes, and an empty dashboard.