Skip to content

Production deployment

Copy page

The demo apps run on PGlite + an in-process realtime hub + a long-lived demo token. That’s perfect for a laptop and wrong for production. This page is the gap: what to change, how to deploy, and the failure modes that only show up with real users.

ConcernDemoProduction
DatabasePGlite (in-memory, single process, lost on restart)A real Postgres (Neon / RDS / Supabase / self-hosted). See Managed Postgres.
Auth tokensOne long-lived token from /demo/session, never refreshedShort-lived bearer tokens + a refresh path (see below)
RealtimeinProcessRealtime (single process only)Pick by topology — listenNotifyRealtime (multi-instance) or Cloudflare (edge). See Realtime.
SecretsHard-coded SECRET in the demo serverNIZHAL_JWT_SECRET (and CF NIZHAL_PUBLISH_SECRET) from the environment / secret store
CORScors: true (any origin)An explicit allow-list of your app origins
LimitsDefaults (1 MiB body, 120 req/actor/min)Tune per your traffic — see Scaling
Persistence (client)Web in-memory / op-sqliteDurable on both: waSqlitePersistence (web, OPFS) + opSqlitePersistence (RN) so writes survive reload/restart

issueBearerToken defaults to a 1-hour TTL. The realtime/HTTP transports send that token on every pull/push. When it expires:

  1. /sync/push and /sync/pull return 401.
  2. A 401 is a 4xx → the durable outbox treats it as a deterministic client error and parks the write in the dead-letter queue (poison-quarantine, by design — it does not blindly retry 4xx).
  3. The write is now stuck locally and will not auto-flush, even after the user gets a fresh token.

So a long-open tab or a backgrounded app silently stops syncing. Fix it two ways, together:

  • Wire auth.refresh on the client so a 401 transparently rotates the token and retries:

    createNizhalClient({
    server,
    auth: {
    getHeaders: () => ({ Authorization: `Bearer ${currentToken}` }),
    refresh: async () => {
    currentToken = await fetchFreshToken(); // your endpoint
    return { Authorization: `Bearer ${currentToken}` };
    },
    },
    // ...
    });

    The HTTP transport calls refresh() on a 401 and retries; the WebSocket source re-reads auth on every reconnect, and runs refresh on a connect that fails before opening (the upgrade-time auth failure mode).

  • Set a token TTL comfortably longer than your worst-case reconnect window (issueBearerToken({ ..., expiresInSec })). With backoff capped at ~10s, a few-minute TTL has plenty of headroom; pair it with refresh for sessions longer than the TTL.

Nizhal splits into two independent surfaces — HTTP (/sync/pull, /sync/push, /nizhal/contract) and realtime (a contentless repull poke; the cursor pull is always authoritative). You can scale and host them separately.

One createNizhalServer process, postgresStorage, inProcessRealtime. Simplest correct setup; handles real traffic for a single region. See Node.js.

const server = createNizhalServer({
storage: postgresStorage({ connectionString: process.env.DATABASE_URL }),
secret: process.env.NIZHAL_JWT_SECRET,
cors: { origin: ["https://app.example.com"] },
// realtime defaults to inProcessRealtime
});

2. Multi-instance Node + Postgres LISTEN/NOTIFY

Section titled “2. Multi-instance Node + Postgres LISTEN/NOTIFY”

inProcessRealtime only fans out within one process — a second instance won’t see the first’s commits. Swap in listenNotifyRealtime so every instance hears every commit through Postgres. No extra infra (no Redis), just your existing database.

createNizhalServer({
storage: postgresStorage({ connectionString: DATABASE_URL }),
realtime: listenNotifyRealtime({ connectionString: DATABASE_URL }),
secret: process.env.NIZHAL_JWT_SECRET,
});

3. Serverless HTTP (Vercel) + Cloudflare realtime

Section titled “3. Serverless HTTP (Vercel) + Cloudflare realtime”

Serverless functions can’t hold WebSockets, so split: host the HTTP surface on Vercel (Fluid/serverless + external Postgres) and run realtime on a Cloudflare worker. The Node/serverless server drives the edge DOs over the publish bridge with cloudflareHttpRealtime:

createNizhalServer({
storage: postgresStorage({ connectionString: DATABASE_URL }),
realtime: cloudflareHttpRealtime({
publishUrl: "https://nizhal-realtime.acme.workers.dev/_nizhal/publish",
publishSecret: process.env.NIZHAL_PUBLISH_SECRET,
}),
});

Server-on-Workers + cloudflareRealtime (direct Durable Object RPC). One DO per bucket is the realtime hub; clients hold a standard WebSocket to /parties/nizhal-bucket/<bucket>. Best for global low-latency and very high connection counts. See Cloudflare and when to use it.

Terminal window
pnpm --filter @nizhal/server build
cd packages/server
wrangler deploy -c src/adapters/cloudflare/wrangler.jsonc
wrangler secret put NIZHAL_JWT_SECRET # verifies the WS upgrade ?token=
wrangler secret put NIZHAL_PUBLISH_SECRET # authorizes the server→DO publish bridge

Configure NIZHAL_AUTHORIZATION_URL (or a service binding) so the worker authorizes each room join against your server, and the DO re-checks before every ping (fails closed if unset). The full path is verified end-to-end on real workerd + DOs by packages/server/examples/run-cf-e2e.sh.

  • Run the chaos suite (pnpm chaos) against your real Postgres (NEON_URL=...) — 19 adversarial scenarios incl. server restart mid-outbox, dropped realtime, poison dead-letter, revocation. See Validation.
  • Exercise offline → online on a real device: write while offline, confirm the outbox flushes on reconnect; take a device down, add data elsewhere, bring it up and confirm catch-up.
  • Confirm token refresh works: let a token expire (or shorten TTL) and verify writes still flush instead of dead-lettering.

Then size it: Scaling & concurrency.