← Back to the library
System designHow it works · Deep dive · 18 min

Replication, sharding and consistency

When data lives on more than one machine, who accepts writes, where does each key go, and what can a reader actually observe?

Replication copies the same data to several nodes for availability and read scale; sharding splits data so each node owns a part for write scale and size. Every choice about how copies stay in sync is a choice about which stale or reordered reads your users can see.

THE MENTAL MODEL

Think of each replica as applying an ordered log of writes, and of consistency as a promise about how far behind that log a reader may be and in what order they see it. With a single leader the log has one author and followers trail it; asynchronous followers make writes fast but let a reader see the past, synchronous ones make the leader wait. Multi-leader and leaderless designs accept writes in several places, so the logs diverge and must be merged, by last-write-wins, version vectors or CRDTs. Quorums (R + W > N) make every read overlap the latest acknowledged write, but overlap alone is not linearizability. Consensus protocols such as Raft give a replicated log that a majority agrees on, which is how CockroachDB and Spanner offer strong consistency per range at the cost of a round trip to a majority on every write. Sharding is a separate axis: a partition key, hashed or ranged, decides which replica group owns a record, and a bad key concentrates load on one shard however many you have. CAP only says that during a network partition a system must choose between linearizability and answering every request; PACELC adds that even without partitions, replication trades latency against consistency all the time.

HOW IT FITS TOGETHER

Async replication lag breaks read-your-writesNothing failed: the write committed and the replica is healthy, just a few milliseconds behind. Route the follow-up read by LSN or to the primary.
  1. User → App serverPATCH /profile name=Ada
  2. App server → PrimaryUPDATE users SET name = 'Ada'
  3. Primary → App serverCOMMIT ok at LSN 0/3000148Durable on the primary; streaming to replicas has only just started.
  4. App server → User200 OK, redirect to profile
  5. Primary → Async replicaWAL stream, still in flight
  6. User → App serverGET /profile
  7. App server → Async replicaSELECT name (reads go to replicas)
  8. Async replica → App servername = 'Bob' (replayed up to 0/3000100)A correct answer for the replica's position in the log, and a lost edit to the user.
  9. App server → UserProfile still shows Bob
  10. Primary → Async replicaWAL arrives; replica replays 0/3000148
  11. User → App serverGET /profile, cookie lsn=0/3000148Fix: remember the write's LSN for the session.
  12. App server → Async replicaIs pg_last_wal_replay_lsn() >= lsn?
  13. Async replica → App serverYes: serve the read, else use primary
Replication topologiesPick by write location and failure behaviour first; the consistency you get follows from that.
Writes accepted byConflictsOn leader or node lossReader can seeExamples
Single leader, asyncOne primaryNone; one write orderPromote a follower; unreplicated commits can be lostStale reads on followers; monotonic reads break across themPostgres and MySQL defaults, MongoDB secondaries
Single leader, syncOne primary that waits for sync standbysNoneNo acked commit lost; writes stall if standbys are downremote_apply makes standby reads currentPostgres synchronous_standby_names, semi-sync MySQL
Multi-leaderA leader per region or deviceConcurrent writes conflict; need LWW, merge or CRDTsOther leaders keep accepting writesDivergent values until mergedDynamoDB global tables (MREC), offline-first sync
Leaderless quorumAny replica, coordinated per requestResolved by timestamp (LWW) and read repairKeeps serving while W and R replicas respondLatest acked write if R + W > N, not linearizableCassandra, ScyllaDB, Riak
Consensus (Raft, Paxos)Leader of each group, committed by a majorityNone; the majority-agreed log is the orderNew leader elected; minority side stops servingLinearizable reads via leader or leaseholderetcd, Consul, CockroachDB ranges, Spanner
Sharding strategiesThe key decides the shard; the strategy decides what happens to range queries, hot spots and resharding.
PlacementRange queriesHot spotsAdding shardsUsed by
RangeContiguous key ranges per shardEfficient within a shardSequential keys (time, ids) hit the last rangeSplit busy or large ranges and move themCockroachDB, Spanner, HBase, MongoDB ranged
Hash modulo Nhash(key) mod NScatter to all shardsEven unless one key is hotChanging N moves almost every keyPostgres hash partitions (single node)
Consistent hashinghash(key) on a ring of node tokens or vnodesScatter to all shardsEven with vnodes; hot keys stay hotMoves about 1/N of keys to the new nodeCassandra, ScyllaDB, Dynamo-style stores
Fixed hash slotshash(key) to many slots; slots assigned to nodesScatter, unless keys share a hash tagEven unless one key is hotMove whole slots between nodesRedis Cluster (16,384 slots), Citus (fixed shard count)
Directory / tenantLookup table maps tenant or entity to shardEfficient within a tenantOne huge tenant overloads its shardMove tenants individuallySaaS cell architectures, per-tenant databases

KEY TERMS

Session guarantees
Read-your-writes: after you write, your reads reflect it. Monotonic reads: once you have seen a value you never see an older one, which breaks when consecutive reads hit replicas at different lag. Both are per-client promises and can be provided by routing (sticky replica, LSN checks, primary after write) without making the whole system strongly consistent.
Quorums: R + W > N
With N replicas, a write acknowledged by W and a read from R overlap in at least one replica when R + W > N, so the read can see the latest acknowledged write. It still is not linearizable: concurrent writes resolved by timestamps, sloppy quorums with hinted handoff and failed partial writes can all yield anomalies. Cassandra applies this per query with QUORUM or LOCAL_QUORUM.
Consistency models
Linearizable: every operation appears to take effect atomically at one instant between its start and end, respecting real time. Sequential: one total order all processes agree on, without the real-time constraint. Causal: causally related operations appear in the same order everywhere, concurrent ones may not. Eventual: replicas converge if writes stop, with no promise about what you read meanwhile.
CAP and PACELC, precisely
CAP (Gilbert and Lynch) says that when the network partitions, a system cannot be both linearizable and available, where available means every request to a non-failing node gets a response. It says nothing about normal operation. PACELC adds the everyday trade-off: Else, with no partition, choose between Latency and Consistency, because waiting for replicas costs round trips.
Hot partitions and resharding
A shard key with low cardinality or skewed traffic (one celebrity, today's date) caps throughput at one shard's limit; DynamoDB, for example, limits each partition to 3,000 read units and 1,000 write units per second. Fixes include high-cardinality keys, write sharding with a suffix, and splitting ranges. Resharding moves data while serving traffic, so plan it as a migration with double reads or a copy-then-cutover.
2PC vs sagas
Two-phase commit makes participants vote then commit atomically, but blocks while the coordinator is down and holds locks across services. A saga runs local transactions in sequence and undoes earlier steps with compensating actions on failure. Sagas stay available but give up isolation, so other readers can observe intermediate states.
Consensus (Raft)
Raft elects a leader per term, the leader appends entries to followers, and an entry is committed once a majority has stored it. A cluster of 2f + 1 nodes tolerates f failures: 3 nodes survive 1, 5 survive 2. Distributed SQL databases run one consensus group per range so they can shard and still offer serializable, linearizable access.

IN YOUR STACK

TypeScript · node-postgres, primary + replica Server

import { Pool } from "pg";

const primary = new Pool({ connectionString: process.env.PRIMARY_URL });
const replica = new Pool({ connectionString: process.env.REPLICA_URL });
type Session = { lastWriteLsn?: string }; // persisted in the session or a cookie

export async function write(session: Session, sql: string, params: unknown[]) {
  const client = await primary.connect();
  try {
    await client.query(sql, params);
    const { rows } = await client.query("SELECT pg_current_wal_lsn()::text AS lsn");
    session.lastWriteLsn = rows[0].lsn;
  } finally { client.release(); }
}

export async function read(session: Session, sql: string, params: unknown[]) {
  if (!session.lastWriteLsn) return replica.query(sql, params);
  const client = await replica.connect(); // check and read on the same replica connection
  try {
    const { rows } = await client.query(
      "SELECT pg_last_wal_replay_lsn() >= $1::pg_lsn AS caught_up", [session.lastWriteLsn]);
    return rows[0].caught_up ? await client.query(sql, params) : await primary.query(sql, params);
  } finally { client.release(); }
}
  • The LSN check is exact; the cruder alternative is pinning a session to the primary for a few seconds after any write.
  • If REPLICA_URL is a load-balanced endpoint, a new connection may land on a different replica, which is why the check and the read share one client.
  • This gives read-your-writes for one session only; other users can still see the old value until the replica catches up.
node-postgres, primary + replica documentation (opens in new tab)

TypeScript · Apache Cassandra driver Server

import cassandra from "cassandra-driver";

const { consistencies } = cassandra.types; // keyspace replication = {'class': 'NetworkTopologyStrategy', 'dc1': 3}
const client = new cassandra.Client({ contactPoints: ["10.0.0.1", "10.0.0.2"], localDataCenter: "dc1", keyspace: "shop" });
const quorum = { prepare: true, consistency: consistencies.localQuorum };

// LOCAL_QUORUM write (2 of 3) + LOCAL_QUORUM read (2 of 3): 2 + 2 > 3, so reads overlap the write.
export async function placeOrder(customerId: string, orderId: string, totalCents: number) {
  await client.execute(
    "INSERT INTO orders_by_customer (customer_id, created_at, order_id, total_cents) VALUES (?, ?, ?, ?)",
    [customerId, new Date(), orderId, totalCents], quorum);
}

export async function recentOrders(customerId: string) {
  const rs = await client.execute(
    "SELECT order_id, total_cents FROM orders_by_customer WHERE customer_id = ? LIMIT 20", [customerId], quorum);
  return rs.rows;
}

// Compare-and-set needs Paxos (a lightweight transaction), not just a quorum.
export async function claimUsername(name: string, userId: string) {
  const rs = await client.execute("INSERT INTO usernames (name, user_id) VALUES (?, ?) IF NOT EXISTS",
    [name, userId], { prepare: true, serialConsistency: consistencies.localSerial });
  return rs.wasApplied();
}
  • The driver's default consistency is LOCAL_ONE for Apache Cassandra, so without an explicit level a read can miss a write acknowledged a moment ago.
  • LOCAL_QUORUM keeps the quorum inside one datacenter; EACH_QUORUM or QUORUM across datacenters adds WAN latency to every request.
  • Quorums do not serialise concurrent writes to the same cell: the highest timestamp wins, so clock skew can drop an update.
Apache Cassandra driver documentation (opens in new tab)

TypeScript · DynamoDB (AWS SDK v3) Server

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, QueryCommand } from "@aws-sdk/lib-dynamodb";

const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));

// Right after a write the user is waiting on: strongly consistent, twice the read cost.
export async function getAccount(accountId: string) {
  const { Item } = await db.send(new GetCommand({
    TableName: "accounts",
    Key: { PK: `ACCOUNT#${accountId}`, SK: "PROFILE" },
    ConsistentRead: true,
  }));
  return Item;
}

// Feeds and lists: the default eventually consistent read is usually fine.
export async function listTransactions(accountId: string) {
  const { Items } = await db.send(new QueryCommand({
    TableName: "accounts",
    KeyConditionExpression: "PK = :pk AND begins_with(SK, :tx)",
    ExpressionAttributeValues: { ":pk": `ACCOUNT#${accountId}`, ":tx": "TX#" },
    ScanIndexForward: false,
  }));
  return Items ?? [];
}
  • ConsistentRead works on GetItem, Query and Scan against tables and local secondary indexes; global secondary indexes and streams are always eventually consistent.
  • Eventually consistent reads cost half as much as strongly consistent ones.
  • Global tables default to multi-Region eventual consistency (MREC); multi-Region strong consistency (MRSC) replicates synchronously before the write returns.
DynamoDB (AWS SDK v3) documentation (opens in new tab)

TypeScript · node:crypto Server

import { createHash } from "node:crypto";

const hash = (s: string) => parseInt(createHash("md5").update(s).digest("hex").slice(0, 8), 16);

export class HashRing {
  private ring: { point: number; node: string }[] = [];
  constructor(nodes: string[], private vnodes = 128) {
    for (const node of nodes) this.add(node);
  }

  add(node: string) {
    for (let i = 0; i < this.vnodes; i++) this.ring.push({ point: hash(`${node}#${i}`), node });
    this.ring.sort((a, b) => a.point - b.point);
  }

  remove(node: string) { this.ring = this.ring.filter((v) => v.node !== node); }

  nodeFor(key: string): string {
    const h = hash(key);
    let lo = 0, hi = this.ring.length;
    while (lo < hi) { const mid = (lo + hi) >> 1; if (this.ring[mid].point < h) lo = mid + 1; else hi = mid; }
    return this.ring[lo % this.ring.length].node; // first point clockwise, wrapping to the start
  }
}
  • Adding a fourth node to three moves only the keys that now fall on its points, about a quarter, instead of nearly all keys with hash mod N.
  • Virtual nodes give each server many small arcs, smoothing load and letting a joining node take a little from everyone.
  • Real systems also replicate to the next distinct nodes clockwise and store the ring as cluster metadata, as Cassandra does with its token map.
node:crypto documentation (opens in new tab)

SQL · PostgreSQL Server

-- On the primary: one row per directly connected standby.
SELECT application_name,
       client_addr,
       state,                 -- startup, catchup, streaming, backup, stopping
       sync_state,            -- async, potential, sync, quorum
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag_bytes,
       write_lag,
       flush_lag,
       replay_lag
FROM pg_stat_replication;

-- On a standby: is it a standby, and how old is the last replayed commit?
SELECT pg_is_in_recovery() AS is_standby,
       pg_last_wal_replay_lsn() AS replayed_to,
       now() - pg_last_xact_replay_timestamp() AS since_last_replayed_commit;
  • Alert on replay_lag_bytes or replay_lag; for async standbys replay_lag approximates how stale replica reads are.
  • since_last_replayed_commit keeps growing on an idle primary even when the standby is fully caught up, so pair it with the byte lag.
  • A missing row means the standby is disconnected, which is worse than any lag value; alert on the expected count too.
PostgreSQL documentation (opens in new tab)

SQL · PostgreSQL Server

CREATE TABLE orders (
    customer_id bigint      NOT NULL,
    order_id    bigint      NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now(),
    total_cents bigint      NOT NULL,
    PRIMARY KEY (customer_id, order_id)   -- must include the partition key
) PARTITION BY HASH (customer_id);

CREATE TABLE orders_p0 PARTITION OF orders FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE orders_p1 PARTITION OF orders FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE orders_p2 PARTITION OF orders FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE orders_p3 PARTITION OF orders FOR VALUES WITH (MODULUS 4, REMAINDER 3);

-- Created on every partition.
CREATE INDEX ON orders (customer_id, created_at DESC);

-- Pruned to a single partition because the key is in the filter.
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 20;

-- No partition key in the filter: every partition is scanned.
EXPLAIN SELECT * FROM orders WHERE order_id = 9001;
  • This partitions within one server; it bounds index and vacuum sizes but does not add write capacity. Spreading partitions over nodes needs Citus or a distributed database.
  • Unique and primary key constraints must include every partition key column, because each partition can only enforce uniqueness locally.
  • Changing MODULUS later means moving rows; choose a partition count with headroom up front.
PostgreSQL documentation (opens in new tab)

WHEN TO REACH FOR WHAT

Single leader, async replicas

Use when The default for most OLTP systems: simple, fast writes, read scaling, and failover that tolerates losing the last moments of commits.

Avoid when Losing any acknowledged write on failover is unacceptable, or reads after writes must always be current without routing logic.

Single leader, synchronous

Use when Zero data loss on failover matters more than write latency, with at least two standbys so one outage does not stall writes.

Avoid when Standbys are far away or flaky; every commit pays their round trip and stalls when the sync set is unavailable.

Multi-leader

Use when Users in several regions or offline devices must write locally and the data merges naturally (CRDTs, append-only, per-user ownership).

Avoid when Invariants span records, such as balances, inventory or uniqueness, where conflicting concurrent writes cannot be merged.

Leaderless quorum (Cassandra)

Use when High write availability across datacenters, with data modelled per partition key and per-query consistency choices.

Avoid when You need multi-row transactions, read-modify-write correctness without lightweight transactions, or linearizable reads.

Consensus-based distributed SQL

Use when You need SQL, serializable transactions and automatic sharding across nodes or regions (CockroachDB, Spanner, YugabyteDB).

Avoid when One Postgres primary still fits; every write now pays a majority round trip and the operational model is new.

Range sharding

Use when Queries scan contiguous keys (time windows, alphabetical ranges) and the system can split hot ranges automatically.

Avoid when Keys are monotonically increasing and the newest range takes every write.

Hash or consistent-hash sharding

Use when Access is by exact key and load must spread evenly regardless of key order.

Avoid when Range scans across keys are common; each one becomes a scatter-gather across every shard.

Saga instead of 2PC

Use when A business process spans services that own their own databases and every step has a meaningful compensation.

Avoid when Intermediate states must never be visible, or steps cannot be undone; keep that data in one database transaction.

WHERE IT BITES

  • Sending every read to replicas, then debugging 'lost' edits that are really read-your-writes violations from replication lag.
  • Treating QUORUM reads and writes as linearizable; timestamp-based conflict resolution and partial failures still produce anomalies.
  • Quoting CAP as 'pick two of three' and choosing a database by its CAP label instead of by its behaviour during partitions and its latency without them.
  • Choosing a shard key by what is unique (an auto-increment id, a timestamp) rather than by what queries filter on and how load spreads.
  • Dual-writing to two services or stores and calling it a transaction; use one database transaction, an outbox, or a saga with compensations.
  • Failing over an async primary automatically without fencing the old one, so two primaries accept writes and the data splits.

CLOSE THE AI. EXPLAIN THIS.

Your Postgres primary has two async replicas, and users report that edits 'sometimes disappear' and then come back. Explain the anomaly by name, show how you would measure it with pg_stat_replication, and give two fixes with their costs.

SOURCES

Explainer reviewed