Quickstart
from zero to a running engine + dashboard in one command
npx @dicabrio/durable
# → dashboard + API on http://localhost:3030
# → discovers http://localhost:3000/api/durableThis starts an embedded Postgres, applies all migrations, and serves the dashboard and API on one port. Dev workspaces reset on every start; Durable then discovers DURABLE_DEV_APP_URL (default http://localhost:3000/api/durable) and keeps retrying until it is available. The downloaded Postgres files remain in ~/.durable/pgdata. No Docker.
Connect an app (TypeScript)
Install the SDK — @dicabrio/durable-sdk, zero runtime dependencies, ships ESM + CommonJS with types:
npm install @dicabrio/durable-sdkimport { createFunction, serve, DurableClient } from "@dicabrio/durable-sdk";
const hello = createFunction({
id: "hello",
trigger: { event: "demo.hello" },
handler: async ({ event, step }) => {
return step.run("greet", () => `hi ${event.data.name}`);
},
});
// 1. create a workspace in the dashboard (or POST /api/apps) → id + signing key
const client = new DurableClient({
baseUrl: "http://localhost:3030",
appId: process.env.APP_ID, signingKey: process.env.APP_KEY,
});
// 2. expose the callback (app.all: serve() answers POST invocations + GET manifest)
app.all("/api/durable", serve([hello], {
signingKey, baseUrl: "http://localhost:3030", appId: process.env.APP_ID,
}));
await client.sync([hello]);
// 3. fire events
await client.send({ name: "demo.hello", data: { name: "world" } });Next.js App Router
Import serveNext from @dicabrio/durable-sdk/next, export its GET and POST handlers, and set runtime = "nodejs". ConfiguremaxDuration above the function timeout.
npm run dev in the repo starts the full stack (Postgres in Docker, Adminer, service, a worker, and three demo apps) via process-compose.Concepts
the five nouns, and the replay model that makes them durable
| Term | Meaning |
|---|---|
| event | A named fact (user.created) with a JSON payload, sent into one app's workspace. |
| function | Your handler plus its trigger and options, registered via sync. |
| run | One execution of a function for one event. |
| step | A named checkpoint inside a run (step.run("send-email", …)) whose persisted result is replayed. |
| workspace | An app × environment pair — fully isolated data, functions and signing key. |
The replay model
The engine never runs your code. It POSTs the run's state — the triggering event plus all memoized step results — to your app. The SDK calls your handler from the top: completed steps return their stored results instantly (no side effects), and the first new step executes for real. Its result is persisted and the cycle repeats, one step per round-trip, until the function returns.
Because each invocation starts from the top, your handler must be deterministic between steps: put every side effect (DB write, API call, randomness, Date.now()) inside a step.run. A timeout can still occur after an external side effect but before its result is persisted, so provider idempotency keys remain required.
Run state, two layers
| Layer | Values | Question it answers |
|---|---|---|
| status | active · completed · failed · cancelled | Is the run finished? |
| activity | executing · queued · waiting · sleeping · scheduled | What is an active run doing right now? |
A run waiting seven days for an approval is active · waiting — alive, but consuming no compute and no worker slot.
Steps API
bounded durable primitives for sequential and parallel work
step.run(id, fn)
Execute a side effect once; the result is memoized and replayed forever after. A throw becomes a retry (exponential backoff, then the run fails).
const invoice = await step.run("create-invoice", () => billing.create(order));step.parallel(id, tasks)
Run 1–20 callbacks concurrently as one composite checkpoint. Multiple new step.run calls in Promise.allare rejected before their callbacks start. Parallel branches can all repeat if the composite response is lost, so external effects still need idempotency keys.
step.sleep(id, duration)
Durable pause — "90s", "12h", "30d" or milliseconds. No process waits; a timer wakes the run. Survives restarts and deploys.
step.waitForEvent(id, { event, match?, timeout })
Park the run until a matching event arrives, or the timeout elapses. match is a subset check against the incoming event.data. Resolves with the event, or null on timeout — human-in-the-loop in four lines:
const approval = await step.waitForEvent("approve", {
event: "approval.received",
match: { orderId: event.data.orderId },
timeout: "7d",
});
if (!approval) return { rejected: "timeout" };step.sendEvent(id, event)
Checkpoint and dispatch an event in one Durable transaction. Supply a stable event ID for safe fan-out. Outside workflows, client.sendMany(events)atomically sends up to 100 events. Identical retries return the original receipt.
Retries and failure handling
Set retries per function (retries after the initial attempt), throw NonRetriableError for permanent failures. As in Inngest, a terminal failure emits durable/function.failed andonFailure is shorthand for a separate durable handler receiving event, error,step and its own runId.
Triggers & cron
event-driven or on a schedule
trigger: { event: "order.paid" } // runs per matching event
trigger: { cron: "0 3 * * *" } // daily at 03:00
trigger: { cron: "*/20 * * * * *" } // 6-field: every 20 secondsCron runs receive a synthetic $cron event. Schedules never double-fire (row locks) and never storm after downtime — the next occurrence is always computed strictly in the future.
Flow control
multiple simultaneous local, keyed and cross-function shared constraints
| Option | Effect on a burst | Use for |
|---|---|---|
| concurrency | max N executing at once; excess queues | protecting APIs & resources |
| priority | higher starts sooner under contention | VIP tenants, critical work |
| throttle | starts spread over time; nothing dropped | external rate limits |
| rateLimit | excess runs dropped | abuse, duplicate webhooks |
| debounce | burst collapses to one run with the last event, after quiet | rapid saves → one reindex |
| batch | events grouped; one run gets the whole list | bulk writes, metric ingestion |
createFunction({
id: "sync-crm",
trigger: { event: "contact.changed" },
retries: 3,
timeout: "20s",
concurrency: [
{ limit: 2, key: "tenantId" }, // per tenant
{ limit: 20, scope: "all-imports" }, // across functions
],
priority: 10,
throttle: { limit: 1, period: "3s" },
rateLimit: { limit: 100, period: "1m" },
debounce: { period: "5s", key: "contactId" },
// batch: { maxSize: 25, timeout: "10s" } → event.data = the list
handler: async ({ event, step }) => { /* … */ },
});scopeshares capacity across functions in the same app. Keys are direct top-level fields in event.data, never expressions; precompute derived keys.Apps & environments
isolation is the default, environments are explicit
An app identity is (name, environment). Every combination is a fully isolated workspace: its own app_id, its own signing key, its own events, functions and runs. An event fired into billing · acc can never trigger billing · prod.
The environment defaults to dev. Pass it explicitly toprovisionApp or pick it when creating the workspace. The dashboard shows color-coded badges: dev grey, acc amber, prod red.
# authenticated provisioning (the dashboard is preferred in production)
curl -X POST :3030/api/apps \
-H 'content-type: application/json' \
-H 'authorization: Bearer $DURABLE_ADMIN_TOKEN' \
-d '{"name":"billing","environment":"prod","appUrl":"https://billing.example.com/api/durable"}'Workflow versions
Set an immutable version for incompatible changes. Runs stay pinned to IDs such as recipe@v1. Keep old code registered withenabled: false until its runs drain; only one version of a base function may accept new triggers. Query client.getVersionStatus()and remove code only when the disabled version reports safeToRemove.
Auth: every app→service call carries x-durable-app plus an HMAC-SHA256 signature over the raw body; service→app callbacks are signed with the same per-workspace key.
Registration
two ways for Durable to learn your functions — push or pull
// Option A — push: register explicitly (also great for CI/deploy)
await client.sync([hello]);
// Option B — pull: give Durable the app URL once; it fetches the manifest
// itself (on boot, when the URL is set, nightly, and via the dashboard button).
const { id, key } = await provisionApp({
baseUrl, appUrl, name: "billing", environment: "prod", adminToken,
});Push — client.sync(functions) posts your manifest to the service. Explicit and immediate; ideal from CI or a deploy step.
Pull — give Durable your app's URL once (the required appUrl, set when the workspace is created or edited in the dashboard). The service fetches the manifest from your app itself over a signed GET — so you don't orchestrate a sync call at all. It refreshes on service start, when the URL is set or changed, on a nightly schedule, and on the dashboard's Refresh button.
Both paths converge on the same registration (they share one manifest builder), so mixing them is safe — last write wins. If a pull can't reach your app, the existing functions are kept and the error is shown in the dashboard; a successful pull with fewer functions disables removed functions without deleting their run history. Retained disabled versions remain available to active sleeping/waiting runs.
Dashboard
realtime, app-centric, safe in production
- Realtime everywhere — Postgres NOTIFY → SSE push; every screen updates the moment data changes.
- Trace drawer — click a run: a waterfall per step, each bar split grey (durable queue/sleep time) vs green (your server's execution time); click a step for its input/output; expand for the exact split.
- Run actions — Rerun (from scratch), Rerun from step (steps before it are reused, the chosen step re-executes), Cancel.
- Registration — set an app's URL, hit Refresh to re-pull its functions, and see last-synced time (or the last pull error) at a glance.
- Metrics — throughput, failure rate, durable-delay vs app-time, per-function p95, and backlog depth over time (1h / 24h / 7d).
- Prod guards — in a prod workspace, Fire/Run/Cancel/Rerun arm on first click and execute only on a confirming second click.
- Production auth — set
DURABLE_ADMIN_TOKENand the dashboard, tRPC and SSE surface require sign-in (httpOnly session cookie or a Bearer token). Unset = open, for local dev.
PHP SDK
same replay model, dependency-free, PHP ≥ 8.1
use Durable\{Client, DurableFunction, Serve, Step};
$fn = new DurableFunction(
id: 'onboarding',
trigger: ['event' => 'user.created'],
handler: function (array $event, Step $step) {
$user = $step->run('load-user', fn () => loadUser($event['data']['id']));
$step->sleep('cooldown', '3s');
return $step->run('send-email', fn () => sendMail($user));
},
);
// callback endpoint (vanilla PHP, Laravel, Symfony — anything):
Serve::handle([$fn], $signingKey);
// register + fire:
$client = new Client($baseUrl, $appId, $key);
$client->sync([$fn]);
$client->send('user.created', ['id' => 'u1']);All function options (concurrency, priority, throttle, rateLimit, debounce, batch) are supported with human-readable periods ('3s', '7d'). See sdk-php/example/ for a runnable app on PHP's built-in server.
Operations
scaling, shutdown, configuration
Scaling workers
Production separates API, queue execution, scheduling and migration. Workers combine exact multi-job claims with LISTEN/NOTIFY wakeups; polling remains the durable fallback:
durable migrate
durable serve # API, dashboard, realtime, /metrics
durable worker # queue execution
durable scheduler # timers, cron, cleanup, failure deliveryGraceful drain
On SIGINT/SIGTERM the service stops pulling new jobs, finishes what's in flight (bounded by DURABLE_DRAIN_TIMEOUT_MS, default 15s), then exits. On timeout, abandoned jobs recover via lease expiry — nothing is lost either way. Every claim gets a new lease token, so a late response from an expired callback cannot commit. A second signal forces exit.
Observability and failure delivery
/metrics exposes Prometheus queue age/depth, callback, retry, concurrency, stuck-run, process-heartbeat and failure-delivery metrics. Terminal failures are written to a dead-letter outbox and optionally delivered as signed, idempotent webhooks even when onFailure itself fails.
Environment variables
| Variable | Default | Purpose |
|---|---|---|
| PORT | 3030 | service + dashboard port |
| DATABASE_URL | — | Postgres connection (unused with embedded PG) |
| DURABLE_PG_PORT / DURABLE_PG_DIR | 5434 / ~/.durable/pgdata | embedded Postgres |
| DURABLE_DEV_APP_URL | http://localhost:3000/api/durable | local endpoint discovered by durable dev |
| DURABLE_WORKERS | 2 | worker loops per process |
| DURABLE_CALLBACK_TIMEOUT_MS | 30000 | default callback/checkpoint timeout |
| DURABLE_MANIFEST_TIMEOUT_MS | 10000 | pull-registration timeout |
| DURABLE_MAX_BODY_BYTES | 1048576 | inbound JSON/invocation limit |
| DURABLE_MAX_RESPONSE_BYTES | 1048576 | callback/manifest response limit |
| DURABLE_MAX_ATTEMPTS | 3 | total attempts when a function omits retries |
| DURABLE_DRAIN_TIMEOUT_MS | 15000 | graceful-drain bound |
| DURABLE_ADMIN_TOKEN | unset locally | required when NODE_ENV=production |
| DURABLE_METRICS_TOKEN | unset | optional Bearer token for /metrics |
| DURABLE_RETENTION_*_DAYS | 0 | terminal history retention; zero disables cleanup |
Wire protocol
small enough to port an SDK in an afternoon
One HTTP round-trip advances a run by at most one new step. All bodies are JSON; every request and response is signed: x-durable-signature: hex(hmac_sha256(rawBody, key)), app→service calls also send x-durable-app: <appId>.
Service → app (invoke)
POST {stored callback URL}
{ "runId": "…", "functionId": "onboarding",
"event": { "id": "…", "name": "user.created", "data": { … } },
"steps": { "load-user": { "type": "run", "data": { … } } } }App → service (the next operation reached)
{ "op": "step", "id": "send-email", "data": … }
{ "op": "sendEvent", "id": "fanout", "events": [ … ] }
{ "op": "sleep", "id": "cooldown", "until": "2026-07-05T09:00:00.000Z" }
{ "op": "wait", "id": "approve", "event": "approval.received",
"match": { "orderId": "o1" } | null, "until": "…" }
{ "op": "done", "data": … }
{ "op": "error", "id": "send-email" | null, "message": "…", "retryable": true }App → service (management)
POST /e { "id": "stable-idempotency-key", "name": "user.created", "data": { … } }
POST /e/bulk { "events": [ { "id": …, "name": …, "data": … } ] }
POST /runs/* // app-scoped search, get, cancel and idempotent rerun
POST /fn/sync { "functions": [ { "id", "trigger", …options } ] } # callback URL comes from the app record
GET {stored callback URL} → { "functions": [ … ] } # pull: service fetches the manifest (signed timestamp)
POST /api/apps { "name": "billing", "environment": "dev", "appUrl": "https://app/api/durable" } # provision (admin)That's the whole surface an SDK needs: sign, sync, send, and answer invokes with one of six ops. TypeScript and PHP adapters implement the same signed contract.