Skip to content

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-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) 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 has BYPASSRLS (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 upload Worker preview version that never touches the production route. The trap: a branch that fails on a missing config.toml env() secret is mislabeled MIGRATIONS_FAILED - the migrations are fine, the branch just never got your custom-SMTP/OAuth secrets.

CloudflareSupabase (eu-central-1)BrowserWorker /Pages FunctionHTTPSRealtimeWS (direct)Cache API / KVread cacheHyperdrivepooled SQLPostgRESTsupabase-js / RESTAuth (GoTrue)verify / getUserStoragesigned URLPostgresdirect :5432

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


You needUseCost (SG -> FRA)Why
RLS CRUD, Auth, Storage, the full SDKsupabase-js / PostgREST~210ms / calldefault; RLS enforced by the key; no nodejs_compat
A repeated read made fastWorker Cache API~10ms hitzero extra infra, no nodejs_compat
Real SQL (joins, transactions, COPY) or faster writesHyperdrive~10ms cached, ~172ms writemanaged pooled wire path (raw drivers work too, but the pooler needs specific flags); you own authz
Hot-path authedge JWT verify (jose)~0mslocal after the first JWKS fetch
Revocation-aware authgetUser()~207msround trip; use per sensitive route
Realtimebrowser-direct, or a Durable Object relayn/aa stateless Worker cannot hold a long-lived socket
A per-PR databasepreview branch + Worker preview versionn/aisolated project, auto-migrated, own preview URL

The absolute costs are the Singapore-to-Frankfurt figures below; the choice in each row is geography-independent.


25-row / ~50KB reads and single-row writes, n=40/n=20, times in milliseconds, from the Singapore 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 - no auth.uid() context, so you own authz; RLS still applies by role
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

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.

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 a plpgsql/sql function2 pays ~210ms once instead of N times. Push multi-step reads and writes into a function rather than chaining supabase-js calls across the wire. Pasteriser’s view_paste(uuid) collapses a read + a read_count bump + a conditional burn-delete (three wire round trips) into one .rpc(), and its SELECT ... FOR UPDATE also 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-1 for 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.

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.

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:6543IPv4From a Worker needs prepare: false + a pooler SSL workaround; 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;
-- 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 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 cost9:

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


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 SQLRLS still applies unless the DB role has BYPASSRLS or owns the table; there is just no auth.uid() for user-scoped policiesDB role, no JWT
Auth (edge verify)jose signature + exp/aud checksJWKS (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.

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


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 object
await authorize(request, env); // authorize FIRST - a cache HIT must not skip authz
const cache = caches.default;
const key = cacheKeyForScope(request); // scope the key so one fill can't leak across callers
let 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 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.co socket 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.

Supabase (eu-central-1)CloudflarePostgresRealtimerealtime.send()trigger on public insertRecentFeedDO(one instance)1 upstream WS(anon JWT, private chan)Browsers (N)fan-out over Nsame-origin sockets

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 ignores sb_* tokens as a private-channel access_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 the access_token must be a real signed anon/user JWT - still never the sb_publishable_ key.)
  • The DO’s outbound socket is fetch("https://...", { headers: { Upgrade: "websocket" } }), not new WebSocket("wss://..."). A Worker’s fetch only upgrades an http(s) URL; a wss:// URL is rejected, so the upstream never connects. (A browser WebSocket accepts wss://, so this only bites server-side.)
  • Response middleware must skip the 101. A header-adding middleware that re-wraps every response with new Response(body, init) drops the non-standard webSocket property, and the browser upgrade fails with a 1002.
  • Rejoin with Broadcast replay. On reconnect, send broadcast.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
});
}
}

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 / DELETE and 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 calling pg_net, so they are async: the triggering transaction commits first, the HTTP call fires after.18
  • pg_net is 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, speaks http_get / http_post / http_delete only (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
SupabaseCloudflarePostgrestriggerINSERT / UPDATE / DELETEpg_net(net.http_post)Worker routePOST JSON, after commit(+ shared-secret header)purge cache /fan-out / notify

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_net is at-least-once and webhooks can retry, so key on the row id + version and no-op on repeats.

Three schedulers, and the split is where the work runs:

  • pg_cron runs SQL on a schedule inside Postgres - zero network hop, transactional with your data. Right for data maintenance. Pasteriser burns expired rows every 5 minutes with DELETE FROM pastes WHERE id IN (SELECT id FROM pastes WHERE expires_at < now() LIMIT 1000); the LIMIT 1000 is load-bearing - an unbounded DELETE takes 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_cron schedule (or a Worker via pgmq_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 stripe schema, a warehouse schema); if a column must reach the API, expose it through a security definer function with explicit filters and a grant execute to 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:

Terminal window
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.


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-region header / 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.

GitHub ActionsCloudflareSupabasePR opened / syncgate(typecheck/lint/test/build)PR closedpreview branch(isolated Postgres)DELETE /v1/branches/{id}provision branch(Management API + secrets)wrangler versionsupload --varurl + secret keyPOST /v1/projects/{ref}/branchesWorker preview version(own URL)reads branch DB (REST)

The Worker preview is bound to the branch DB with two --var overrides at upload:

Terminal window
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.


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.

NeedCloudflareSupabasePick Cloudflare when
Object storageR2 (zero egress)StorageHeavy egress or edge serving; Storage for RLS + built-in image transforms on one platform
Vectors / embeddingsVectorize35pgvectorPure ANN at the edge; pgvector when vectors live beside relational data and RLS
Edge config / cacheKVa table / VaultRead-mostly config at the edge with no SQL
Edge SQLD1 (SQLite)PostgresEdge-local, low-write, per-tenant; Postgres for the real relational store
Analytical / columnar queryR2 SQL + Data Catalog (Iceberg)27Postgres analytical queries / a warehouse FDWBig append-only / event data queried column-wise, zero-egress across engines; Postgres for transactional, RLS’d rows
QueueQueuespgmqCross-service async; pgmq when it must be transactional with the row write
ScheduleCron Triggerspg_cronApp logic on a timer; pg_cron for in-DB SQL
AI inferenceWorkers 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.


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.

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.

SurfaceDefault locationPin it to a regionCompliance note
Supabase projectRegion 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 replicaThe replica’s own regionThat region is the choiceA 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 ObjectWherever it was first createdenv.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 dataGlobal 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.

HopDefaultMake it private / in-region
Browser -> CF PoP -> WorkerTLS 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 -> SupabasePublic-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 / storageTLS in flight; AES-256 at rest (LUKS), CF-managed keys41n/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.

SurfaceDefaultResidency control / note
CF Customer LogsStored per Cloudflare’s defaultCustomer Metadata Boundary (Enterprise) sets the storage region
DO logs + analyticsUS-onlyNo EU option - under CMB set to EU the DO metrics tab does not populate
Postgres connection loggingOff (auto-on for High-Compliance / HIPAA projects)The SOC 2 / HIPAA audit trail4445
wrangler tail + broadcast payloadCarries row dataTreat 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.

What does the Workerneed from Supabase?DataaccessAuthcheckRealtimepushPer-PRpreviewsupabase-js / PostgREST(RLS enforced by the key)need RLS, Auth,Storage, or the SDKWorker Cache APIa hot read youcan cacheHyperdrive(you own authz)joins/transactionsor faster writesEdge JWT verify(jose + JWKS)hot path,latency mattersgetUser()round-triprevocation musttake effect nowBrowser-direct socketpublic data, anonkey in browser OKDurable Object relaykeep the key server-side(CSP stays self)Preview binding(two REST vars)app talks RESTPer-branchHyperdrive configapp needs thewire protocol

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:

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'

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

  1. Cloudflare, “Supabase (third-party integrations),” Cloudflare Docs. https://developers.cloudflare.com/workers/databases/third-party-integrations/supabase/

  2. Supabase, “Database Functions,” Supabase Docs. https://supabase.com/docs/guides/database/functions

  3. Cloudflare, “Smart Placement,” Cloudflare Docs. https://developers.cloudflare.com/workers/configuration/smart-placement/

  4. Supabase, “Read replicas,” Supabase Docs. https://supabase.com/docs/guides/platform/read-replicas

  5. Supabase, “Connecting to your database,” Supabase Docs. https://supabase.com/docs/guides/database/connecting-to-postgres

  6. Supabase, “Serverless drivers,” Supabase Docs. https://supabase.com/docs/guides/database/connecting-to-postgres/serverless-drivers

  7. Cloudflare, “Connect Hyperdrive to a Supabase Postgres database,” Cloudflare Docs. https://developers.cloudflare.com/hyperdrive/examples/connect-to-postgres/postgres-database-providers/supabase/

  8. Cloudflare, “Hyperdrive query caching,” Cloudflare Docs. https://developers.cloudflare.com/hyperdrive/configuration/query-caching/

  9. Supabase, “JSON Web Tokens (JWTs),” Supabase Docs. https://supabase.com/docs/guides/auth/jwts

  10. Supabase, “Server-side auth (@supabase/ssr),” Supabase Docs. https://supabase.com/docs/guides/auth/server-side

  11. Cloudflare, “Rate limiting binding,” Cloudflare Docs. https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/

  12. Cloudflare, “Cache API,” Cloudflare Docs. https://developers.cloudflare.com/workers/runtime-apis/cache/

  13. Supabase, “S3 authentication (Storage),” Supabase Docs. https://supabase.com/docs/guides/storage/s3/authentication

  14. Supabase, “Realtime protocol,” Supabase Docs. https://supabase.com/docs/guides/realtime/protocol

  15. Supabase, “Realtime benchmarks,” Supabase Docs. https://supabase.com/docs/guides/realtime/benchmarks

  16. Supabase, “Broadcast,” Supabase Docs. https://supabase.com/docs/guides/realtime/broadcast

  17. Cloudflare, “Durable Objects: use WebSockets,” Cloudflare Docs. https://developers.cloudflare.com/durable-objects/best-practices/websockets/

  18. Supabase, “Database Webhooks,” Supabase Docs. https://supabase.com/docs/guides/database/webhooks

  19. Supabase, “pg_net,” Supabase Docs. https://supabase.com/docs/guides/database/extensions/pg_net

  20. Supabase, “Cron (pg_cron),” Supabase Docs. https://supabase.com/docs/guides/cron

  21. Cloudflare, “Cron Triggers,” Cloudflare Docs. https://developers.cloudflare.com/workers/configuration/cron-triggers/

  22. Cloudflare, “Queues,” Cloudflare Docs. https://developers.cloudflare.com/queues/

  23. Supabase, “Queues (pgmq),” Supabase Docs. https://supabase.com/docs/guides/queues

  24. Supabase, “Foreign Data Wrappers,” Supabase Docs. https://supabase.com/docs/guides/database/extensions/wrappers/overview

  25. Cloudflare, “R2,” Cloudflare Docs. https://developers.cloudflare.com/r2/

  26. Cloudflare, “R2 Data Catalog,” Cloudflare Docs. https://developers.cloudflare.com/r2/data-catalog/

  27. Cloudflare, “R2 SQL,” Cloudflare Docs. https://developers.cloudflare.com/r2-sql/ 2

  28. Cloudflare, “R2 SQL reference,” Cloudflare Docs. https://developers.cloudflare.com/r2-sql/sql-reference/

  29. Supabase, “Edge Functions: regional invocation,” Supabase Docs. https://supabase.com/docs/guides/functions/regional-invocation

  30. Supabase, “Edge Functions,” Supabase Docs. https://supabase.com/docs/guides/functions

  31. Supabase, “Branching,” Supabase Docs. https://supabase.com/docs/guides/deployment/branching

  32. Cloudflare, “wrangler versions upload,” Cloudflare Docs. https://developers.cloudflare.com/workers/wrangler/commands/#versions-upload

  33. Supabase, “Management API: create a branch,” Supabase Docs. https://supabase.com/docs/reference/api/v1-create-a-branch

  34. Supabase, “Branching configuration and secrets,” Supabase Docs. https://supabase.com/docs/guides/deployment/branching/configuration

  35. Cloudflare, “Vectorize,” Cloudflare Docs. https://developers.cloudflare.com/vectorize/

  36. Cloudflare, “Durable Objects: data location and jurisdictions,” Cloudflare Docs. https://developers.cloudflare.com/durable-objects/reference/data-location/

  37. Cloudflare, “Regional Services,” Cloudflare Docs. https://developers.cloudflare.com/data-localization/regional-services/

  38. Cloudflare, “Data Localization Suite,” Cloudflare Docs. https://developers.cloudflare.com/data-localization/

  39. Cloudflare, “Workers VPC,” Cloudflare Docs. https://developers.cloudflare.com/workers-vpc/

  40. Cloudflare, “Workers VPC: VPC Services,” Cloudflare Docs. https://developers.cloudflare.com/workers-vpc/configuration/vpc-services/

  41. Cloudflare, “Durable Objects: data security,” Cloudflare Docs. https://developers.cloudflare.com/durable-objects/reference/data-security/

  42. Cloudflare, “Connect Hyperdrive to a private database,” Cloudflare Docs. https://developers.cloudflare.com/hyperdrive/configuration/connect-to-private-database/

  43. Supabase, “Postgres connection logging,” Supabase Docs. https://supabase.com/docs/guides/platform/postgres-connection-logging

  44. Supabase, “SOC 2 compliance,” Supabase Docs. https://supabase.com/docs/guides/security/soc-2-compliance

  45. Supabase, “HIPAA compliance,” Supabase Docs. https://supabase.com/docs/guides/security/hipaa-compliance