Skip to content

Deployment Guide ​

Choose the right Durably mode for your application.

Three Modes ​

ServerFullstackSPA
Jobs run onNode.jsNode.jsBrowser
Storagelibsql / better-sqlite3Same (server-side)SQLite WASM + OPFS
UINone (CLI, cron, API)React via SSEReact (local)
OfflineNoNoYes
Multi-userYes (API)Yes (SSE)No (single tab)
Package@coji/durably+ @coji/durably-react+ @coji/durably-react

Decision Flowchart ​

Do you need a UI?
├── No → Server Mode
│   (cron, CLI, data pipelines)
│
└── Yes → Does data need to stay on the user's device?
    ├── Yes → SPA Mode
    │   (offline-first, local-only)
    │
    └── No → Fullstack Mode
        (most web apps)

Server Mode ​

Use for: cron jobs, data pipelines, CLI tools, microservices.

ts
import { createDurably } from '@coji/durably'

const durably = createDurably({ dialect, jobs: { ... } })
await durably.init()

// Trigger and wait
const { output } = await durably.jobs.myJob.triggerAndWait({ ... })

// Or trigger and let the worker handle it
await durably.jobs.myJob.trigger({ ... })

No HTTP handler, no React — just @coji/durably. See Server Mode guide.

Production Tips ​

  • Use Turso or a remote libsql URL for persistent storage
  • Set longer intervals in production to reduce DB load:
    ts
    createDurably({
      dialect,
      pollingIntervalMs: 1000, // default, good for production
      maxConcurrentRuns: 1, // default; raise to process multiple runs concurrently
      leaseRenewIntervalMs: 5000, // default
      leaseMs: 30000, // default
    })
  • Use concurrencyKey to prevent parallel runs of the same job
  • Use idempotencyKey for deduplication in cron/webhook triggers

Fullstack Mode ​

Use for: most web apps with background jobs and real-time UI.

Two packages:

  • @coji/durably — server-side: jobs, worker, HTTP handler
  • @coji/durably-react — client-side: type-safe hooks via SSE
app/lib/durably.server.ts  →  createDurably + createDurablyHandler
app/lib/durably.ts          →  createDurably<typeof server>({ api })
app/routes/api.durably.$.ts →  splat route

See Fullstack Mode guide.

Framework Support ​

FrameworkRoute Pattern
React Router/Remixapp/routes/api.durably.$.ts
Next.jsapp/api/durably/[...path]/route.ts
Honoapp.all('/api/durably/*', ...)

See HTTP Handler for full examples.

Production Tips ​

  • Add auth middleware for any public-facing deployment
  • Use onRequest for lazy initialization:
    ts
    createDurablyHandler(durably, {
      onRequest: async () => {
        await durably.init() // Safe to call multiple times
      },
    })
  • SSE progress events are throttled at 100ms by default (configurable via sseThrottleMs)

SPA Mode ​

Use for: offline-capable apps, local-first apps, prototyping.

Everything runs in the browser. Uses SQLocal for SQLite WASM with OPFS.

ts
import { createDurably } from '@coji/durably'
import { DurablyProvider, useJob } from '@coji/durably-react/spa'

See SPA Mode guide.

Requirements ​

  • HTTPS or localhost (OPFS needs Secure Context)
  • COOP/COEP headers (SharedArrayBuffer)
  • Single tab only (OPFS exclusive access)

Production Tips ​

  • Set shorter intervals for responsive UX:
    ts
    createDurably({
      dialect: sqlocal.dialect,
      pollingIntervalMs: 100,
      maxConcurrentRuns: 1, // default; raise to process multiple runs concurrently
      leaseRenewIntervalMs: 500,
      leaseMs: 3000,
    })
  • For Vercel/Netlify, add headers via config:
    json
    {
      "headers": [
        {
          "source": "/(.*)",
          "headers": [
            { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" },
            { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" }
          ]
        }
      ]
    }

Job Versioning and Deploys ​

When you deploy updated job definitions, in-flight runs may be affected. Durably matches steps by name (not index), which makes most changes safe.

Safe Changes (no special handling) ​

ChangeWhat happens
Add new stepsNew step has no cached data, runs normally
Change step logicCompleted steps return cached output, new steps run new code
Reorder stepsName-based matching, order doesn't matter
Delete stepsOld step data is ignored

Risky Changes (be careful) ​

ChangeRiskMitigation
Rename a stepOld cached output doesn't match — step re-executesSafe if the step is idempotent
Change step output typeOld cached output returned with wrong typeRename the step so it re-executes
Change input schemaPending runs have old-format inputretrigger() validates against current schema and throws if incompatible

Breaking Changes (cancel first) ​

For these, cancel pending and leased runs before deploying:

ts
// Cancel all pending and leased runs for a job before deploy
const pending = await durably.jobs.myJob.getRuns({ status: 'pending' })
const leased = await durably.jobs.myJob.getRuns({ status: 'leased' })
for (const run of [...pending, ...leased]) {
  await durably.cancel(run.id)
}
  • Renaming a job — old runs reference the former name and become orphaned
  • Fundamental logic rewrite — in-flight runs may produce incorrect results

General Guidance ​

  • Steps should be idempotent — re-execution after deploy is always safe if steps don't have side effects beyond their return value
  • No version pinning — Durably does not pin runs to a specific code version. New code applies to all in-flight runs on the next step execution
  • Durably uses retrigger() (not retry) to re-run failed jobs. retrigger() validates the input against the current schema, so stale runs with incompatible input are caught early

Migrating Between Modes ​

Server → Fullstack ​

  1. Add @coji/durably-react
  2. Add createDurablyHandler + splat route
  3. Create client with createDurably<typeof server>({ api })
  4. Job definitions stay the same

Fullstack → SPA ​

  1. Replace server dialect with SQLocal
  2. Move job registrations to browser code
  3. Switch imports from @coji/durably-react to @coji/durably-react/spa
  4. Wrap app with DurablyProvider
  5. Job definitions stay the same

Next Steps ​

Released under the MIT License.