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:

  1. replicaReads: true.
  2. functions is passed, and the call is a registered query — a mutation, action or stream is never eligible.
  3. Cloudflare could geolocate the request. A caller it cannot place has no region to be near, so there is no replica to prefer.
  4. 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:

DispatchServed by
queryThe region-local replica, when it can
mutation / action / streamThe owner, always
Live subscriptionsThe owner (or its relays)
Fan-out, admin, PITR, exportThe 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 not replicaReads is 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.

See also