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/durable

This 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-sdk
import { 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.

Also available: 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

TermMeaning
eventA named fact (user.created) with a JSON payload, sent into one app's workspace.
functionYour handler plus its trigger and options, registered via sync.
runOne execution of a function for one event.
stepA named checkpoint inside a run (step.run("send-email", …)) whose persisted result is replayed.
workspaceAn 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

LayerValuesQuestion it answers
statusactive · completed · failed · cancelledIs the run finished?
activityexecuting · queued · waiting · sleeping · scheduledWhat 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 seconds

Cron 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

OptionEffect on a burstUse for
concurrencymax N executing at once; excess queuesprotecting APIs & resources
priorityhigher starts sooner under contentionVIP tenants, critical work
throttlestarts spread over time; nothing droppedexternal rate limits
rateLimitexcess runs droppedabuse, duplicate webhooks
debounceburst collapses to one run with the last event, after quietrapid saves → one reindex
batchevents grouped; one run gets the whole listbulk 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 }) => { /* … */ },
});
Policy interaction: batch and debounce are aggregation modes. Registration rejects combinations that would silently bypass rate-limit/throttle policies. Multiple concurrency rules must all have capacity; a named 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,
});

Pushclient.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_TOKEN and 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 delivery

Graceful 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.

Self-hosting responsibility: Durable supplies process health, migration locking, queue correctness and graceful drain. Operators still own PostgreSQL HA/PITR, backup restore drills, load balancing, capacity, alerts, secrets/TLS and upgrades. Embedded PostgreSQL is for local development, not production HA.

Environment variables

VariableDefaultPurpose
PORT3030service + dashboard port
DATABASE_URLPostgres connection (unused with embedded PG)
DURABLE_PG_PORT / DURABLE_PG_DIR5434 / ~/.durable/pgdataembedded Postgres
DURABLE_DEV_APP_URLhttp://localhost:3000/api/durablelocal endpoint discovered by durable dev
DURABLE_WORKERS2worker loops per process
DURABLE_CALLBACK_TIMEOUT_MS30000default callback/checkpoint timeout
DURABLE_MANIFEST_TIMEOUT_MS10000pull-registration timeout
DURABLE_MAX_BODY_BYTES1048576inbound JSON/invocation limit
DURABLE_MAX_RESPONSE_BYTES1048576callback/manifest response limit
DURABLE_MAX_ATTEMPTS3total attempts when a function omits retries
DURABLE_DRAIN_TIMEOUT_MS15000graceful-drain bound
DURABLE_ADMIN_TOKENunset locallyrequired when NODE_ENV=production
DURABLE_METRICS_TOKENunsetoptional Bearer token for /metrics
DURABLE_RETENTION_*_DAYS0terminal 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.