Scaling on Cloudflare (Hyperdrive + Durable Objects)
Copy page
The Cloudflare topology puts every layer at the edge: Workers serve the HTTP sync surface, Durable Objects are the per-bucket realtime hubs, and Hyperdrive accelerates your existing Postgres so the read-heavy pull path stays fast globally. This is the highest-concurrency, lowest-latency way to run Nizhal — see when to reach for it first; for most apps a Node server is simpler.
The shape
Section titled “The shape”Browser / RN ──HTTP (pull/push)──▶ Worker ──Hyperdrive──▶ Postgres (Neon/RDS/…) └──────WebSocket───────▶ NizhalBucket Durable Object (one per bucket) ▲ repull poke server commit ───────────┘ (cloudflareRealtime, direct DO RPC)- Compute —
createNizhalServerruns inside the Worker (Hono is Workers-native). - Realtime —
cloudflareRealtimeRPCs oneNizhalBucketDurable Object per bucket; clients hold a standard WebSocket to/parties/nizhal-bucket/<bucket>. Verified on realworkerdbyrun-cf-e2e.sh. - Database — your Postgres, reached through Hyperdrive (pooling + caching) instead of a raw connection.
Why Hyperdrive matters here
Section titled “Why Hyperdrive matters here”Workers are stateless and globally distributed, so a naive Postgres connection pays full setup latency on every isolate and can exhaust your database’s connection slots. Hyperdrive fixes both: it pools connections near your database, sets up connections near your Worker, and caches read query results — exactly the profile of Nizhal’s hot path, where most traffic is /sync/pull → getChanges (reads) and writes go through /sync/push.
Wiring it
Section titled “Wiring it”postgresStorage takes any postgres.js client, so point it at the Hyperdrive binding:
import postgres from "postgres";import { createNizhalServer } from "@nizhal/server";import { postgresStorage } from "@nizhal/server/adapters";import { cloudflareRealtime } from "@nizhal/server/adapters/cloudflare";
export default { async fetch(request: Request, env: Env, ctx: ExecutionContext) { const sql = postgres(env.HYPERDRIVE.connectionString, { max: 5, // Workers cap concurrent external connections at ~6 fetch_types: false, // skip a round-trip if you don't use array types }); const server = createNizhalServer({ storage: postgresStorage({ client: sql }), secret: env.NIZHAL_JWT_SECRET, realtime: cloudflareRealtime(env), // direct DO RPC cors: { origin: ["https://app.example.com"] }, }); return server.app.fetch(request, env, ctx); },} satisfies ExportedHandler<Env>;Bind Hyperdrive and the DO in wrangler.jsonc (alongside the NizhalBucket binding the realtime worker already declares).
The caching caveat (read this)
Section titled “The caching caveat (read this)”Hyperdrive caches read query results for a short TTL. Nizhal’s getChanges is parameterized by the client cursor, so each advancing cursor is a distinct query — caching mostly helps when many devices sit at the same cursor (e.g. a bucket that just settled), and never caches writes (/sync/push). Two things make this safe:
- A briefly-stale pull is not a correctness problem — Nizhal is offline-first, the cursor pull is authoritative, and the realtime poke plus the next pull converge within the TTL. Worst case is “syncs a TTL later,” never “wrong data.”
- If you want zero staleness on the sync path, scope Hyperdrive caching (shorter
max_age, or disable caching for the sync queries) and keep pooling — pooling is the bigger win anyway.
Keep max small (≤5) per the Workers concurrent-connection limit; Hyperdrive multiplexes those onto its own larger pool.
Cloudflare stack — what Nizhal supports today
Section titled “Cloudflare stack — what Nizhal supports today”You asked whether the full CF stack is first-party. Honest map:
| CF service | Status | Notes |
|---|---|---|
| Workers (compute) | ✅ First-party | createNizhalServer (Hono) runs on Workers; cloudflareRealtime + worker.entry.ts ship today. |
| Hyperdrive (DB acceleration) | ✅ Works now, no code change | postgresStorage takes the Hyperdrive connectionString/client. Transparent pooling + caching. |
| WAF & DDoS | ✅ Automatic (infra) | Sits in front of Workers/Pages; nothing to wire in Nizhal. |
| R2 (object storage) | ⚠️ Seam-ready | BlobStore is an S3-style presign seam; R2 is S3-compatible, so an R2 adapter is a thin wrapper — but only memoryBlobStore ships first-party today. |
| Queues (background jobs) | ⚠️ Different model | Nizhal ships a Postgres-backed durable job scheduler/worker (createJobScheduler/createJobWorker), not CF Queues. A Queues-backed JobScheduler adapter isn’t shipped. |
| D1 (SQLite) + better-auth | ❌ Not first-party | Storage is Postgres-first (columns + triggers, no WAL) via the StorageAdapter seam; the audit log already has a libSQL adapter, so a D1/SQLite storage adapter is feasible but unbuilt. Auth is bearer-token (bearerTokenAuth); better-auth would be a custom integration. |
| KV (cache) | ❌ Not used | Hyperdrive already covers DB-read caching; a KV layer for contract/session caching isn’t wired. |
Recommendation. The high-value, low-effort CF-first-party work, in order: (1) Hyperdrive — already works, just document/bless it (done here); (2) an R2 BlobStore adapter (small, the presign seam exists); (3) a D1/SQLite StorageAdapter if you want a no-external-Postgres edge deployment (largest effort — the trigger-based change-tracking has to be reworked for SQLite, and the libSQL audit adapter is the starting point). KV/Queues/better-auth are nice-to-haves, not blockers.