Quickstart
One import. One durable boundary.
Durably is a library, not a service. Install it, wrap your workflow, put effects inside steps, and run the file as you already would.
import { workflow, run } from '@nikhilverma/durably'
type Input = { urls: string[] }
const digest = workflow<Input>()(async (ctx, { urls }) => {
const results = await ctx.parallel(
urls.map(url => () =>
ctx.step(
() => fetch(url).then(response => response.text()),
{
timeoutMs: 10_000,
retry: {
attempts: 4,
backoff: 'exponential',
baseMs: 500,
},
},
),
),
{ concurrency: 5 },
)
return results.filter(result => result.ok)
})
console.log(await run(digest, { urls: process.argv.slice(2) })) By default, run state is stored under .durably/ in the current working directory. There is no engine setup, configuration file, daemon, or required schema library.
Executable test on GitHub Kill and resume the literal quickstart ↗
Mental model
Replay the function. Reuse the effects.
On resume, the workflow function re-executes from the top. Completed ctx.step() calls return their recorded results instead of running again.
The code between steps must be pure: branching, mapping, and deriving values are fine. Network requests, file writes, current time, and randomness belong inside a step or use ctx.now() and ctx.random().
Steps match by hierarchical position, not by label or completion order. Parallel branches own local counters, so two branches may finish in any order and still replay to the same paths.
Whatever crosses that boundary is recorded, so it must be plain JSON data or a Date. Class instances, functions, and non-finite numbers are rejected at the step that produced them, with the offending property path in the error. Array holes are normalized to null, so a live value and the value a resumed process reads back have the same shape.
| Safe between steps | Put inside a step |
|---|---|
| Array mapping and filtering | HTTP and model calls |
| Branching on recorded output | Database or file writes |
| ctx.now() / ctx.random() | Date.now() / Math.random() |
Executable test on GitHub Replay, idempotency, purity, and serialization ↗
Run semantics
Join, start, or resume.
A run candidate is scoped by workflow family plus canonical input. A custom key replaces the input within that family. The workflow body hash is the fast path; edited workflows resume only after an effect-free compatibility replay confirms the same durable structure. Calling run() then follows these rules:
| Existing run | What run() does |
|---|---|
| Unfinished | Resume it from recorded progress |
| Live lease elsewhere | Subscribe and wait for its result |
| Completed | Start a fresh run |
| Failed | Resume at the failed step; fresh: true starts over |
| None | Start |
run() returns the workflow value directly. It never wraps your result in metadata. Run IDs and advisories travel through stderr, callbacks, inspection, and thrown errors.
Executable tests on GitHub Run identity and lifecycle ↗ Policy edits and structural compatibility ↗
Steps and retry policy
Effects get a durable address.
A step records either its output or failure. Retries are off by default. Add only the policies the effect actually needs.
const page = await ctx.step(
({ signal }) => fetch(url, { signal }).then(r => r.text()),
{
name: 'fetch-page',
timeoutMs: 10_000,
retry: {
attempts: 5,
backoff: 'exponential',
baseMs: 200,
maxMs: 10_000,
jitter: true,
},
},
) Validation, when data crosses a boundary
Pass any Standard Schema-compatible schema as schema. Durably has no schema dependency. Recorded outputs are revalidated on replay, which catches incompatible data after code changes.
Compensating sagas
A completed step can define a typed compensate handler that receives its recorded output. If a later step exhausts retries, Durably runs compensations in reverse completion order. Each completed compensation is recorded, so a process crash during rollback resumes the remaining rollback instead of starting it again.
const held = await ctx.step(
() => inventory.hold(sku),
{
name: 'hold-inventory',
compensate: hold => inventory.release(hold.id),
},
)
const charged = await ctx.step(
() => payments.capture(held),
{
name: 'capture-payment',
compensate: receipt => payments.refund(receipt.id),
},
)
// If this exhausts its retries, Durably refunds the payment,
// then releases inventory. A crash during rollback resumes rollback.
return ctx.step(
() => shipping.book(charged),
{ retry: { attempts: 3, backoff: 'exponential' } },
) Compensation is an explicit recovery action, not an ACID transaction. Use it for effects with a real inverse: release a hold, refund a charge, remove a provisional record, or revoke issued access.
Retrying a rolled-back run re-executes every compensated step. A compensation states that its step's effect was undone, so the recorded output no longer describes the world; reusing it would let the retry finish on a hold that was already released.
Keyed concurrency
Use concurrencyKey when work against the same host, tenant, or resource must serialize.
Executable tests on GitHub Retries, timeouts, sagas, and concurrency ↗ Crash-resumable saga rollback ↗
Fan-out and fan-in
Concurrency without swallowed failures.
ctx.parallel() is cheap in-run concurrency. It returns Result<T>[], so every branch is either { ok: true, value } or { ok: false, error }. Failures stay typed and visible.
It runs every thunk at once unless you pass { concurrency }. Child runs are capped by something else: the engine runs 4 runs at a time unless createEngine({ concurrency }) says otherwise, and a wider fan-out raises CONCURRENCY_CAPPED with both numbers. A parent waiting in joinAll() is parked rather than executing, so it never holds a slot its own children need.
For child runs with independent retries, budgets, and lifecycle, use ctx.spawnAll() followed by ctx.joinAll(). Cancelling a parent cancels children unless they are detached.
Parallel step paths are hierarchical. Scheduling changes completion order, never replay identity.
Executable tests on GitHub Out-of-order branches, children, loops, and waits ↗ Whole-workflow fan-out/fan-in recovery ↗
Test engine
Crash it on purpose.
The in-memory test engine includes a fake clock, crash injection, signals, and shadow replay. After a completed test run, shadow replay checks the same step-path sequence and verifies that no effect executes again.
import { testEngine } from '@nikhilverma/durably/test'
test('resumes without repeating completed work', async () => {
const te = testEngine()
const crashed = await te.run(digest, input, {
crashAfter: 'fetch-page',
})
expect(crashed.status).toBe('crashed')
const resumed = await te.resume(crashed.runId)
expect(resumed.status).toBe('completed')
expect(resumed.advisories).toEqual([])
}) Shadow replay is also why Durably needs no lint plugin. The critical replay rule is checked in-band by tests you were writing anyway.
Executable test on GitHub Crash injection, fake time, signals, and shadow replay ↗
Executable specification
The tests are runnable documentation.
Durably's public contract was written as tests before the runtime. The suite covers small units, complete workflows, real process termination, two-engine cooperation, Node portability, and the exact npm tarball. Each file is deliberately small enough to reuse as an implementation example.
| What you are building | Open on GitHub |
|---|---|
| Steps and replay | m1-kernel.test.ts ↗ |
| Retries, timeouts, and sagas | m3-policies.test.ts ↗ |
| Run identity and operations | m5-engine.test.ts ↗ |
| Parallel and agent workflows | m6-topology-agent.test.ts ↗ |
| Real crash and resume | readme-hero-process.test.ts ↗ |
| Node and package consumers | m8-node-portability.test.ts ↗ |
Run everything with bun run test:all, or run one file with bun test path/to/file.test.ts while adapting its pattern.
Browse on GitHub Test-suite map and commands ↗ All test sources ↗
Observability
State you can cat. History you can grep.
Each file-backed run lives under .durably/runs/<runId>/. Read state.json for current status and events.log for append-only history, compacted to the newest state record once it passes a megabyte. The programmatic equivalents are inspect() and list(). While a run is still going, onStep streams { label, status, attempt, ms } — including replayed for work that came back from the log rather than executing.
| Operation | Use it for |
|---|---|
| inspect(runId) | Current state, progress, active advisories |
| list(filters) | Runs by status, workflow, key, or limit — list({ key }) is how the name you chose leads back to a run id |
| retry(runId) | Continue a failed run, optionally from a step; compensated steps run again |
| restart(runId) | Start over with fresh recorded state |
| cancel(runId) | Cancel at the next durable boundary; finished runs are left untouched |
Executable test on GitHub Priority, pause/resume, retry, restart, and failed runs ↗
Advisories
The API teaches itself.
Durably watches for approaching ceilings and missed durability tools. Advisories are deduplicated by code, capped, and reserved for concrete next moves—not style opinions.
5,000+ steps in a loop-shaped pattern; replay cost grows linearly. Hint: ctx.loop checkpoints state snapshots, making resume O(1).
Read them in state.json, inspect(), test-engine results, and errors. For run(), use stderr-once or onAdvisory. Pass advisories: "silent" when a caller owns the channel.
Executable test on GitHub Every advisory, threshold, channel, and anti-spam rule ↗
Human and time boundaries
Park without keeping a process alive.
waitFor(), sleep(), and sleepUntil() persist their boundary. The process can exit; the next invocation after the signal or wake time continues.
const approval = await ctx.waitFor(
'approval',
ApprovalSchema,
{ timeoutMs: 72 * 3600_000 },
)
// From another process:
await durably.signal(runId, 'approval', { by: 'nikhil' }) A signal goes to the wait the run is currently parked on, addressed by its durable position rather than by name, so a workflow can await the same name more than once and each signal releases the right one. The delivered payload is recorded like a step output, so replaying a satisfied wait returns it instead of parking again.
A wait's schema lives in the workflow body, so the payload is checked where it is consumed rather than where it is sent. A payload the schema rejects was never delivered: it is discarded, the run stays parked for a corrected signal, and the refusal is recorded on the wait. That holds whichever process called signal().
durably.pause(runId) requests a pause at the next durable boundary; durably.resume(runId) continues from recorded progress.
Executable tests on GitHub Waits, signals, timers, and loop checkpoints ↗ Pause and resume lifecycle ↗
Shared resource policies
Rate limits and breakers across runs.
Named resources apply one concurrency limit, token-bucket rate limit, and circuit breaker across every run owned by an engine. Attach the resource to a step with uses. An open breaker raises a retryable error, so the step's backoff policy can ride out its cooldown.
import { createEngine, resource } from '@nikhilverma/durably'
const openai = resource('openai', {
rateLimit: { max: 60, perMs: 60_000 },
concurrency: 8,
breaker: {
failureRate: 0.5,
windowMs: 30_000,
cooldownMs: 10_000,
},
})
const engine = createEngine({ resources: [openai] })
await ctx.step(() => callModel(), { uses: 'openai' }) Executable test on GitHub Concurrency, rate limiting, circuit breakers, and budgets ↗
Storage and engines
Files first. SQLite when needed.
FileStorage is the zero-configuration default. It uses checksummed, length-prefixed event records and per-run locking. The built-in SQLite adapter uses node:sqlite with no native addon and requires Node 22.5 or newer.
import { createEngine } from '@nikhilverma/durably'
import { SqliteStorage } from '@nikhilverma/durably/sqlite'
const engine = createEngine({
storage: new SqliteStorage('./durably.db'),
concurrency: 8,
budget: { usd: 200 },
}) A run is executed by whichever process holds its lease, which a heartbeat renews for as long as a step is running. If a process stalls long enough to lose its lease, another engine reclaims the run; the stalled one discards the work it can no longer commit rather than overwriting the new owner, and adopts the stored outcome.
Need clusters, task routing, a persistent scheduler, or service-scale transaction orchestration? That is the point to choose Temporal, Restate, or DBOS. Durably is intentionally for the script or agent you invoke and expect to finish.
Executable tests on GitHub Checksummed files, atomic state, and torn-tail recovery ↗ Two engines and process lease reclamation ↗