Read replication
Serve reads from D1's regional replicas with the Sessions API — one config flag, one response header, sequential consistency per client.
Cloudflare D1 can keep read-only copies of a database in every region
(ENAM, WNAM, WEUR, EEUR, APAC, OC) at no extra cost. Replicas only serve
queries made through the D1 Sessions
API;
plain binding queries always go to the primary. readReplication makes the
generated API use that session for every request-scoped feature read.
// quickback.config.ts
database: defineDatabase("cloudflare-d1", {
readReplication: true,
}),Then enable replication on the features database itself — Settings →
Enable Read Replication in the D1 dashboard, or PUT the database with
{ "read_replication": { "mode": "auto" } } through the REST API. The
compiler never flips that switch. The generated code is correct either way:
without replicas every session runs on the primary, so turning the flag on
first and replication on later is safe.
D1 only. Postgres providers reject the option; Neon's read replicas are a connection-string concern.
What the generated API does
Each request's db handle runs on a D1 session. The session constraint is
chosen by method:
| Request | Session starts | Why |
|---|---|---|
GET / HEAD carrying the bookmark header | at that bookmark | The client continues its own session: monotonic reads, read-your-writes |
GET / HEAD without the header | first-unconstrained | Nearest replica — the common case for a fresh page load |
Everything else (POST, PATCH, PUT, DELETE, actions, batch) | first-primary | Writes go to the primary anyway; starting there keeps a handler's reads after its own write fresh |
Every response carries the session's bookmark:
HTTP/1.1 200 OK
x-d1-bookmark: 00000085-0000000a-00004e6e-9d8f2c1b
x-d1-served-by: WEUR;primary=falseA client that sends the bookmark back on its next GET gets sequential
consistency across requests: it never reads an older version of the database
than the one it last saw, and it sees its own writes. A client that ignores
the header gets eventual consistency on reads only — the same thing any
replicated database gives an un-sessioned reader — and still gets fresh
reads inside every write. Both headers are CORS-exposed so browser code can
read them.
x-d1-served-by is for verifying a rollout: it is set only when D1 reports
where the session ran (meta.served_by_region / served_by_primary), which
is every remote request and never wrangler dev.
Echo the bookmark from your client
let bookmark: string | null = null;
async function api(path: string, init: RequestInit = {}) {
const headers = new Headers(init.headers);
if (bookmark) headers.set("x-d1-bookmark", bookmark);
const res = await fetch(`${API}${path}`, { ...init, headers, credentials: "include" });
bookmark = res.headers.get("x-d1-bookmark") ?? bookmark;
return res;
}One variable per browser tab or app session is enough. Sending a bookmark from a different session is harmless: D1 only guarantees the new session is at least as fresh as the bookmark.
Only a well-formed bookmark (four 8-hex groups, as D1 issues them) continues
a session. Any other header value is ignored and the request starts
first-unconstrained, so a stale or hand-edited header can never fail a
query.
Rename the header
readReplication: { header: "x-bookmark" },The name must be a valid HTTP header token. It is matched case-insensitively on the way in and emitted lowercase.
What stays on the primary
Only the per-request features db rides the session. These keep their own
primary handles, exactly as before:
- the auth database (Better Auth's adapter and
c.get('authDb')) — session and membership rows must never be read from a lagging replica - the audit and webhooks databases
- schedules, queues and email handlers — no request, no client, no bookmark
- Durable Objects and anything that builds its own
drizzle(env.DB)
A read that must be fresh regardless of the client — polling a payment
status, an OTP flow — belongs in an action or a non-GET route, which starts
on the primary.
Replica lag
Replication is asynchronous and a first-unconstrained read may be
arbitrarily old. D1 bounds it per session, not globally: the session waits
until a replica has caught up to the bookmark you passed. That is why writes
start first-primary and why the bookmark is worth echoing.
With queryTimeoutMs set, the
deadline wraps the session as well — a replica that takes too long to catch up
to a bookmark answers 503 DB_TIMEOUT like any other slow read.
Related
- Using D1 — queries, dual-DB mode, backend stalls
- Database options — every
defineDatabase("cloudflare-d1")option - Cloudflare: Global read replication