Skip to content

Cloudflare + Supabase: an architecture reference

Every way a Cloudflare Worker talks to Supabase, which path to use for what, and what each one actually costs. All numbers below were measured from a Worker running on Cloudflare’s edge (wrangler dev --remote) against a Supabase project in Frankfurt (eu-central-1), 2026-07-14.

TL;DR:

  • Reads: supabase-js over PostgREST is ~210ms with a fat tail; a cacheable read through either the Worker Cache API or Hyperdrive is ~10ms. The Cache API needs no extra infra and matches Hyperdrive for pure read caching.
  • Raw Postgres drivers (pg, postgres.js) cannot reach Supabase’s Supavisor pooler from the Workers runtime - node-postgres terminates the connection, postgres.js hangs. Hyperdrive is the only working wire-protocol path from a Worker.
  • Writes: Hyperdrive (~172ms) beats PostgREST (~208ms) because it skips the PostgREST layer - but it also skips RLS, so you own authorization in the Worker.
  • Auth: verifying a Supabase JWT at the edge with cached JWKS is ~0ms; a getUser() round-trip is ~207ms. Verify locally for latency, round-trip only when you need revocation.

d2 diagram

The pattern this reference assumes is Browser -> Worker -> Supabase: the browser never holds a Supabase key, and the Worker is the trust boundary. Realtime is the one surface that usually stays browser-direct (see below).


25-row / ~50KB reads and single-row writes, n=40/n=20, times in milliseconds, from the edge to Frankfurt.

Pathmedianp95notes
PostgREST over HTTPS (supabase-js)213591the default path; fat tail from TLS/connection variance
Worker Cache API in front of REST1018first call ~258ms (cache fill), then edge-local
Hyperdrive - cached read1018deterministic query, default 60s TTL
Hyperdrive - uncached read (now())215229bound by RTT, same floor as REST but tighter tail
raw node-postgres -> Supavisor--fails: Connection terminated unexpectedly
raw postgres.js -> Supavisor--fails: connection hangs (6s timeout)
Pathmedianp95notes
PostgREST insert (supabase-js)208264enforces RLS via the key
Hyperdrive insert172231pooled direct connection, skips PostgREST - and skips RLS
raw driver -> Supavisor--fails (same as reads)
Operationmedianp95notes
Verify JWT at the edge (jose + cached JWKS)~0~0first call 263ms (JWKS fetch), then local
getUser() round-trip (/auth/v1/user)207219revocation-aware
Tampered token--correctly rejected

Data plane: how a Worker reaches the database

Section titled “Data plane: how a Worker reaches the database”

There are three ways, and only two of them work from a Worker.

A. supabase-js over PostgREST (the default)

Section titled “A. supabase-js over PostgREST (the default)”

createClient(url, key) then .from()/.rpc(). Every call is an HTTPS request to PostgREST. This is the path to prefer for most work: it carries auth, enforces Row Level Security via the anon/authenticated key, needs no nodejs_compat, and gives you the full supabase-js surface (Auth, Storage, Realtime). The cost is a full request to Frankfurt per call (~210ms here) with a fat tail.

B. Raw Postgres driver - does not work from a Worker

Section titled “B. Raw Postgres driver - does not work from a Worker”

Pointing node-postgres or postgres.js straight at a Supavisor pooler string from the Workers runtime failed both ways: node-postgres returns Connection terminated unexpectedly immediately, postgres.js hangs until timeout. This is the TLS-over-socket path the Workers runtime does not carry cleanly to Supavisor. The takeaway is firm: do not plan on a raw driver to Supavisor from a Worker. If you need the Postgres wire protocol from a Worker, use Hyperdrive (next). A raw driver against Supavisor is fine from a normal Node/Bun server - it is specifically the Worker runtime that is the wall.

C. Hyperdrive - the working wire-protocol path

Section titled “C. Hyperdrive - the working wire-protocol path”

Hyperdrive is Cloudflare’s pooler + read cache living in their network. The Worker gets a connectionString from a binding and connects with node-postgres; Hyperdrive handles the TCP/TLS to Postgres and keeps the real pool warm near the database. It is the only way to run raw SQL from a Worker that worked, and it is faster than REST for writes and cache-eligible reads.

The win on reads is the cache, not the connection. A deterministic read is served from Hyperdrive’s cache near the Worker (~10ms). The moment the query is non-cacheable - a volatile function like now(), or any write - it drops to the same ~210ms RTT floor as REST. Hyperdrive’s pooling buys you the tight write path (~172ms vs 208ms) and a tighter tail, not a shorter wire.

ModeHost / portIPUse from a Worker
Direct connectiondb.<ref>.supabase.co:5432IPv6-only (no A record) unless IPv4 add-onBehind Hyperdrive
Supavisor - transactionaws-N-<region>.pooler.supabase.com:6543IPv4Not from a Worker (raw driver fails); fine from a server
Supavisor - sessionaws-N-<region>.pooler.supabase.com:5432IPv4Hyperdrive’s IPv4 fallback if direct is unreachable
PostgREST<ref>.supabase.co:443IPv4/IPv6supabase-js (path A)

1. Least-privilege role (do not grant postgres - that hands a Worker-reachable role superuser-adjacent rights):

create role hyperdrive_user login password 'GENERATE_A_STRONG_ONE';
grant usage on schema public to hyperdrive_user;
grant select on all tables in schema public to hyperdrive_user;
-- add insert/update/execute only for exactly what the Worker needs

2. Create the config against the direct connection (Hyperdrive verifies connectivity at create time):

Terminal window
wrangler hyperdrive create my-supabase \
--connection-string="postgres://hyperdrive_user:PASSWORD@db.<ref>.supabase.co:5432/postgres"

3. Bind it in wrangler.jsonc (pg needs nodejs_compat):

{
"compatibility_date": "2026-07-12",
"compatibility_flags": ["nodejs_compat"],
"hyperdrive": [{ "binding": "HYPERDRIVE", "id": "<config-id>" }]
}

4. Query (pg >= 8.16.3, fresh client per request - Hyperdrive keeps the pool warm):

import { Client } from "pg";
const client = new Client({ connectionString: env.HYPERDRIVE.connectionString });
await client.connect();
const { rows } = await client.query("select id, body from t order by id limit $1", [25]);

Supabase issues asymmetric JWTs (ES256 here) with a JWKS endpoint at <url>/auth/v1/.well-known/jwks.json. That gives a Worker two options with very different cost:

  • Verify locally with jose against the cached JWKS - createRemoteJWKSet fetches the keys once (~263ms first call) then verifies every subsequent token in ~0ms, no round-trip. Rejects tampered tokens correctly. Use this for the hot path.
  • getUser() hits /auth/v1/user (~207ms) and is revocation-aware - it fails immediately if the user was signed out or deleted server-side. Use it only where revocation matters (sensitive mutations), not on every request.
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(new URL(`${env.SUPABASE_URL}/auth/v1/.well-known/jwks.json`));
const { payload } = await jwtVerify(token, jwks, { audience: "authenticated" });
// payload.sub, payload.role - ~0ms after the first JWKS fetch

Keys: the browser gets the anon key (public, RLS applies). The Worker holds the service_role / sb_secret_ key as a Wrangler secret and never ships it to the client. On a Hyperdrive/raw-SQL path there is no key at all - the Postgres role is the identity, and RLS does not apply (see the security model).


Verified round-trip: create bucket -> upload -> signed URL -> fetch all work against the Storage REST API with the service key. The CF-relevant pattern is to serve Storage objects through a Worker and cache them at the edge: the Worker mints or proxies a signed URL, fetches the object, and puts it in the Cache API. Objects are effectively immutable per signed URL, so the same ~10ms edge-cache behaviour as the read path applies to a hot object after the first fill.

// Worker: proxy + edge-cache a private Storage object
const cache = caches.default;
let res = await cache.match(request);
if (!res) {
const signed = await mintSignedUrl(env, path); // service key, server-side
res = await fetch(signed);
res = new Response(res.body, res);
res.headers.set("cache-control", "public, max-age=60");
ctx.waitUntil(cache.put(request, res.clone()));
}
return res;

Do not put the service key or a bucket-wide token in the browser - the Worker is where the privileged Storage call happens.


Realtime is a WebSocket to Supabase. A Worker is a poor place to terminate a long-lived WS (request-scoped, no persistent connection), so the normal shape is browser-direct to Realtime with an anon key and RLS-backed channel authorization - the one surface that legitimately bypasses the Worker. If you must relay (to hide the project from the browser, or fan-out server-side), a Durable Object is the right CF primitive for holding the socket, not a stateless Worker. Not benchmarked here - it is a connection-lifetime concern, not a latency one.


PathWho enforces authorizationKey / identity
supabase-js REST (anon)RLS, via the anon/authenticated JWTanon key, in the browser or Worker
supabase-js REST (service)Your code - service key bypasses RLSservice key, Worker-only secret
Hyperdrive / raw SQLYour code - Postgres role bypasses PostgREST + RLSDB role, no JWT
Auth (edge verify)jose signature + exp/aud checksJWKS (public)

The load-bearing point: the moment you leave PostgREST (Hyperdrive or raw SQL), RLS is gone. That path suits Browser -> Worker -> Supabase where the Worker already authorizes every request and the browser holds no key. It does not suit letting a client drive queries. Give the Hyperdrive role exactly the grants the Worker uses, never postgres. Keep the service key and the Hyperdrive connection string server-side (the Hyperdrive string lives in the config, not in Worker secrets, which is a nice property).


You want…Use
Auth, Storage, Realtime, CRUD with RLSsupabase-js / PostgREST
A repeated read to stop costing ~210msWorker Cache API (no extra infra)
Real SQL (joins, COPY, transactions) from a Worker, or a faster write pathHyperdrive
Cheapest possible auth check on the hot pathEdge JWT verify (jose + JWKS)
Revocation-aware auth on a sensitive routegetUser() round-trip
Long-lived RealtimeBrowser-direct, or a Durable Object relay
Raw driver straight to Supavisor from a WorkerDo not - it fails; use Hyperdrive
An uncacheable read/write to be materially fasterMove the data closer (read replica / region); the wire is the floor

The harness lives on the hyperdrive-bench branch of the pastebin repo under bench/ - a standalone module Worker (pg, postgres, jose), not wired into the app. Recreate a least-privilege role + Hyperdrive config per the setup above, drop a bench/.dev.vars with SUPABASE_ANON_KEY / SUPABASE_SERVICE_KEY / PG_TXN / PG_SESSION / TEST_JWT, then:

Terminal window
wrangler dev --remote --config bench/wrangler.jsonc --port 8799
curl 'http://localhost:8799/reads?n=40'
curl 'http://localhost:8799/writes?n=20'
curl 'http://localhost:8799/auth?n=30'

Every role, table, test user, Storage bucket, and Hyperdrive config used for the run above was torn down afterwards - there are no standing credentials.


Sources: Cloudflare Hyperdrive - Supabase - Hyperdrive query caching - Workers Cache API - Supabase - Connecting to Postgres - Supabase Auth - JWTs - measured eu-central-1, 2026-07-14.