D1 read replication: global reads with consistent sessions
D1 read replication, now in public beta, places read-only copies of your database in multiple regions across Cloudflare's network. For read-heavy workloads — e-commerce sites, content management tools, mobile backends — the benefit is twofold: user requests are routed to nearby replicas, cutting average latency, and read queries are offloaded from the primary, freeing it to handle more writes.
The primary database holds the authoritative copy; read replicas are automatically created and maintained by the D1 service. When you enable replication, request routing is handled for you based on performance heuristics, the types of queries in a request, and consistency needs expressed by your application. Replica creation and routing require no additional cost — usage is based on the same rows_read and rows_written metrics as without replication.
The Sessions API
To use read replication, your Worker must adopt the new D1 Sessions API. A session groups all queries that form one logical unit of application work — for instance, all requests originating from a single browser tab or from one user's mobile app. Within a session, queries can be routed to whatever database copy makes sense for each request, but the D1 service guarantees sequential consistency across all of them.
Sequential consistency provides properties that matter for transactional applications: “read my own writes,” “writes follow reads,” and a total ordering of writes. Every replica observes transactions committed in the same order, meaning reads and writes execute in the order your code issues them. Consider an online store where a user places an order (write) and then views their account page (read that may hit a replica): the new order must appear in that list. Likewise, a bank transfer followed immediately by a balance check must reflect the payment.
Why is the Sessions API necessary instead of querying replicas directly? D1 runs across Cloudflare's global network, and there is no guarantee that requests from the same client will route to the same replica. Clients switch networks, data centers go into maintenance, and traffic shifts. Read replication is asynchronous, so a newly selected replica may lag behind the one used previously — it might not yet have learned of writes that just completed. Without sessions, the only consistency guarantee would be read committed, which isn't very useful for most applications.
The Sessions API flips the problem: instead of forcing the same replica, D1 uses bookmark information from the session to ensure whichever replica handles a query is sufficiently up to date.
export default {
async fetch(request: Request, env: Env) {
// A. Create the session.
// When we create a D1 session, we can continue where we left off from a previous
// session if we have that session's last bookmark or use a constraint.
const bookmark = request.headers.get('x-d1-bookmark') ?? 'first-unconstrained'
const session = env.DB.withSession(bookmark)
// Use this session for all our Workers' routes.
const response = await handleRequest(request, session)
// B. Return the bookmark so we can continue the session in another request.
response.headers.set('x-d1-bookmark', session.getBookmark())
return response
}
}
async function handleRequest(request: Request, session: D1DatabaseSession) {
const { pathname } = new URL(request.url)
if (request.method === "GET" && pathname === '/api/orders') {
// C. Session read query.
const { results } = await session.prepare('SELECT * FROM Orders').all()
return Response.json(results)
} else if (request.method === "POST" && pathname === '/api/orders') {
const order = await request.json<Order>()
// D. Session write query.
// Since this is a write query, D1 will transparently forward it to the primary.
await session
.prepare('INSERT INTO Orders VALUES (?, ?, ?)')
.bind(order.orderId, order.customerId, order.quantity)
.run()
// E. Session read-after-write query.
// In order for the application to be correct, this SELECT statement must see
// the results of the INSERT statement above.
const { results } = await session
.prepare('SELECT * FROM Orders')
.all()
return Response.json(results)
}
return new Response('Not found', { status: 404 })
}
Creating a session uses the withSession method, which takes either a bookmark or a constraint (step A). The constraint tells D1 where to route the session's first query: first-unconstrained allows any replica to process it without restrictions on freshness, while first-primary forwards it to the primary.
// A. Create the session.
const bookmark = request.headers.get('x-d1-bookmark') ?? 'first-unconstrained'
const session = env.DB.withSession(bookmark)
An explicit bookmark tells D1 that the database instance processing the query must be at least as up-to-date as that bookmark (the primary is always up-to-date). Explicit bookmarks are how sessions can be continued across user requests, maintaining sequential consistency. Once you have a session, you issue queries as you normally would with D1; the session object ensures they are sequentially consistent with one another.
// C. Session read query.
const { results } = await session.prepare('SELECT * FROM Orders').all()
In the example worker, a read query listing orders (step C) returns results at least as current as the bookmark used to create the session (step A). More interesting is a write adding an order (step D) followed by a read listing all orders (step E): since both execute in the same session, the read is guaranteed to observe a database copy that includes the write. A single batch to the primary could accomplish the same, but sessions let reads travel to replicas and keep the primary for writes.
Bookkeeping is handled for you: the session tracks the latest bookmark observed across all queries executed within it and includes that bookmark in every request to D1. Queries issued outside the session object, however, are not guaranteed sequential consistency with those inside it.
// D. Session write query.
await session
.prepare('INSERT INTO Orders VALUES (?, ?, ?)')
.bind(order.orderId, order.customerId, order.quantity)
.run()
// E. Session read-after-write query.
const { results } = await session
.prepare('SELECT * FROM Orders')
.all()
Where possible, we recommend carrying sessions across requests by returning the bookmark to the client (step B) so future requests can pass it back. Grab the current bookmark at the end of a request with session.getBookmark() and send it in HTTP headers, cookies, or the response body.
What sessions fix
Without the Sessions API, a classic read-after-write scenario can break. A write is processed by the primary; the subsequent read is routed to a replica that trails the primary and does not yet contain the write. The returned data is inconsistent with what the application just committed.

Sessions eliminate this problem. The write goes to the primary and the response includes a bookmark — call it “Bookmark 100,” stored transparently by the session object. The next read hits a replica, but because the request carries “Bookmark 100,” the replica waits until its copy is at least that current before processing the query and returning results — along with its own latest bookmark, possibly more advanced (say “Bookmark 104”) if other clients' writes replicated to that replica in the meantime.

Enabling read replication
Getting started is a two-step process:
- Update your Worker to use the D1 Sessions API, which also works for databases without read replication enabled, so you can ship the code change before enabling replicas.
- Enable replicas for your database: Cloudflare dashboard > select the D1 database > Settings.
Replication is built into D1, with no per-replica storage or compute costs. Unlike traditional replication systems, you don't manually create replicas, choose regions, or configure routing. Since the feature is in beta, try it on a non-production database first, then migrate to production workloads after validating behavior.
Observing replica behavior
Once replication is on, read queries begin hitting replicas. Each query response includes metadata inside the nested meta object: served_by_region identifies the region of the database instance that handled the query, and served_by_primary is true if and only if the primary processed it. The D1 dashboard overview also shows how many queries were handled by the primary versus replicas, plus a per-region breakdown of queries and rows read.


Anatomy of D1’s replicated architecture
D1 runs on SQLite-backed Durable Objects, which in turn sit on Cloudflare’s Storage Relay Service. The service has three layers: a binding API inside the customer’s Worker, a stateless routing layer, and the Durable Objects that execute SQL operations.

For a non-replicated database, one Durable Object handles everything. Request flow starts in the user’s Worker, which calls the D1 binding. Routing logic maps the database ID to its Durable Object, fetches an RPC stub, and opens a connection — regardless of where that object physically runs. The Durable Object then executes the query against a local SQLite database using the Durable Objects SQL API.
SQLite operates in WAL mode, so every write appends to the write-ahead log. The Storage Relay Service leader replicates those WAL entries synchronously to five durability followers spread across different datacenters. When at least three of the five acknowledge safe storage, the leader lets the write commit and releases the Durable Object’s output gate.
The WAL gives D1 a complete, ordered history of committed changes. That log becomes the foundation for several capabilities:
- Each write is identified by a Lamport timestamp, called a bookmark, which uniquely orders the write in the database’s history.
- New database copies can be constructed anywhere by fetching the latest snapshot from cold storage and replaying WAL entries from that point forward.
- Point-in-time recovery replays entries up to a specific bookmark instead of to the end of the log.
WAL entries are stored in write order, which is not always the fastest way to service reads. SQLite checkpoints periodically copy WAL entries back into the main database file, and reads are served from whichever file — main or WAL — holds the most recent committed data. The Storage Relay Service similarly snapshots the database to cold storage to avoid replaying enormous numbers of individual entries when reconstructing a database.
Because writes stream out of the leader in real time, the same mechanism that feeds cold storage can feed replicas.

Building replicas
Read replication was implemented in five steps.
Step one: create read-only replica objects. A replica boots by fetching the latest snapshot and replaying the log from cold storage up to the primary’s last committed bookmark. These initial replicas were effectively point-in-time copies — they only advanced when the Durable Object restarted.
Step two: stream WAL entries to replicas. The replica leader registers with the primary’s leader, which then sends every new WAL entry to the replica at the same time it sends entries to durability followers. Each entry carries its bookmark. Because these writes reach the replica before a quorum of durability followers confirms them, they are technically unconfirmed. The replica leader implements enough of SQLite’s WAL-index protocol to make those unconfirmed writes look like any other in-flight SQLite client write — SQLite ignores them until the primary confirms them. The replica leader can then commit them the moment confirmation arrives.
Sending writes as soon as they are generated minimizes lag. If a write query is proxied through a replica to the primary, the response can return to the replica at nearly the same instant as the WAL update — effectively zero observable replica lag.
Step three: teach the router about replicas. When read replication is enabled, a static set of replicas is provisioned in every region D1 supports, and the routing policy sends each request to the replica closest to the user. At this point replicas are updateable and can accept routed traffic.
Step four: handle writes at replicas. D1 uses SQLite to determine whether a query is a read or a write, and that determination happens only after routing. Replicas are therefore instantiated with a reference to their primary and forward any write query to it. The forwarding is transparent to user code.
Step five: enforce session consistency. With replicas serving requests from different regions, a user could hit different copies of the database and see different states. The Sessions API solves this using bookmarks, which are strictly monotonically increasing — every committed write produces a bookmark larger than any before it.
The Sessions API algorithm
The mechanism spans the binding, the stateless Worker, and the Durable Object layers.
In the binding, code creates the D1DatabaseSession object and tracks the latest bookmark seen by that session.
// D1Binding is the binding code running within the user's Worker
// that provides the existing D1 Workers API and the new withSession method.
class D1Binding {
// Injected by the runtime to the D1 Binding.
d1Service: D1ServiceBinding
function withSession(initialBookmark) {
return D1DatabaseSession(this.d1Service, this.databaseId, initialBookmark);
}
}
// D1DatabaseSession holds metadata about the session, most importantly the
// latest bookmark we know about for this session.
class D1DatabaseSession {
constructor(d1Service, databaseId, initialBookmark) {
this.d1Service = d1Service;
this.databaseId = databaseId;
this.bookmark = initialBookmark;
}
async exec(query) {
// The exec method in the binding sends the query to the D1 Worker
// and waits for the the response, updating the bookmark as
// necessary so that future calls to exec use the updated bookmark.
var resp = await this.d1Service.handleUserQuery(databaseId, query, bookmark);
if (isNewerBookmark(this.bookmark, resp.bookmark)) {
this.bookmark = resp.bookmark;
}
return resp;
}
// batch and other SQL APIs are implemented similarly.
}
The binding calls into the stateless Worker (d1Service), which determines the target Durable Object and proxies the request.
class D1Worker {
async handleUserQuery(databaseId, query) {
var doId = /* look up Durable Object for databaseId */;
return await this.D1_DO.get(doId).handleWorkerQuery(query, bookmark)
}
}
The Durable Object layer then handles the request.
class D1DurableObject {
async handleWorkerQuery(queries, bookmark) {
var bookmark = bookmark ?? "first-primary";
var results = {};
if (this.isPrimaryDatabase()) {
// The primary always has the latest data so we can run the
// query without checking the bookmark.
var result = /* execute query directly */;
bookmark = getCurrentBookmark();
results = result;
} else {
// This is running on a replica.
if (bookmark === "first-primary" || isWriteQuery(query)) {
// The primary must handle this request, so we'll proxy the
// request to the primary.
var resp = await this.primary.handleWorkerQuery(query, bookmark);
bookmark = resp.bookmark;
results = resp.results;
} else {
// The replica can handle this request, but only after the
// database is up-to-date with the bookmark.
if (bookmark !== "first-unconstrained") {
await waitForBookmark(bookmark);
}
var result = /* execute query locally */;
bookmark = getCurrentBookmark();
results = result;
}
}
return { results: results, bookmark: bookmark };
}
}
Each Durable Object decides whether it can execute the query locally or must forward it to the primary. If it can run locally, it waits until its own state is at least as current as the bookmark requested by the binding. The combined logic guarantees that every query in a session sees a database state that is sequentially consistent — each new query is blocked until it has observed the effects of all previous queries in that session.
Replication performance
Internally, Cloudflare measures confirm lag: the time from when a primary confirms a change to when a replica confirms it. Measurements for two databases with primaries in different regions show that lag closely tracks network round-trip time.
|
|
Database A (Primary region: ENAM) |
Database B |
|
ENAM |
N/A |
30 ms |
|
WNAM |
45 ms |
N/A |
|
WEUR |
55 ms |
75 ms |
|
EEUR |
67 ms |
75 ms |
Geographic distance is the dominant factor. For instance, replicas in Western Europe are closer to North American primaries than those in Eastern Europe, and confirmation latency reflects that. Region sizes matter too — a primary placed in one corner of a large region like ENAM will experience more lag relative to another datacenter in the same region than a primary placed more centrally.
As the data shows, replication speed is ultimately bounded by physics.
Availability and next steps
Read replication automatically provisions replicas in every supported region, and the routing layer sends each request to the nearest copy. This allows read queries to be served close to users while maintaining strong sequential consistency within sessions.
The Sessions API is currently available only through the D1 Worker Binding; HTTP REST API support is planned. Cloudflare is also exploring evolving replica placement policies and expects developer feedback to guide future changes.



