Skip to content

Scaling & concurrency

Copy page

Nizhal is offline-first, which changes the scaling math: the client’s local SQLite is the source of truth, and the realtime ping is only a hint — the cursor pull is always authoritative. A dropped socket, a missed ping, or a briefly-overloaded server degrades to “syncs a moment later,” not “data lost.” That property is what lets you scale each piece independently.

AxisWhat it isHow it scalesFirst bottleneck
HTTP pull/pushStateless /sync/pull + /sync/pushHorizontally — add stateless instances behind a load balancerThe database behind it
Database (Postgres)postgresStorage — columns + triggers, no logical replicationVertical first, then read replicas / partition by tenantWrite throughput on getChanges + push
Realtime fan-outThe contentless repull poke per bucketIn-process → LISTEN/NOTIFY (multi-instance) → edge DO (per-bucket)Process count, then NOTIFY volume
WebSocket connectionsLive sockets held open per deviceOne process caps out; Cloudflare DOs spread connections globallyOpen-FD / memory on a single Node process

Because pull is authoritative, you can always shed realtime load: drop the ping cadence and lean on pull.intervalMs (a periodic safety pull). Correctness never depends on socket liveness.

These are starting points, not hard limits — measure against your own write rate and concurrent-device count.

Tier 1 — single region, up to ~a few thousand devices

Section titled “Tier 1 — single region, up to ~a few thousand devices”
  • 1 Node process (createNizhalServer) + 1 managed Postgres (Neon/RDS/Supabase).
  • inProcessRealtime (default).
  • Durable client persistence on both platforms.
  • This is the Node topology. Scale the box vertically before adding instances.

Tier 2 — multi-instance, one region, tens of thousands of devices

Section titled “Tier 2 — multi-instance, one region, tens of thousands of devices”
  • N stateless Node instances behind a load balancer (sticky sessions not required — pull/push are stateless; WS reconnects rebalance naturally).
  • listenNotifyRealtime so every instance hears every commit through Postgres — no Redis/NATS needed.
  • Postgres is now the ceiling: move to a larger instance, add read replicas for /sync/pull, and consider partitioning by tenant (ownerId) if one tenant is hot.
  • Watch NOTIFY volume; if commit rate is very high, batch or move realtime to the edge (Tier 3).

Tier 3 — global / very high concurrency (100k+ live sockets)

Section titled “Tier 3 — global / very high concurrency (100k+ live sockets)”
  • Cloudflare for realtime: one Durable Object per bucket holds that room’s sockets, at the edge, close to users. Connection count scales with DO count, not a single process’s FD limit. Hibernation keeps idle rooms cheap.
  • HTTP can stay on Node/serverless with Postgres, driving the edge via cloudflareHttpRealtime; or go full-Workers with cloudflareRealtime. See deployment topologies.
  • Keep the cursor pull authoritative — even at this scale a missed edge poke self-heals on the next pull.

All on createNizhalServer({ limits, presence }) — safe defaults shown:

KnobDefaultTune when
limits.rateLimit120 sync requests / actor / minute (sliding window, keyed ownerId:userId)Raise for chatty clients; lower to protect Postgres under abuse. Returns 429 when exceeded. false disables.
limits.maxBodyBytes1 MiBRaise for large batched pushes; keep tight to bound memory. Returns 413.
presence.heartbeatTimeoutMs30sLower to evict dead presence faster; raise to tolerate flaky networks.
  • pull.intervalMs — a periodic safety pull. Lower = faster convergence if realtime is dropped, but more load. This is your realtime-load relief valve.
  • reconnect (minDelayMs/maxDelayMs) — the WebSocket source already does exponential backoff + full jitter, so thousands of clients reconnecting after a deploy don’t thunder-herd the server. Widen the cap to spread reconnect storms further.
  • Heartbeat — the client pings idle sockets and reconnects on a missed pong, so half-open connections (mobile NAT timeouts, proxy idle-kills) are detected instead of silently stalling.
  • Reads dominate. Most traffic is /sync/pull (bootstrap + catch-up). Add read replicas and cache-friendly cursors before scaling writes.
  • Buckets are the unit of fan-out. A repull wakes everyone in a bucket. Keep buckets scoped (per shop / per workspace), not global — a global bucket re-pulls every device on every write.
  • Offline writes batch on reconnect. A device offline for an hour flushes its whole outbox at once; size maxBodyBytes and rate limits so a reconnect burst doesn’t 413/429 itself into the dead-letter queue.
  • Measure getChanges. It’s the hot server path; the load benchmark (apps/emulation) and validation suite exercise it under partition/restart/poison conditions.