Durable execution · TypeScript + Postgres

Kill the worker.
Keep the workflow.

Write a multi-step process as a plain async function. When the machine running it dies halfway, another worker replays the event history and carries on, without charging the card twice.

orderSagaORD-7K2Q
  1. reserveInventory
  2. chargeCard
  3. sleep(4s)
  4. child: fulfillment
  5. sendConfirmation

working effect applied, not yet recorded

Worker Aidle
waiting for work
Worker Bidle
waiting for work
External world what really happened
inventory.reserve0
payments.charge0
carrier.dispatch0
email.send0
retries recognised and ignored0
Event history Postgres, append-only
    Press Run & crash it for me and Worker A dies right after the card is charged, before that fact is recorded. Or start it yourself and kill Worker A while a step's bar is amber.

    This demo is a simulation. For the real engine on a real Postgres, open the playground below, or run it yourself with one command.

    Playground · real Postgres, in your browser

    Don't take our word for it.
    Run the actual engine.

    The demo above is a simulation. This isn't. It's the real durable-flow engine running on a real Postgres database (compiled to WebAssembly) inside this page. Edit the workflow, kill workers, and query the very tables the engine writes to. Nothing leaves your browser. Killing a worker here is simulated: it stops heartbeating and writing, which the engine can't tell apart from a dead process.

    durable-flowonnot started

    Boot a Postgres in this tab

    Downloads Postgres compiled to WebAssembly (about 15 MB unpacked, cached after the first visit), creates the engine's tables, and starts two workers. No server, no sign-up.

    Workers
    Signal
    Nothing running yet.
    02How it works

    Your code is the workflow.
    The history is the truth.

    async function orderSaga(ctx, order) {
      const saga = ctx.saga()
      try {
        await ctx.activity('reserveInventory', order)
        saga.add('releaseInventory', order)
    
        await ctx.activity('chargeCard', order)
        saga.add('refundCard', order)
    
        await ctx.sleep(4000)            // durable
        await ctx.child('fulfillment', order)
        await ctx.activity('sendConfirmation', order)
      } catch (err) {
        await saga.compensate()          // refund, release
        throw err
      }
    }
    1. 1

      Record

      Every ctx.* call becomes an event in Postgres: scheduled, started, completed, failed, timer fired. No serialized stack to corrupt.

    2. 2

      Replay

      A worker re-runs your function from the top. Calls already in history return their recorded results instantly. The first call without one is where real work resumes.

    3. 3

      Resume

      Workers hold a lease they must keep renewing. If one dies, the lease expires, another claims the workflow at a higher epoch, and the dead worker's late writes are rejected.

    03What it does

    Everything a workflow needs
    to outlive its process.

    • replay

      Deterministic replay

      Divergence is detected and parked as a stuck workflow, never silently corrupted.

    • crash

      Crash recovery

      Leases, heartbeats and epoch fencing: a dead worker's lease expires, another takes over, and the dead one's late writes are rejected. Tested by simulating a crash at the worst moment.

    • time

      Durable timers

      ctx.sleep holds no worker while it waits. Any worker wakes it when it's due.

    • retry

      Retries

      Attempts, backoff, jitter and non-retryable errors per activity. Every attempt is an event.

    • saga

      Compensation

      Undo steps run in reverse as recorded, retried, idempotent activities.

    • flow

      Signals and children

      Park on a human approval, fan out child workflows, query live state.

    • ship

      Safe code changes

      ctx.patched() versions in-flight runs. A fuzzer finds hidden clock and RNG use.

    • see

      Observable

      Every step is a row in Postgres you can query with plain SQL, and the playground shows the live event history. A visual timeline and inspector are in progress.

    • scale

      Bounded history

      continueAsNew hands state to a fresh run so long loops never replay forever.

    04Use cases

    If it can't half-finish
    or run twice, it's a workflow.

    Reach for it when work takes more than a few seconds, touches more than one system, or would hurt if it stopped midway or repeated itself.

    • Checkout and order fulfilment

      Reserve stock, charge the card, ship, send the receipt. If shipping fails, refund and release stock automatically, in reverse order.

      activitysagachild workflow
    • Subscriptions and failed-payment recovery

      Retry a declined card on day 1, 3 and 7, then cancel. The schedule lives in the workflow, not in a cron table you have to babysit.

      sleepretries
    • Onboarding with human approval

      Create the account, then wait days for a manager to approve. The workflow just sits there, costing nothing, until a signal arrives.

      signalsleep
    • Reminders and trial expiry

      "Email them in 3 days unless they upgrade." One line of code, and it survives deploys and restarts.

      sleepsignal
    • AI and LLM pipelines

      A multi-step agent where every model call costs money. Finished steps are replayed from history instead of being paid for again after a crash.

      activityretries
    • Data pipelines and ETL

      Fetch, transform, load, with backoff on flaky sources. A job that dies at row 40,000 resumes there, and continueAsNew keeps long runs light.

      retriescontinueAsNew
    • Provisioning and rollbacks

      Create a database, a bucket, a DNS record. If step four fails, tear down one to three in reverse instead of leaving orphans.

      sagaactivity
    • Document and media processing

      Fan out one child workflow per file, wait for all of them, then assemble the result.

      child workflowretries

    Who it's for

    TypeScript backend teams

    You already run Postgres and have multi-step jobs that must not half-finish or repeat. You'd rather not stand up a new cluster just to get durability.

    Teams evaluating durable execution

    A small, readable engine to prototype against before committing to a bigger platform. The same ideas in about 1,200 lines.

    Anyone learning how it works

    Ten files. Read replay.ts, run the tests, kill a worker. The fastest way to really understand determinism, leases and idempotency.

    05Get started

    Run it in five minutes.
    Nothing to sign up for.

    1. 1

      What you need

      Node.js 20 or newer (built and tested on Node 24) and git. You do not need Docker or a Postgres install for the quickstart: it pulls a real Postgres binary through the embedded-postgres package and throws it away afterwards.

    2. 2

      Get the code

      terminal
      $ git clone https://github.com/karmic05/durable-flow.git
      $ cd durable-flow
      $ npm install
    3. 3

      Watch it survive a crash

      This starts a throwaway Postgres, runs a 3-step checkout workflow, simulates a hard crash of Worker A right after it charges the card, and lets Worker B finish the job.

      terminal
      $ npm run quickstart

      You'll see this (real output):

      output
      ▶ Worker A starts
         📦 reserved stock for 1001
         💳 charged $49 (key order-1001:2)
         💥 Worker A killed: the card WAS charged, but the result was never recorded
      
      ▶ Worker B starts (A is dead, its lease expires, B takes over)
         ↩︎  charge ignored, key order-1001:2 already seen
         ✉️  receipt sent for 1001
      
      ✔ completed: "order 1001 complete"
      
      Event history (what Postgres stored):
         1  WorkflowStarted
         2  ActivityScheduled  reserveStock
         3  ActivityStarted    #1
         4  ActivityCompleted  #1
         5  ActivityScheduled  chargeCard
         6  ActivityStarted    #2
         7  WorkerTakeover     A → B
         8  ActivityStarted    #2
         9  ActivityCompleted  #2
        10  TimerStarted       #3
        11  TimerFired         #3
        12  ActivityScheduled  sendReceipt
        13  ActivityStarted    #4
        14  ActivityCompleted  #4
        15  WorkflowCompleted
      
      Card charged 1× for 1 order.  No duplicate side effects.

      Read it top to bottom: the charge happened once (row 5–6), Worker A died, B took over (row 7), re-ran the step, the service recognised the idempotency key and ignored the repeat, and the workflow finished.

    4. 4

      Run the test suite

      terminal
      $ npm test

      29 tests: replay determinism, non-determinism detection, the same crash-in-the-worst-spot scenario, a zombie worker being fenced out, retries and backoff, durable timers, sagas, signals, child workflows, continueAsNew and ctx.patched() versioning.

    5. 5

      Write your own workflow

      Define workflows and activities, start one from your app, and run workers as separate processes (as many as you like).

      import { Registry, Store, Worker } from '@durable-flow/engine'
      
      const store = new Store(process.env.DATABASE_URL!)   // any Postgres
      await store.migrate()                                // creates its tables, safe to re-run
      
      const registry = new Registry()
        .workflow('welcome', async (ctx, user) => {
          await ctx.activity('createAccount', user)      // recorded; retried on failure
          await ctx.sleep(24 * 60 * 60 * 1000)           // sleeps a day, holds no worker
          await ctx.activity('sendTips', user)
          return 'done'
        })
        .activity('createAccount', async (user, ac) => {
          // call your API here. Pass ac.idempotencyKey along so a retry can't double-apply.
        })
        .activity('sendTips', async (user, ac) => { /* ... */ })
      
      // start one, e.g. from your signup handler
      await store.createWorkflow({ id: `welcome-${user.id}`, type: 'welcome', input: user })
      
      // run a worker, in its own process
      await new Worker({ id: 'worker-1', store, registry }).start()
      Honest note on packaging. It isn't on npm yet. Today, add your workflows next to examples/ in the cloned repo, or copy packages/engine/src (10 files, about 1,200 lines) into your project. An npm package is the obvious next step.
    6. 6

      Use your own Postgres

      Point the store at any Postgres database (tested on 17 and 18; Neon, Supabase, RDS and local should all work). Workers can run on any machine that can reach it; the database is the only shared state.

      terminal
      $ export DATABASE_URL=postgres://user:pass@host:5432/dbname

    The API at a glance

    ctx.activity(name, input, opts?)Run a side effect. Recorded, retried with backoff, given an idempotency key.
    ctx.sleep(ms)A durable timer. Survives restarts and holds no worker while it waits.
    ctx.waitForSignal(name)Pause until store.signal(id, name, payload) arrives, e.g. a human approval.
    ctx.child(type, input)Run another workflow and await its result.
    ctx.saga().add(undoActivity, input) as you go, .compensate() on failure. Runs undos in reverse.
    ctx.now() · random() · uuid()Deterministic replacements. Recorded once, identical on every replay.
    ctx.patched(id)Change workflow code safely while old runs are still in flight.
    ctx.continueAsNew(input)Hand state to a fresh run so long loops keep a short history.
    ctx.setQueryHandler(name, fn)Expose live workflow state for inspection.
    What's still in progress. The React dashboard (history timeline, state inspector, chaos mode) and a one-command hosted server are not finished. The interactive demo at the top of this page is a simulation; the playground, the engine, the tests and the quickstart are the real thing.
    06Fine print

    Not magic.
    Here's what it isn't.

    Exactly-once execution

    At-least-once, plus idempotency

    If a worker dies after the side effect but before the result is recorded, the activity runs again. You get exactly-once effects only if the service you call honours the key. Flip the switch in the demo to see the alternative.

    Makes your code deterministic

    You keep that promise

    Workflow functions must not read the clock, call Math.random() or touch the network. Use ctx.now(), ctx.random() and activities. The fuzzer finds mistakes; it can't prove there are none.

    A Temporal replacement

    A working implementation of the idea

    One Postgres, polling workers, TypeScript only. No sharding, multi-region, multi-tenancy or auth, and none of the production hardening.

    Low-latency

    Built for seconds to days

    Each step is a database round-trip plus a replay. Great for business processes, wrong for request handlers.

    Free replay

    Linear in history length

    Long-lived workflows need continueAsNew to reset the history.

    Fire-and-forget

    Await everything that matters

    An activity you start but never await is dropped when the workflow ends.

    The contract

    1. Workflows are deterministic. Same history in, same commands out.
    2. Activities are idempotent. Use the key; assume you'll be called twice.
    3. The history is the truth. If it isn't an event, it didn't happen.