Cloudflare D1 Sessions: Bookmarks, Read Replicas, and Sequential Consistency
Carry D1 bookmarks across Worker requests for sequentially consistent replica reads; distinguish sessions from transactions, authorization, and shared caching.
Cloudflare D1 read replication can place read-only database copies closer to users, but a request that reaches a replica may otherwise observe a version that has not yet received a recent write. The D1 Sessions API adds a consistency context to queries: bookmarks let a later query begin from a database version at least as recent as the version already observed by that logical session.
The important word is sequential. D1 documents sequential consistency for queries made through a session, including monotonic reads and read-your-own-writes behavior. A session is not a long-lived connection pinned to one replica, and its bookmark is not a multi-request SQL transaction or an authorization token. Design those boundaries explicitly rather than treating “session” as a synonym for a database transaction.
How a D1 session relates to replicas
D1 writes are routed to the primary database instance. When read replication is enabled, replicas receive changes asynchronously and can temporarily lag. D1’s Sessions API carries a bookmark so that a later query can be routed only to an instance at least as current as the bookmark requires. The service may satisfy that requirement with a replica that has caught up or with the primary; the application should not assume that every read is served locally or that a replica never waits for replication.
The Worker binding exposes withSession() and getBookmark(). withSession("first-unconstrained") allows the first query to use any available instance and is the default. It is useful when the initial read does not require the newest database version. withSession("first-primary") directs the first query to the primary; later queries in that session may still use replicas while preserving the session’s sequential ordering. Passing a bookmark from an earlier session starts the new session at a database version at least as current as that bookmark.
Read replication is an optimization for read latency and throughput, not a replacement for correctness tests. D1’s documentation says that the Sessions API is required to use read replicas through the Worker binding; the D1 REST API does not expose the Sessions API. The database setting enables replication, but merely enabling that setting does not make ordinary env.DB.prepare(...).run() calls use the session consistency contract.
Carry the bookmark across an HTTP boundary
Create one session for a request, use that session for the reads that belong to the same consistency decision, then persist the latest bookmark in authenticated application state for the next request. The following Worker integration sketch deliberately does not accept a raw bookmark header or return bookmark state to the browser. It tenant-scopes the SQL query to the verified principal; adapt that predicate to the application’s actual authorization model.
interface BookmarkStore {
// The adapter returns "valid" only for a D1-issued value integrity-bound
// to this principal and database; corrupt, foreign, or malformed state is
// reported as "invalid" rather than passed through.
get(principalId: string, databaseId: string): Promise<
{ kind: "none" } | { kind: "valid"; value: string } | { kind: "invalid" }
>;
// Atomically retain the lexicographically newer D1 bookmark on races.
advance(principalId: string, databaseId: string, bookmark: string): Promise<void>;
}
interface Env {
DB: D1Database;
BOOKMARKS: BookmarkStore;
DB_ID: string;
}
// Application-specific authentication: return only a verified principal.
declare function authenticateRequest(request: Request, env: Env): Promise<{ id: string } | null>;
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (request.method !== "GET" || url.pathname !== "/api/orders") {
return new Response("Not found", { status: 404 });
}
const principal = await authenticateRequest(request, env);
if (!principal) return new Response("Unauthorized", { status: 401 });
// The bookmark comes from server-side state keyed by the authenticated
// principal and this database, never directly from a request header.
const stored = await env.BOOKMARKS.get(principal.id, env.DB_ID);
const maxBookmarkChars = 1024; // Application policy, not a D1 limit.
if (stored.kind === "invalid") {
return new Response("Invalid session state", { status: 400 });
}
const incoming = stored.kind === "valid" ? stored.value : null;
if (incoming !== null && incoming.length > maxBookmarkChars) {
return new Response("Invalid session state", { status: 400 });
}
const session = env.DB.withSession(incoming ?? "first-unconstrained");
// This example assumes orders.owner_id is the application's tenant key.
const result = await session
.prepare("SELECT id, status, updated_at FROM orders WHERE owner_id = ? AND status = ? ORDER BY updated_at DESC LIMIT 50")
.bind(principal.id, "open")
.run();
const headers = new Headers({ "Cache-Control": "private, no-store" });
const next = session.getBookmark();
if (next !== null) await env.BOOKMARKS.advance(principal.id, env.DB_ID, next);
return Response.json(result.results, { headers });
},
};
This is an integration sketch, not a drop-in Worker: BookmarkStore and authenticateRequest() are application-owned components, and the illustrative orders table is assumed to have an owner_id tenant column matching the verified principal. The store must be inaccessible to unauthenticated callers and keyed by both principal and database identity; get() must accept only a bookmark previously produced by D1 for that exact identity and classify malformed, corrupt, or foreign values as invalid. advance() must atomically retain the newer bookmark when parallel requests race; Cloudflare documents D1 bookmark ordering as lexicographic from oldest to newest, so the store can use that documented ordering without parsing or fabricating bookmark contents. If you instead accept a client-carried value, authenticate or cryptographically validate it, bind it to that same database and principal, cap its size, and reject invalid state before calling withSession(). The 1,024-character ceiling above is an application policy, not a D1 guarantee. Do not return a raw bookmark to an untrusted client merely because D1 calls it a bookmark.
The binding’s getBookmark() returns the latest database version seen by the last query in that session, or null if no query ran. Do not invent or persist a bookmark when it is null. The example sets a private, non-cacheable response because the result is principal-specific. If the Worker serves browser requests cross-origin, configure its CORS policy deliberately; do not expose internal session state just to make a custom header readable by page JavaScript.
If a client-carried bookmark is unavoidable, an HttpOnly; Secure; SameSite=Lax cookie can reduce JavaScript exposure, but those attributes do not provide integrity or bind the value to a user or database. Protect it with authenticated or signed state that is bound to the principal and database, enforce an application-sized limit, validate it before withSession(), and map invalid state to a controlled client error. Server-side state avoids exposing the opaque bookmark altogether. In either design, the bookmark only supplies a consistency starting point; authorize each record and operation independently. It does not prove who the caller is, grant access to rows, or make a stale authorization decision safe.
Avoid bookmark regressions under concurrent requests
An ordinary request-response chain can carry the previous response’s bookmark into the next request. Parallel requests are different: two responses can be produced from the same starting bookmark and arrive in the opposite order. If a late response overwrites newer client state with an older bookmark, the next request can start from a weaker point than one sibling request had already observed.
Serialize operations that must form one causal user journey, or make bookmark persistence concurrency-aware. D1 documents bookmark ordering as lexicographic from oldest to newest, so a server-side coordinator can apply that documented ordering when it merges bookmarks for the same database. Keep the database identity in that key: never compare or merge bookmarks from different databases, and do not parse or fabricate their contents. A browser implementation should also associate responses with request generations so an older response cannot replace newer state merely because it completed later.
This matters for workflows such as “write a cart item, then reload the cart,” or “read an inventory value, then show the result of the user’s update.” It is less important for independent public catalogue reads that are allowed to be slightly stale. Choose the initial policy per endpoint: first-primary when the first result itself must start from the primary’s current version, a prior bookmark when the user needs continuity from an earlier request, and unconstrained start when lower initial latency is more important than seeing the latest global write.
Do not confuse sequential consistency with a transaction
A D1 session bookmark constrains the minimum version a later session may observe. It does not hold a database lock, reserve rows, freeze the whole database at one snapshot, or make a client journey atomic. Other clients can write between two HTTP requests. If a business operation must atomically update several rows, use a supported server-side transaction boundary such as D1’s prepared-statement batch() and design the operation so its statements commit or fail as one unit. Do not attempt to keep a transaction open in a browser between requests.
Similarly, first-primary is not a permanent “primary-only” switch. Cloudflare documents that subsequent queries in the session may use read replicas. A session bookmark is useful for a later write-followed-by-read flow, but it does not replace conditional updates, uniqueness constraints, idempotency keys, or other database invariants when concurrent writers can race.
Verify the deployed behavior, not just local syntax
Local D1 development does not create the production read replicas. Cloudflare documents that served_by_region and served_by_primary are undefined for local wrangler dev D1 results. In remote Worker-binding results, both fields are present regardless of whether read replication is enabled or the query used the Sessions API. Use a non-production remote database with read replication enabled and a session-based Worker path to verify real replica routing and latency. Record the Wrangler version, database configuration, region metadata, and request bookmark flow when comparing before and after.
Test at least these cases: an initial unconstrained read; a request starting with first-primary; a write followed by a read carrying the returned bookmark; two sequential reads that must not move backward; no query executed and therefore no bookmark; malformed or oversized client state; parallel responses arriving out of order; and a request with no valid prior cookie. Also verify that the endpoint still enforces row-level authorization, never exposes database errors or bookmarks in logs unnecessarily, and does not cache user-specific results publicly.
Measure end-to-end latency, not only SQL execution time. A session may preserve consistency while waiting for a replica to catch up, and a first-primary query can pay the round-trip to the primary. Inspect D1 result metadata to see the serving region and whether the primary handled a remote query. Treat those fields as diagnostics, not a promise that every future request will use the same region.
Related:
- Cloudflare D1 Migrations and Time Travel: Safe Schema Changes and Point-in-Time Recovery
- Cloudflare Workers and Pages: Wrangler Local Development, Staging, and Safe Rollouts
Sources: