Cloudflare Workers + Supabase: an architecture reference
Every way a Cloudflare Worker talks to Supabase, which path to use for what, what each one actually costs, and where its data lives.1
All numbers below were measured from Singapore: a Worker on Cloudflare’s edge (wrangler dev --remote) against a Supabase project in Frankfurt (eu-central-1), 2026-07-14 - a ~10,000 km round trip, so the absolute latencies are distance-dominated. The ~210ms floor is mostly the Singapore-to-Frankfurt wire, not stack overhead; read the numbers for their shape, and see Reading the numbers for what generalises and what does not.
wrangler dev --remote runs the Worker on Cloudflare’s real edge with live bindings, so the matrix tracks a deployed Worker - and the patterns here run in production, not just a bench: pasteriser (paste.erfi.io) serves a live /recent feed through the Durable Object Realtime relay, sweeps expired rows with pg_cron, and does its CRUD through the RPC and PostgREST paths below - all verified live end to end.
Every latency here was measured against that setup; every claim about behavior links to the Cloudflare or Supabase docs in Sources. Where a claim is empirical rather than documented (the raw-driver failure, the Realtime relay gotchas), it is called out as measured, not asserted.
TL;DR:
- Reads:
supabase-jsover 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) run from a Worker over the runtime’s TCP socket API, but against the Supavisor pooler they need pooler-specific flags (prepare: false, an SSL workaround) or the connection fails (node-postgres terminates, postgres.js hangs). Hyperdrive is the managed wire path: pooling plus read cache, over the direct connection, no driver tuning. - Writes: Hyperdrive (~172ms) beats PostgREST (~208ms) because it skips the PostgREST layer - and drops the JWT context (
auth.uid()is null), so you own authorization in the Worker. RLS itself still applies by DB role unless that role hasBYPASSRLS(see the security model). - 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. - Preview branches: each PR gets an isolated Supabase project built from your migrations; wire it to a
wrangler versions uploadWorker preview version that never touches the production route. The trap: a branch that fails on a missingconfig.tomlenv()secret is mislabeledMIGRATIONS_FAILED- the migrations are fine, the branch just never got your custom-SMTP/OAuth secrets.
Topology
Section titled “Topology”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).
Which path to use
Section titled “Which path to use”| You need | Use | Cost (SG -> FRA) | Why |
|---|---|---|---|
| RLS CRUD, Auth, Storage, the full SDK | supabase-js / PostgREST | ~210ms / call | default; RLS enforced by the key; no nodejs_compat |
| A repeated read made fast | Worker Cache API | ~10ms hit | zero extra infra, no nodejs_compat |
Real SQL (joins, transactions, COPY) or faster writes | Hyperdrive | ~10ms cached, ~172ms write | managed pooled wire path (raw drivers work too, but the pooler needs specific flags); you own authz |
| Hot-path auth | edge JWT verify (jose) | ~0ms | local after the first JWKS fetch |
| Revocation-aware auth | getUser() | ~207ms | round trip; use per sensitive route |
| Realtime | browser-direct, or a Durable Object relay | n/a | a stateless Worker cannot hold a long-lived socket |
| A per-PR database | preview branch + Worker preview version | n/a | isolated project, auto-migrated, own preview URL |
The absolute costs are the Singapore-to-Frankfurt figures below; the choice in each row is geography-independent.
The measured matrix
Section titled “The measured matrix”25-row / ~50KB reads and single-row writes, n=40/n=20, times in milliseconds, from the Singapore edge to Frankfurt.
| Path | median | p95 | notes |
|---|---|---|---|
PostgREST over HTTPS (supabase-js) | 213 | 591 | the default path; fat tail from TLS/connection variance |
| Worker Cache API in front of REST | 10 | 18 | first call ~258ms (cache fill), then edge-local |
| Hyperdrive - cached read | 10 | 18 | deterministic query, default 60s TTL |
Hyperdrive - uncached read (now()) | 215 | 229 | bound 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) |
Writes
Section titled “Writes”| Path | median | p95 | notes |
|---|---|---|---|
PostgREST insert (supabase-js) | 208 | 264 | enforces RLS via the key |
| Hyperdrive insert | 172 | 231 | pooled direct connection, skips PostgREST - no auth.uid() context, so you own authz; RLS still applies by role |
| raw driver -> Supavisor | - | - | fails (same as reads) |
| Operation | median | p95 | notes |
|---|---|---|---|
Verify JWT at the edge (jose + cached JWKS) | ~0 | ~0 | first call 263ms (JWKS fetch), then local |
getUser() round-trip (/auth/v1/user) | 207 | 219 | revocation-aware |
| Tampered token | - | - | correctly rejected |
Reading the numbers
Section titled “Reading the numbers”These were measured Singapore edge -> Frankfurt DB, ~10,000 km apart. Three things follow, and only the first is specific to this setup:
- The absolute floor is the wire. A Singapore-to-Frankfurt round trip is ~150-165ms of RTT before any TLS handshake or query work, which is why every uncached, must-reach-the-DB path lands near ~210ms. That floor is geography, not the stack.
- The architecture wins are ratios, and they generalize. A cached read (~10ms) is ~20x faster than the round trip; edge JWT verify (~0ms) removes a ~207ms round trip outright; Hyperdrive’s pooled write (~172ms) shaves the PostgREST layer off the wire. Those relationships hold at any region pairing.
- Co-locate to collapse the floor. Put the Worker’s effective region and the Supabase region on the same continent - or front the DB with a read replica near the edge - and every wire number drops toward the intra-region floor (single-digit to low-tens of ms), while the cache and verify wins stay proportionally the same. Supabase offers regional placement (APAC is
ap-southeast-1, Singapore), so pairing the Worker’s effective region with the database region is a deployment choice.
Cutting the round trip
Section titled “Cutting the round trip”The floor is the wire, so the fixes are: remove the round trip, reduce how many you make, or shorten each one. The first two matter most; they draw on both platforms.
-
Remove it - cache / edge verify (Cloudflare). The Cache API and Hyperdrive cache serve hot reads at ~10ms with no wire at all; edge JWT verify removes the ~207ms
getUser()round trip. This is the biggest lever and it grows with distance (see the matrix and Auth sections). -
Reduce the count - RPC / database functions (Supabase). The floor is charged per round trip, so collapsing several statements into one
.rpc()to aplpgsql/sqlfunction2 pays ~210ms once instead of N times. Push multi-step reads and writes into a function rather than chainingsupabase-jscalls across the wire. Pasteriser’sview_paste(uuid)collapses a read + aread_countbump + a conditional burn-delete (three wire round trips) into one.rpc(), and itsSELECT ... FOR UPDATEalso closes a burn-after-reading race that three separate calls could not. -
Shorten it - Smart Placement (Cloudflare). By default a Worker runs nearest the user; Smart Placement moves its execution near the origin it talks to most - your Supabase region - when that lowers total latency.3 A chatty Worker making several sequential DB calls from Singapore to Frankfurt then runs beside Frankfurt, so only one user-to-Worker hop crosses the distance and the DB calls are intra-region. Enable per app:
{ "placement": { "mode": "smart" } }It wins for multi-call Workers and is roughly neutral for single-call ones (you just move where the one hop happens).
-
Shorten it - read replicas (Supabase). A read replica in a region near the edge (
ap-southeast-1for Singapore) serves reads locally while writes stay on the primary.4 Point read-only paths at the replica; keep writes on the primary. -
Remove the distance - co-locate. If users, Worker, and DB can share a region, the wire floor drops to single-digit / low-tens of ms and most of this section is moot. Region choice is the cheapest fix when it is available.
Data plane: how a Worker reaches the database
Section titled “Data plane: how a Worker reaches the database”There are three ways to reach the database from a Worker.5
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 from Singapore; your floor is whatever your edge-to-DB round trip is) with a fat tail.
B. Raw Postgres driver
Section titled “B. Raw Postgres driver”Raw pg and postgres.js connect from the Workers runtime over its TCP socket API (connect() from cloudflare:sockets); both Cloudflare and Supabase document driving node-postgres / Postgres.js directly from a Worker.6 Against the Supavisor pooler the connection needs pooler-specific settings: prepare: false for the transaction pooler (it keeps no prepared statements), plus an SSL/pooler workaround on the connection string. With driver defaults the handshake does not complete. For the wire protocol without that tuning, use Hyperdrive (next) - it pools, caches, and targets the direct connection.
C. Hyperdrive - the managed wire-protocol path
Section titled “C. Hyperdrive - the managed wire-protocol path”Hyperdrive is Cloudflare’s pooler + read cache living in their network.7 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 managed way to run raw SQL from a Worker - pooled and cache-eligible, faster than REST for writes and cache-eligible reads - without hand-tuning a driver against the pooler.
The win on reads is the cache, not the connection.8 A deterministic read is served from Hyperdrive’s cache near the Worker (~10ms). The moment the query is non-cacheable - a STABLE or VOLATILE function like now() (Hyperdrive caches neither), 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.
Connection modes and ports
Section titled “Connection modes and ports”| Mode | Host / port | IP | Use from a Worker |
|---|---|---|---|
| Direct connection | db.<ref>.supabase.co:5432 | IPv6-only (no A record) unless IPv4 add-on | Behind Hyperdrive |
| Supavisor - transaction | aws-N-<region>.pooler.supabase.com:6543 | IPv4 | From a Worker needs prepare: false + a pooler SSL workaround; fine from a server |
| Supavisor - session | aws-N-<region>.pooler.supabase.com:5432 | IPv4 | Hyperdrive’s IPv4 fallback if direct is unreachable |
| PostgREST | <ref>.supabase.co:443 | IPv4/IPv6 | supabase-js (path A) |
Setting up Hyperdrive
Section titled “Setting up Hyperdrive”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;-- this covers existing tables only; for tables created later, also:-- alter default privileges in schema public grant select on tables to hyperdrive_user;-- add insert/update/execute only for exactly what the Worker needs2. Create the config against the direct connection (Hyperdrive verifies connectivity at create time):
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]);Auth: verify at the edge, or round-trip
Section titled “Auth: verify at the edge, or round-trip”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 cost9:
- Verify locally with
joseagainst the cached JWKS -createRemoteJWKSetfetches 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 fetchKeys: 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. RLS still applies unless that role has BYPASSRLS, but there is no auth.uid() for user-scoped policies, so you own authorization in the Worker - which the next section works through in full.
That covers bearer-token auth - a Worker verifying a JWT it was handed. For a Worker or Pages Function that renders authenticated HTML server-side, the session lives in cookies instead: use @supabase/ssr with a getAll / setAll cookie adapter so the SSR client reads and refreshes the session from request/response cookies.10 Same trust boundary (keys stay server-side), just a cookie transport rather than a bearer header.
Security model (where auth is enforced)
Section titled “Security model (where auth is enforced)”| Path | Who enforces authorization | Key / identity |
|---|---|---|
supabase-js REST (anon) | RLS, via the anon/authenticated JWT | anon key, in the browser or Worker |
supabase-js REST (service) | Your code - service key bypasses RLS | service key, Worker-only secret |
| Hyperdrive / raw SQL | RLS still applies unless the DB role has BYPASSRLS or owns the table; there is just no auth.uid() for user-scoped policies | DB role, no JWT |
| Auth (edge verify) | jose signature + exp/aud checks | JWKS (public) |
The common belief that “leaving PostgREST turns RLS off” is wrong - and the correction matters, because the least-privilege Hyperdrive role this same doc recommends is exactly the case that trips it. RLS is a Postgres feature enforced for any role that lacks the BYPASSRLS attribute and does not own the table, no matter whether the connection arrives via PostgREST or a raw wire protocol. service_role and postgres carry BYPASSRLS (so REST-with-service-key and raw SQL as postgres both skip RLS); a purpose-made hyperdrive_user does not. What you actually lose by leaving PostgREST is the JWT context: auth.uid() is null, so user-scoped policies match nothing.
RLS enforcement by connection identity
Section titled “RLS enforcement by connection identity”With RLS enabled on a pastes table (two rows, both owned by user A), the same query resolves differently depending on how the connection identifies itself:
| Identity (how it connects) | Rows seen |
|---|---|
| anon key (PostgREST) | public only |
| user A JWT (PostgREST) | A’s private + public |
| user B JWT (PostgREST) | public only - cannot see A’s private |
service_role (PostgREST, BYPASSRLS) | all |
hyperdrive_user (raw SQL, no BYPASSRLS) | 0 - RLS denies by default, no policy targets that role |
A least-privilege Hyperdrive role sees nothing through RLS-enabled tables unless you add policies for it, grant it BYPASSRLS, or the table has RLS off (an RLS-disabled table returns every row to any role). Decide deliberately: BYPASSRLS makes the Worker the sole authorization gate; policies-for-the-role keeps Postgres enforcing. Either way, never hand a Worker-reachable role postgres.
Because the Worker is the single ingress, it is also the single place to throttle: the Workers Rate Limiting binding11 (or a Durable Object counter) caps abuse at the edge before it reaches Supabase - one rate-limit surface instead of one per client path. This is the same reason the browser-direct Realtime shape is a downgrade: it opens a second ingress the Worker cannot see or throttle.
The deployment shape this suits is Browser -> Worker -> Supabase: the Worker authorizes every request and the browser holds no key. 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).
Storage
Section titled “Storage”The full round-trip - create bucket -> upload -> signed URL -> fetch - runs 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.12 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 objectawait authorize(request, env); // authorize FIRST - a cache HIT must not skip authzconst cache = caches.default;const key = cacheKeyForScope(request); // scope the key so one fill can't leak across callerslet res = await cache.match(key);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(key, res.clone()));}return res;Order matters: authorize before cache.match, and scope the cache key to what the caller may see - a cache hit that skips the auth check would serve the private object to whoever requests the same URL next. And do not put the service key or a bucket-wide token in the browser - the Worker is where the privileged Storage call happens.
Storage also exposes an S3-compatible endpoint, reachable from a Worker with a signing client like aws4fetch.13 Use it for multipart uploads of large objects or to point existing S3 tooling at a bucket, where the REST API is awkward.
Realtime
Section titled “Realtime”Realtime is a WebSocket to Supabase, with three modes worth separating14:
- Postgres Changes streams row changes from the WAL to subscribers. It authorizes per subscriber per event and is single-threaded, so throughput scales with subscriber count, not compute; at high subscriber counts it becomes the bottleneck (Supabase’s Realtime benchmarks give a per-database calculator15) and Broadcast is the path that scales.
- Broadcast is the general channel: low-latency messages client-to-client over the WebSocket, the REST endpoint, or from the database via
realtime.send()/realtime.broadcast_changes()in a trigger. It is the recommended path at scale16, and the one a server can reach without holding a socket (a Worker can POST the Broadcast REST endpoint to push). - Presence tracks shared ephemeral state (who is online, cursors); not for high-frequency updates.
Channels are public by default; opt into a private channel with private: true to gate it behind RLS policies on realtime.messages. A private channel re-sends its JWT on refresh, so keep JWT expiry short.
Where the Worker fits. A stateless Worker cannot terminate a long-lived socket. Two shapes:
- Browser-direct - the browser opens the
wss://<ref>.supabase.cosocket itself with the publishable/anon key and RLS-backed channel authorization. Simplest, and the one surface that legitimately bypasses the Worker - but it exposes the anon key, relaxes the CSP to allow the Supabase host, and is a second surface outside the Worker. - Durable Object relay - a DO holds ONE server-side subscription and fans out to browsers over a same-origin socket. Keeps the browser -> Worker -> Supabase boundary intact (no key in the browser, CSP stays
connect-src 'self', one surface), and the hibernatable WebSocket API carries thousands of browser connections cheaply on the server side.17
Pasteriser takes the relay route for its live /recent feed: a realtime.send() trigger on public-paste inserts, a Durable Object holding the single upstream subscription, and browsers on a same-origin /api/recent/live socket - so the anon key never reaches the browser and the CSP is unchanged.
The asymmetry is the point: one upstream subscription to Supabase, N browser sockets fanned out from the DO. The push path runs INSERT -> trigger -> Realtime -> the one upstream -> fan-out; browsers dial the DO in the other direction to set the sockets up.
Four load-bearing details, each a silent failure otherwise (all four cost real debugging time to find):
- The DO subscribes with the anon JWT, not the
sb_publishable_key. Realtime silently ignoressb_*tokens as a private-channelaccess_token, so the join is unauthenticated and receives nothing. (Supabase is deprecating the legacy anon key by end of 2026; on a new-keys-only project theaccess_tokenmust be a real signed anon/user JWT - still never thesb_publishable_key.) - The DO’s outbound socket is
fetch("https://...", { headers: { Upgrade: "websocket" } }), notnew WebSocket("wss://..."). A Worker’sfetchonly upgrades anhttp(s)URL; awss://URL is rejected, so the upstream never connects. (A browserWebSocketacceptswss://, so this only bites server-side.) - Response middleware must skip the
101. A header-adding middleware that re-wraps every response withnew Response(body, init)drops the non-standardwebSocketproperty, and the browser upgrade fails with a1002. - Rejoin with Broadcast
replay. On reconnect, sendbroadcast.replay { since, limit }so anything broadcast during the gap is backfilled - private channels only, DB-originated messages, up to 25 per request, at least 72h retention; dedup on the client by id (the browser socket stays up while only the DO’s upstream reconnects, so a poll fallback would not cover it).
The load-bearing glue - accept the browser socket, hold one upstream, fan out:
export class RecentFeedDO { private sockets = new Set<WebSocket>(); private upstream?: WebSocket; constructor(private state: DurableObjectState, private env: Env) {}
// Browser side: same-origin /api/recent/live async fetch(_req: Request): Promise<Response> { const { 0: client, 1: server } = new WebSocketPair(); server.accept(); this.sockets.add(server); server.addEventListener("close", () => this.sockets.delete(server)); this.ensureUpstream(); // lazily open the ONE upstream return new Response(null, { status: 101, webSocket: client }); }
private async ensureUpstream() { if (this.upstream) return; // http(s) scheme for fetch(), NOT new WebSocket("wss://...") const url = this.env.SUPABASE_URL.replace(/^wss:/i, "https:") + `/realtime/v1/websocket?apikey=${this.env.SUPABASE_ANON_KEY}&vsn=1.0.0`; const res = await fetch(url, { headers: { Upgrade: "websocket" } }); const ws = res.webSocket!; ws.accept(); this.upstream = ws; // phx_join the PRIVATE channel with the anon JWT as access_token ws.send(JSON.stringify({ topic: "realtime:recent:public", event: "phx_join", payload: { config: { private: true }, access_token: this.env.SUPABASE_ANON_KEY }, ref: "1" })); // phx heartbeat every 20s (protocol needs <=25s) or Realtime times the socket out. // A setInterval also blocks DO hibernation - which is why the relay bills continuously. const hb = setInterval(() => ws.send(JSON.stringify( { topic: "phoenix", event: "heartbeat", payload: {}, ref: "hb" })), 20_000); ws.addEventListener("close", () => { clearInterval(hb); this.upstream = undefined; }); ws.addEventListener("message", (e) => { const msg = JSON.parse(e.data as string); if (msg.event !== "broadcast") return; const frame = JSON.stringify(msg.payload.payload); // the row we broadcast for (const s of this.sockets) s.send(frame); // fan out }); }}The reverse path: Supabase to a Worker
Section titled “The reverse path: Supabase to a Worker”Everything above is the Worker pulling Supabase (request in, response out). The other direction - the database pushing an event to a Worker - is a separate wire with its own tools, and the one most CF + Supabase designs forget.
- Database Webhooks fire on
INSERT/UPDATE/DELETEand POST a JSON payload (type,table,record,old_record) to any URL - point one at a Worker route. They are a convenience wrapper around a Postgres trigger callingpg_net, so they are async: the triggering transaction commits first, the HTTP call fires after.18 pg_netis the engine underneath -net.http_post(url, body, headers)from any function or trigger. Use it directly for conditional or shaped calls (notify only on certain rows, a different Worker per tenant). Constraints: it fires only after commit, tops out around ~200 req/s, speakshttp_get/http_post/http_deleteonly (no PATCH/PUT; POST bodies are JSON), and responses land in an unlogged table for ~6h (lost on crash) - treat it as fire-and-forget.19
Use it for cache invalidation (a row changed, so a Worker purges the Cache API / KV entry), fan-out, and audit or notification pipelines - anything event-driven that would otherwise force the Worker to poll.
Two rules, because the target is a public Worker URL:
- Authenticate the callback. Send a shared secret header (kept in Vault, read in the trigger) and verify it in the Worker; an unauthenticated webhook endpoint is an open write.
- Make the handler idempotent.
pg_netis at-least-once and webhooks can retry, so key on the row id + version and no-op on repeats.
Scheduled and async work
Section titled “Scheduled and async work”Three schedulers, and the split is where the work runs:
pg_cronruns SQL on a schedule inside Postgres - zero network hop, transactional with your data. Right for data maintenance. Pasteriser burns expired rows every 5 minutes withDELETE FROM pastes WHERE id IN (SELECT id FROM pastes WHERE expires_at < now() LIMIT 1000); theLIMIT 1000is load-bearing - an unboundedDELETEtakes row locks on every expired row in one long transaction, blocking concurrent writes to them and bloating the table (MVCC readers are unaffected), so the bounded batch caps lock-hold and transaction size and the next run continues. Keep jobs to 8 or fewer concurrent, each under 10 minutes.20- Cron Triggers run a Worker on a schedule (
scheduled()handler, UTC, 15-min wall-time).21 Right for app logic on a timer - external calls, orchestration. If the scheduled work is “run this SQL” it belongs in pg_cron; if it is “do this app thing” it belongs here. - Workflows are the durable option when the async work is multi-step and must survive failure:
step.do()units with per-step retry and state that persists minutes to weeks.
For async writes off the request path, two queues:
- Cloudflare Queues - producer and consumer are both Workers; batch, retry per message with
ack()/retry(), dead-letter, no egress charge.22 Buffer writes to Supabase so a slow write never blocks the user’s request, and absorb spikes. - pgmq (Supabase Queues) - a durable queue inside Postgres, drained by an Edge Function on a
pg_cronschedule (or a Worker viapgmq_public).23 Use it when the queue must be transactional with your data.
Rule of thumb: cross-service, app-level async -> Cloudflare Queues; transactional-with-the-row async -> pgmq. And pg_cron can enqueue into pgmq for a scheduled producer.
Querying external systems from the database (FDW)
Section titled “Querying external systems from the database (FDW)”The reverse path above pushes an event out; Foreign Data Wrappers pull the other way, at query time. A wrapper (supabase/wrappers, several now Wasm-based) maps an external system - another Postgres, ClickHouse, BigQuery, Snowflake, Stripe, Firebase, an S3 bucket, a REST API - to a foreign table you query with plain SQL.24 The data stays in the remote system; the foreign table is a live view, so a join against auth.users reflects remote state on every query rather than a stale copy (Supabase calls this QETL - query in place instead of moving gigabytes first).
The Cloudflare angle: point the S3 wrapper at an R2 bucket (R2 speaks the S3 API)25 and Postgres reads R2 data directly, with no Worker in the path; or read a third-party API (Stripe subscriptions, say) from SQL instead of a Worker round trip. For bulk movement a pg_cron job can insert into warehouse.x select ... from public.x on a schedule - fine for incremental loads, but a large batch taxes the database and a dedicated ETL tool (Fivetran, Airbyte) is the better call there.
Two things to get right:
- FDWs do not enforce RLS. A foreign table is not row-secured, so never place one in an API-exposed schema. Keep wrappers in a private schema (a
stripeschema, awarehouseschema); if a column must reach the API, expose it through asecurity definerfunction with explicit filters and agrant executeto a specific role, not the raw table. - Every read is a synchronous call inside your transaction. Querying a foreign table blocks on the remote system’s latency and rate limits and holds a database connection while it waits. It is for analytical and administrative queries, not a hot request path - for that, cache the result at the edge or copy it in on a schedule.
Analytical reads: R2 SQL over Iceberg in R2
Section titled “Analytical reads: R2 SQL over Iceberg in R2”The FDW path keeps Postgres in the loop; the inverse is to move cold, append-only, or analytical data out of Postgres and query it where it is cheapest to store. R2 Data Catalog turns an R2 bucket into a managed Apache Iceberg catalog behind a standard Iceberg REST interface,26 and R2 SQL is a serverless, read-only query engine over those tables - no Postgres and no Worker in the path, and R2’s zero-egress model means the query pays no transfer even when the reader sits in another cloud or region.27
The shape that pairs with this stack: land events (paste views, audit rows, request logs) as Iceberg tables on R2 - directly, or via Cloudflare Pipelines / Logpush - and keep Postgres for the hot transactional rows. Query the history from a Worker over the HTTP API or from wrangler r2 sql query:
wrangler r2 sql query <WAREHOUSE> \ "SELECT event, count(*) FROM logs.events WHERE ts > '2026-07-01' GROUP BY event ORDER BY 2 DESC LIMIT 20"It is genuinely SQL - SELECT / WHERE / GROUP BY / HAVING / ORDER BY / LIMIT, JOINs, CTEs, subqueries, window functions, set operations, and exact or approximate aggregates - but it is an analytics engine, not a database.28
When it fits: high-volume append-only or event data you want to query column-wise and keep out of the primary’s storage and egress budget - a lakehouse that other engines (Spark, Snowflake, DuckDB, ClickHouse) can also read through the same Iceberg catalog. When it does not: anything transactional, row-level-secured, or on the hot request path stays in Postgres.
Edge Functions vs Workers
Section titled “Edge Functions vs Workers”Both run server-side TypeScript; the difference is where, and it maps onto the geography model above.
- A Cloudflare Worker runs at the edge nearest the user (or, with Smart Placement, nearest the DB). It has the CF primitives - Cache API, Hyperdrive, Durable Objects, R2, Queues - and it is where this reference assumes your logic lives.
- A Supabase Edge Function (Deno) runs at the edge nearest the user by default, like a Worker - so its DB/Auth/Storage calls cross the same wire. You opt into DB co-location explicitly with regional invocation (the
x-regionheader /FunctionRegion)29, which makes those calls local at the cost of user distance. Either way it has none of the CF primitives.30
Put logic next to whatever it talks to most. A function that makes many DB calls and little user I/O wants to run in the DB region - a region-pinned Edge Function, or a Worker with Smart Placement pointed at the DB region; a function that is user-facing, needs the Cache API / a Durable Object / R2, or is part of the CF stack is a Worker. They are not exclusive - a Worker at the edge can invoke an Edge Function for the DB-heavy step.
Preview branches: a per-PR database, wired to a Worker preview
Section titled “Preview branches: a per-PR database, wired to a Worker preview”Supabase branching gives each pull request its own full Supabase project - an isolated Postgres plus Auth, Storage, and its own ref + keys - built by replaying your supabase/migrations from the PR.31 Cloudflare has no equivalent auto-wiring, so the CF-side pattern is: provision the branch, read its connection creds, and wrangler versions upload a Worker preview version bound to that branch DB. A preview version gets its own preview URL and does not roll out to the production route, so paste.erfi.io is untouched. On PR close, delete the branch.
What binds on the CF side
Section titled “What binds on the CF side”The Worker preview is bound to the branch DB with two --var overrides at upload:
wrangler versions upload \ --var SUPABASE_URL:"https://<branch-ref>.supabase.co" \ --var SUPABASE_SECRET_KEY:"<branch secret key>"versions upload publishes to a distinct preview URL and never rolls out to the production route.32 It is this clean because the app’s data path is REST (supabase-js) - the two vars are the whole binding. Read the branch’s ref (for the URL) and its secret key from the Management API - GET /v1/branches/{id} and GET /v1/projects/{branch-ref}/api-keys?reveal=true - rather than supabase branches get, which needs a linked project a fresh CI checkout does not have.33
Hyperdrive is a static config pointing at one database; it is not auto-repointed per branch. A Hyperdrive-backed app therefore needs a Hyperdrive config created per branch to run the branch DB over the wire protocol. A config against the branch’s (IPv6-only) direct connection carries the full Worker -> Hyperdrive -> branch DB path and holds under concurrency - around 66 req/s at p95 ~105ms / p99 ~117ms in a 6-worker test. The catch is lifecycle: a per-branch Hyperdrive config is another CF resource to create on open and delete on close, so unless you specifically need the wire protocol on previews, the REST binding above is the lighter path.
The trap: a config failure mislabeled MIGRATIONS_FAILED
Section titled “The trap: a config failure mislabeled MIGRATIONS_FAILED”One branching failure mode is worth flagging in this reference because it masquerades as a data-plane problem. If the full branch deploy runs (native auto-branching, or a git-associated branch create) it executes configure -> migrate -> deploy -> seed, and the branch status reflects the whole deploy. If config.toml sets custom Auth (custom SMTP, external OAuth) through env() secrets, a preview branch - which does not inherit the parent’s secrets - fails the configure step with a 401, and the branch is mislabeled MIGRATIONS_FAILED even though every migration is fine. Supply the branch secrets (a secrets payload at create, or a dotenvx supabase/.env.preview), or use the bare-branch + db push route that skips configure entirely.34
The branch lifecycle, compute sizing, this trap in full (with the evidence matrix and the two-status-fields / IPv6-pooler gotchas), and the complete CI workflow live in the Supabase preview-branch guide.
Cloudflare-native alternatives
Section titled “Cloudflare-native alternatives”For several Supabase features there is a Cloudflare primitive that may fit better depending on where the data needs to live. Pick the CF side when the workload is edge-shaped; keep it in Supabase when it belongs with your relational data and RLS.
| Need | Cloudflare | Supabase | Pick Cloudflare when |
|---|---|---|---|
| Object storage | R2 (zero egress) | Storage | Heavy egress or edge serving; Storage for RLS + built-in image transforms on one platform |
| Vectors / embeddings | Vectorize35 | pgvector | Pure ANN at the edge; pgvector when vectors live beside relational data and RLS |
| Edge config / cache | KV | a table / Vault | Read-mostly config at the edge with no SQL |
| Edge SQL | D1 (SQLite) | Postgres | Edge-local, low-write, per-tenant; Postgres for the real relational store |
| Analytical / columnar query | R2 SQL + Data Catalog (Iceberg)27 | Postgres analytical queries / a warehouse FDW | Big append-only / event data queried column-wise, zero-egress across engines; Postgres for transactional, RLS’d rows |
| Queue | Queues | pgmq | Cross-service async; pgmq when it must be transactional with the row write |
| Schedule | Cron Triggers | pg_cron | App logic on a timer; pg_cron for in-DB SQL |
| AI inference | Workers AI | (embeddings via pgvector) | Edge inference near the user |
Frequently combined, not either/or: R2 for the bytes + Postgres for the metadata; Vectorize for search + Postgres for the source rows.
Cost notes
Section titled “Cost notes”The pricing shapes that change an architecture decision:
- Supabase compute is a per-project floor, billed hourly whether or not the DB is busy, and not covered by the spend cap (paid plans include ~$10/mo of compute credit, about one Micro). A read replica is billed as a full extra database (same compute size + 1.25x disk). Scale-to-zero exists only on the Nano tier and is gated (Supabase for Platforms). “Spin up a project / replica / branch per X” has a standing cost, not a per-request one.
- Egress is a unified Supabase quota, ~$0.09/GB uncached vs ~$0.03/GB cached (Storage’s CDN, which is Cloudflare). Serving large objects from Storage bills egress; serving them from R2 is egress-free - the main reason to put big / cacheable bytes behind R2 + the Cache API and keep Postgres for metadata.
- On the CF side, Queues and Hyperdrive add no egress charge, and Hyperdrive’s caching / pooling is included in the Workers plan - the levers in “Cutting the round trip” are mostly free.
Data residency and compliance
Section titled “Data residency and compliance”Latency is not the only reason region matters. Where each hop runs and stores data is a separate axis, and this two-provider shape spreads it across Cloudflare and the AWS region under Supabase.
Where the data sits at rest
Section titled “Where the data sits at rest”| Surface | Default location | Pin it to a region | Compliance note |
|---|---|---|---|
| Supabase project | Region fixed at creation (eu-central-1) | Immutable in place; clone or migrate to move (a clone keeps the region) | Choose your compliance boundary on day one |
| Read replica | The replica’s own region | That region is the choice | A replica copies data onto that soil (a US replica of an EU primary puts EU data on US soil) - a residency decision, not just latency |
| Durable Object | Wherever it was first created | env.NS.jurisdiction("eu") (also us, fedramp) | A location hint (weur, apac) is best-effort latency placement, NOT a compliance guarantee - jurisdiction is36 |
| Edge cache (Cache API) | Whatever PoP served the read (global) | Don’t cache regulated data | Global by default; fine for public data |
If a DO carries regulated data (the relay’s broadcast payload is your row data), pin its jurisdiction to match the database. Pasteriser pins its relay with RECENT_FEED.jurisdiction("eu") to keep even the transient public-feed payload in the database’s region - the feed is public and low-sensitivity, but the same one-line pin is exactly what a regulated payload would rely on. The DurableObjectId is still logged outside the jurisdiction for billing.
In transit
Section titled “In transit”| Hop | Default | Make it private / in-region |
|---|---|---|
| Browser -> CF PoP -> Worker | TLS terminates at the PoP nearest the user (may be outside your region) | Regional Services (Data Localization Suite, Enterprise) pins TLS termination + request processing to a chosen region; pair it with the DO jurisdiction3738 |
| Worker -> Supabase | Public-internet TLS (PostgREST or Hyperdrive to the public hostname) | Workers VPC (beta, free during beta): a TCP VPC Service (--app-protocol postgresql) + Hyperdrive (--service-id), or a VPC Network binding to a Tunnel / Cloudflare Mesh (network_id: "cf1:network") / Magic WAN on-ramp via fetch() / connect()3940 |
| DO transport / storage | TLS in flight; AES-256 at rest (LUKS), CF-managed keys41 | n/a |
Supabase PrivateLink (AWS PrivateLink) applies to clients inside the same AWS network, so it does not by itself cover a Cloudflare Worker.42 The catch for managed Supabase: a Workers VPC tunnel or on-ramp must sit in a network that already has private reach to the instance (an AWS VPC consuming Supabase PrivateLink), since you cannot run cloudflared inside Supabase’s managed network - so this is the direct path for self-hosted Supabase or your own Postgres, and a bridge-VPC exercise for managed.43 The Magic WAN interop guide covers the on-ramp side.
Logs and audit
Section titled “Logs and audit”| Surface | Default | Residency control / note |
|---|---|---|
| CF Customer Logs | Stored per Cloudflare’s default | Customer Metadata Boundary (Enterprise) sets the storage region |
| DO logs + analytics | US-only | No EU option - under CMB set to EU the DO metrics tab does not populate |
| Postgres connection logging | Off (auto-on for High-Compliance / HIPAA projects) | The SOC 2 / HIPAA audit trail4445 |
wrangler tail + broadcast payload | Carries row data | Treat as a data flow, not just a debugging aid |
HIPAA needs a signed BAA, the add-on, and the project marked High Compliance46; PHI must never land in a DO or edge cache that is not itself in scope.
Minimize what you have to protect. Burn-after-read and TTL expiry are data-minimization controls as much as features: they bound how long content exists and give a clean answer to an erasure request. The less regulated data you persist, and the fewer regions you copy it to, the smaller the compliance surface.
Checklist for this shape:
- Set the Supabase project region to your residency boundary at creation; avoid cross-region read replicas unless you accept the copy.
- Pin any DO that carries regulated data with
jurisdiction("eu" | "us" | "fedramp"); never rely on a location hint for compliance. - Add Regional Services on the Worker domain if the TLS-termination region matters; do not edge-cache regulated data.
- The Worker -> database hop is public-internet TLS by default; to make it private use Workers VPC (a TCP VPC Service + Hyperdrive, or a VPC Network bound to a Tunnel / Cloudflare Mesh / Magic WAN on-ramp) into a network that can privately reach the DB. Supabase PrivateLink alone does not cover the Worker path.
- For HIPAA / SOC 2: BAA + add-on, connection logging on, Customer Metadata Boundary for log residency, PHI kept out of DOs and cache.
Decision guide
Section titled “Decision guide”Reproducing
Section titled “Reproducing”The harness lives under bench/ in the pastebin repo (on main) - 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:
wrangler dev --remote --config bench/wrangler.jsonc --port 8799curl 'http://localhost:8799/reads?n=40'curl 'http://localhost:8799/writes?n=20'curl 'http://localhost:8799/auth?n=30'Measured from the Singapore edge to eu-central-1 (Frankfurt), 2026-07-14. Absolute latencies are distance-dominated; the ratios and path choices generalize (see Reading the numbers).
References
Section titled “References”-
Cloudflare, “Supabase (third-party integrations),” Cloudflare Docs. https://developers.cloudflare.com/workers/databases/third-party-integrations/supabase/ ↩
-
Supabase, “Database Functions,” Supabase Docs. https://supabase.com/docs/guides/database/functions ↩
-
Cloudflare, “Smart Placement,” Cloudflare Docs. https://developers.cloudflare.com/workers/configuration/smart-placement/ ↩
-
Supabase, “Read replicas,” Supabase Docs. https://supabase.com/docs/guides/platform/read-replicas ↩
-
Supabase, “Connecting to your database,” Supabase Docs. https://supabase.com/docs/guides/database/connecting-to-postgres ↩
-
Supabase, “Serverless drivers,” Supabase Docs. https://supabase.com/docs/guides/database/connecting-to-postgres/serverless-drivers ↩
-
Cloudflare, “Connect Hyperdrive to a Supabase Postgres database,” Cloudflare Docs. https://developers.cloudflare.com/hyperdrive/examples/connect-to-postgres/postgres-database-providers/supabase/ ↩
-
Cloudflare, “Hyperdrive query caching,” Cloudflare Docs. https://developers.cloudflare.com/hyperdrive/configuration/query-caching/ ↩
-
Supabase, “JSON Web Tokens (JWTs),” Supabase Docs. https://supabase.com/docs/guides/auth/jwts ↩
-
Supabase, “Server-side auth (@supabase/ssr),” Supabase Docs. https://supabase.com/docs/guides/auth/server-side ↩
-
Cloudflare, “Rate limiting binding,” Cloudflare Docs. https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/ ↩
-
Cloudflare, “Cache API,” Cloudflare Docs. https://developers.cloudflare.com/workers/runtime-apis/cache/ ↩
-
Supabase, “S3 authentication (Storage),” Supabase Docs. https://supabase.com/docs/guides/storage/s3/authentication ↩
-
Supabase, “Realtime protocol,” Supabase Docs. https://supabase.com/docs/guides/realtime/protocol ↩
-
Supabase, “Realtime benchmarks,” Supabase Docs. https://supabase.com/docs/guides/realtime/benchmarks ↩
-
Supabase, “Broadcast,” Supabase Docs. https://supabase.com/docs/guides/realtime/broadcast ↩
-
Cloudflare, “Durable Objects: use WebSockets,” Cloudflare Docs. https://developers.cloudflare.com/durable-objects/best-practices/websockets/ ↩
-
Supabase, “Database Webhooks,” Supabase Docs. https://supabase.com/docs/guides/database/webhooks ↩
-
Supabase, “pg_net,” Supabase Docs. https://supabase.com/docs/guides/database/extensions/pg_net ↩
-
Supabase, “Cron (pg_cron),” Supabase Docs. https://supabase.com/docs/guides/cron ↩
-
Cloudflare, “Cron Triggers,” Cloudflare Docs. https://developers.cloudflare.com/workers/configuration/cron-triggers/ ↩
-
Cloudflare, “Queues,” Cloudflare Docs. https://developers.cloudflare.com/queues/ ↩
-
Supabase, “Queues (pgmq),” Supabase Docs. https://supabase.com/docs/guides/queues ↩
-
Supabase, “Foreign Data Wrappers,” Supabase Docs. https://supabase.com/docs/guides/database/extensions/wrappers/overview ↩
-
Cloudflare, “R2,” Cloudflare Docs. https://developers.cloudflare.com/r2/ ↩
-
Cloudflare, “R2 Data Catalog,” Cloudflare Docs. https://developers.cloudflare.com/r2/data-catalog/ ↩
-
Cloudflare, “R2 SQL,” Cloudflare Docs. https://developers.cloudflare.com/r2-sql/ ↩ ↩2
-
Cloudflare, “R2 SQL reference,” Cloudflare Docs. https://developers.cloudflare.com/r2-sql/sql-reference/ ↩
-
Supabase, “Edge Functions: regional invocation,” Supabase Docs. https://supabase.com/docs/guides/functions/regional-invocation ↩
-
Supabase, “Edge Functions,” Supabase Docs. https://supabase.com/docs/guides/functions ↩
-
Supabase, “Branching,” Supabase Docs. https://supabase.com/docs/guides/deployment/branching ↩
-
Cloudflare, “wrangler versions upload,” Cloudflare Docs. https://developers.cloudflare.com/workers/wrangler/commands/#versions-upload ↩
-
Supabase, “Management API: create a branch,” Supabase Docs. https://supabase.com/docs/reference/api/v1-create-a-branch ↩
-
Supabase, “Branching configuration and secrets,” Supabase Docs. https://supabase.com/docs/guides/deployment/branching/configuration ↩
-
Cloudflare, “Vectorize,” Cloudflare Docs. https://developers.cloudflare.com/vectorize/ ↩
-
Cloudflare, “Durable Objects: data location and jurisdictions,” Cloudflare Docs. https://developers.cloudflare.com/durable-objects/reference/data-location/ ↩
-
Cloudflare, “Regional Services,” Cloudflare Docs. https://developers.cloudflare.com/data-localization/regional-services/ ↩
-
Cloudflare, “Data Localization Suite,” Cloudflare Docs. https://developers.cloudflare.com/data-localization/ ↩
-
Cloudflare, “Workers VPC,” Cloudflare Docs. https://developers.cloudflare.com/workers-vpc/ ↩
-
Cloudflare, “Workers VPC: VPC Services,” Cloudflare Docs. https://developers.cloudflare.com/workers-vpc/configuration/vpc-services/ ↩
-
Cloudflare, “Durable Objects: data security,” Cloudflare Docs. https://developers.cloudflare.com/durable-objects/reference/data-security/ ↩
-
Supabase, “PrivateLink,” Supabase Docs. https://supabase.com/docs/guides/platform/privatelink ↩
-
Cloudflare, “Connect Hyperdrive to a private database,” Cloudflare Docs. https://developers.cloudflare.com/hyperdrive/configuration/connect-to-private-database/ ↩
-
Supabase, “Postgres connection logging,” Supabase Docs. https://supabase.com/docs/guides/platform/postgres-connection-logging ↩
-
Supabase, “SOC 2 compliance,” Supabase Docs. https://supabase.com/docs/guides/security/soc-2-compliance ↩
-
Supabase, “HIPAA compliance,” Supabase Docs. https://supabase.com/docs/guides/security/hipaa-compliance ↩