Read replicas & placement
Serve one-shot queries from a copy of the shard in the caller's region, pin where a shard is created, and terminate sockets near the client.
Last updated:
A shard lives in exactly one place. That is what makes its writes serialized and its reads consistent — and it is also why a reader on the other side of the world pays the round trip. This page covers the three controls that move work closer to the reader, and what each one costs.
None of them are on by default. A single-region app should leave them alone.
Where a shard is created
By default Cloudflare creates a Durable Object in the data centre nearest the request that first touched it. That is usually right — the first request is normally a user's — but not always: a shard first materialized by a cron fire, a migration fan-out, a seeding run, or the Studio lands wherever that ran, and stays there for its whole life.
shardRegion decides for those cases:
export default createWorker({
shardDO: env.SHARD,
// A tenant whose region you already know, pinned regardless of who touches
// it first.
shardRegion: (shardKey) => tenantRegions[shardKey],
});Return undefined for a key you have no opinion about and the default applies.
The hint is honoured only by the resolution that creates the object, so
changing this later does not move a shard that already exists — moving one is an
export → import job.
This is placement, not residency. If you need a hard guarantee about where data
lives, use .jurisdiction(), which constrains
rather than hints.
A jurisdiction turns every region hint off. On a deployment pinned with .jurisdiction(...), shardRegion and the replica/relay hints are not sent —
the jurisdiction already constrains placement, and it is the constraint that must hold. The worker logs this once so an ignored shardRegion is never a
silent surprise.
Replica reads
replicaReads serves one-shot queries from a copy of the shard placed in
the caller's region:
export default createWorker({
shardDO: env.SHARD,
// The registry is what tells the worker which calls are queries. Codegen
// threads it for you; a hand-written worker must pass it, or nothing is
// eligible and every read stays on the owner.
functions: LUNORA_FUNCTIONS,
replicaReads: true,
});Four things all have to hold before a read is served from a replica, and each one falls back to the owner rather than failing:
replicaReads: true.functionsis passed, and the call is a registered query — a mutation, action or stream is never eligible.- Cloudflare could geolocate the request. A caller it cannot place has no region to be near, so there is no replica to prefer.
- The shard has a changelog (see below) and its replica is caught up.
Set the whole thing up and watch reads still go to the owner, and it is almost always (2) or (4).
A replica is a second Durable Object, named after the shard it follows, that replays the shard's CDC changelog into its own SQLite. It is a follower in the strict sense: it never accepts a write, never originates a row, and refuses any dispatch the runtime did not explicitly route to it as a read.
Requires CDC. The changelog is the replication feed. Without it a replica has nothing to follow, reports itself unavailable, and every read falls back to the owner — correct, but one wasted hop per read.
What stays on the owner
Everything except one-shot queries:
| Dispatch | Served by |
|---|---|
query | The region-local replica, when it can |
mutation / action / stream | The owner, always |
| Live subscriptions | The owner (or its relays) |
| Fan-out, admin, PITR, export | The owner |
Consistency
Read-your-writes is preserved per client, automatically. The server echoes
the cursor each write committed at; @lunora/client remembers it per shard and
sends it back on later calls, and a replica that has not caught up to it sends
the read to the owner instead of answering from an older copy.
What you are trading away is freshness for other people's writes: a read that
carries no cursor may be up to LUNORA_REPLICA_MAX_STALENESS_MS (default
1000) behind. If a query must never be behind anyone's write, do not enable
this — or keep that query on a live subscription, which is owner-served and
pushed.
A replica reports itself unusable, and reads fall back to the owner, when it cannot reconcile with its owner at all:
- the owner's changelog timeline forked (a reset or a point-in-time rollback), so replay would fabricate a state neither side held;
- the changelog was compacted past the replica's position, so the gap cannot be replayed;
- the shard is too large to snapshot in one response, so bootstrapping would leave the replica with an arbitrary prefix of it.
All three are dead ends, and a Durable Object is never destroyed — so in practice a replica that diverges stays diverged, and every read in that region pays a wasted hop to it before falling back for as long as the deployment lives. Reads stay correct throughout; they just stop being local, quietly.
Cost
Each replica is another Durable Object holding a full copy of the shard: storage per region, plus the changelog traffic to keep it current. Replicas are created on demand, one per region that actually reads, so the bill follows your traffic rather than your region list.
Three costs worth knowing before you turn it on:
- Below roughly one read per second per region, a replica is slower than the owner. Outside the staleness window the first read pays a catch-up round trip to the owner — the same cross-world hop it exists to avoid — and then answers locally. The tier pays for itself under steady read traffic, not under a trickle.
- Query telemetry follows the DO that served it. Request logs, function metrics and issue grouping are written into the serving shard's own storage, so replica-served reads land in per-region replicas that the Studio's pages (which read the owner) do not show.
::replica::is a reserved infix in a shard key, exactly like::relay::. A key containing it addresses a DO that will refuse every dispatch, whether or notreplicaReadsis on.
Sockets near the client
Live subscriptions are owner-served, so a client's WebSocket normally terminates wherever the shard lives. Once a shard is hot enough to promote to the relay tier, that changes: connections spread across relay DOs, and each relay is created in the region of the client that first reached it. A single-region app therefore ends up with every relay in its own region; a multi-region one spreads them across the regions actually generating load. The socket terminates near the client, and only the owner→relay hop crosses the world — once per relay rather than once per connection.
The spread itself stays random on purpose. Routing a whole region to one relay would give tighter locality and pile that region's entire connection load onto a single DO — re-creating the fan-out wall promotion exists to escape.
There is nothing to configure; it applies automatically above the promotion threshold.
One socket per shard. A client subscribing across several shards still opens one socket per shard. Collapsing those onto a single connection is not implemented.