Skip to content

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.

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)
  • ComputecreateNizhalServer runs inside the Worker (Hono is Workers-native).
  • RealtimecloudflareRealtime RPCs one NizhalBucket Durable Object per bucket; clients hold a standard WebSocket to /parties/nizhal-bucket/<bucket>. Verified on real workerd by run-cf-e2e.sh.
  • Database — your Postgres, reached through Hyperdrive (pooling + caching) instead of a raw connection.

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/pullgetChanges (reads) and writes go through /sync/push.

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).

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 serviceStatusNotes
Workers (compute)✅ First-partycreateNizhalServer (Hono) runs on Workers; cloudflareRealtime + worker.entry.ts ship today.
Hyperdrive (DB acceleration)✅ Works now, no code changepostgresStorage 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-readyBlobStore 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 modelNizhal 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-partyStorage 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 usedHyperdrive 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.