Multi-region

One app, every region.
Local reads and writes.

Serve Europe and North America from one Redelay app. Each member's data lives in their home region, so their requests never wait for an ocean — and the framework gives you the pieces, opt-in, without changing a single-region app.

Two regions · two servers each · tie-breaker · live
EuropeNorth Americamembersmembers0.8 ms0.8 ms99–122 msglobal data · async copytie-breakerthird site · votes only, never on the write patheu-1primaryeu-2partnerna-1primaryna-2partnerwrite ✓ ≈ 2 mswrite ✓ ≈ 2 ms

One rule: a write waits for its majority partner

MongoDB, Kafka, ClickHouse Keeper and Redis failover all commit once a majority has the write. A write is as fast as the nearest member of that majority — so keep the majority inside the region that writes, and never let it wait across an ocean.

NA member, primary in Europe

app (NA)primarypartner122 ms0.8 ms≈ 123 ms per write

NA member, home-region shard

app (NA)NA primarypartner~1 ms0.8 ms≈ 2 ms per write

Built on measured round trips

Three numbers decide everything on this page, and all three are measured on a real deployment over its private network (October 2026). The architecture keeps each write's majority inside the first one.

Two servers, one metro area
0.8 ms

measured between two North-American servers; the same holds for two servers in, say, Germany

Europe ↔ North America
99–122 ms

measured between four European and two North-American servers

Tie-breaker at a third site
25–30 ms

measured; it only votes in an election and is never on the write path

One request by a North-American member

Two reads of their own data and three majority writes. The model counts network round trips only (optimistic: no server time, TLS or queueing). Where we have a benchmark of the whole request, it is shown next to the model.

One replica set, primary in Europe

≈ 612 ms

Every read and write crosses the ocean; each write also waits for the primary's partner next door.

reads 2 × 122 mswrites 3 × (122 + 0.8) ms

+ local reads (Nearest + recent-write marker)

≈ 370 ms

Reads come from the North-American secondary; the marker sends a member back to the primary only right after their own write. Writes still cross the ocean.

reads 2 × ~1 mswrites 3 × (122 + 0.8) ms

Home-region shards

≈ 7 msmeasured ~13 ms

The member's rows live on a shard whose primary and majority partner are both in North America.

reads 2 × ~1 mswrites 3 × (~1 + 0.8) ms

A European member's request costs the same ~7 ms in both layouts. With home regions, North America gets the same — instead of ~612 ms. The ~13 ms measurement is the whole request through the shard router on our two-region benchmark.

What each datastore does

The same rule, applied store by store. Every piece is opt-in: an app without HOME_REGIONS behaves exactly as before.

MongoDB — zone shards

Member-owned collections are sharded on {home_region, user_id}; each zone's shard has its primary and write majority in its region. Global data (catalogue, CMS, settings) stays on the primary shard and is read from local secondaries.

rtmongo.Owner · StampOwner · OwnFilter · ShardByRegion

Read your own writes

A recent-write marker on the shared Redis Cluster is set on every master and checked on this node's master — no cross-region hop. A member who just wrote reads the primary for a few seconds; everyone else reads locally.

rtmongo.Nearest · recentwrite.InstallFromEnv

Kafka — replicas in one region

acks=all waits for every in-sync replica, so a replica across the ocean costs ~1 s per publish. Topics are placed on one region's brokers by rack; publishing goes through the transactional outbox, off the request path.

KAFKA_TOPIC_RACK · go-events/outbox

Vector search — regional copies

Versioned index snapshots are copied to each region and read locally; a copy that is behind falls back to the writer. Measured: 120 ms from NA to the EU writer, 2.1 ms to the local copy.

QDRANT_READ_URL · QDRANT_REPLICA_URLS · PublishVersion

ClickHouse & cache — per node

Analytics and cache are node-local ({{.Node.Hostname}}), with batched inserts and the nearest Keeper. Nothing on the request path waits for another region.

clickhouse-{{.Node.Hostname}} · redis-{{.Node.Hostname}}

Placement & rollouts

Nodes carry region=eu|na labels; regional services run one task per node, stop-first, with a 30 s pre-drain so the load balancer finds the replacement before the old task stops. Zero 502s across a rolling deploy.

node.labels.region · SHUTDOWN_PREDRAIN_SECONDS

When the ocean splits

Each zone's shard has its majority inside its own region, so a cut across the Atlantic leaves both regions writing their own members' data. Identity (accounts, sign-in) and other global writes stay with the main region's primary and wait for the link. A tie-breaker vote at a third site keeps a region's majority when one of its nodes is down — and never slows a normal write, because a majority of three is the primary plus the fastest acknowledgement.

  • Members keep reading and writing their own data in their home region
  • Global data is served from local secondaries
  • Events go through the outbox to the region's own brokers
  • New sign-ups and global edits wait for the link
EU zoneNA zonemajority ✓ writes ✓majority ✓ writes ✓

Turning it on

Name the regions and the countries that belong to them. A member gets a home region once, at sign-up, from their country; their access token carries it, so every request knows where their data lives without a lookup. Stores opt in one by one.

Any number of regions — the framework is tested with three (eu, na, apac) on a real sharded cluster, and every helper is a no-op until you set HOME_REGIONS.

env + store.go
# every node
HOME_REGIONS="na=US,CA"
HOME_REGION_DEFAULT=eu

// a member-owned store: new rows carry the
// owner's region (the shard key)
favs := crud.NewMongoCRUD[Fav](db.Collection("favs")).
  WithOwnerRegion(func(f Fav) string { return f.UserID })

// reads: local, fresh after the member's own write
ctx = rtmongo.Nearest(ctx)

// operator step, once per collection
rtmongo.CheckShardable(ctx, favs.Collection(), "user_id")
rtmongo.ShardByRegion(ctx, favs.Collection(), "user_id", "eu", "na")

Why home regions

A member's own data is written by that member, almost always from one place. Putting it in their region gives local writes without the conflicts of multi-master — and keeps one app, one identity and one catalogue.

ApproachWritesReadsOcean splitIn short
One primary, replicas everywhereocean round trip on every writelocal, but stale after your own writefar region read-onlysimple, slow for half your users
Multi-master databaselocallocalboth write — conflicts to resolve laterfast, but last-writer-wins on member data
A separate app per regionlocallocalindependentno global accounts, catalogue or analytics
Redelay home regionslocal (member's own shard)local, fresh after your own writeeach region keeps writing its members' dataone app, one identity, no conflicts