Skip to main content
System design, step by step · Lesson 3 of 7 · 14 min

How to Scale a Database: Indexes, Replicas and Sharding

How to scale a database step by step: model data around queries, add the right index, use read replicas despite lag, then shard by key with IDs that scale.

By Matthews WongReviewed on

Key takeaway

Scaling a database means applying the cheapest fix that removes the measured bottleneck: model the data around its queries, add the right index, add read replicas and route around their lag, and only when one server cannot take the writes, shard the rows across servers by a shard key, with IDs that no single counter has to issue.

You will be able to

  • Order the fixes from cheapest to most expensive, and say which bottleneck each one removes.
  • Route reads so users always see their own writes despite replica lag.
  • Tell partitioning from sharding, choose a shard key, and pick IDs that need no shared counter.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

From one primary to shards: the cheapest fix first

An index makes the primary's query cheap, a read replica takes reads but lags, sending a user's reads to the primary after a write hides that lag, a shard router splits writes by tenant, and UUIDv7 keys need no shared counter.

Step 1 of 7. One Postgres primary takes every read and write. Fixes come cheapest first: an index inside it, then the dashed boxes, a read replica, a router and a second shard.

Keys: ← → step · Home / End · K play or pause

  1. One Postgres primary takes every read and write. Fixes come cheapest first: an index inside it, then the dashed boxes, a read replica, a router and a second shard.

  2. A list screen filters orders by tenant, newest first. Without an index Postgres reads every page; a B-tree on (tenant_id, created_at) walks straight to the matching rows.

    CREATE INDEX ON orders (tenant_id, created_at);
  3. Reads keep growing. A read replica replays the primary's write-ahead log and answers read-only queries; every write still goes to the one primary.

    Predict the next step Ana saves an order, then reloads from the replica at once. She sees? (Her new order · The old list, without it · An error from the replica)
  4. The write commits on the primary first. If replay on the replica lags 500 ms and her reload comes after 100 ms, the replica still shows the old list.

  5. For a few seconds after Ana writes, send her reads to the primary; everyone else keeps reading the replica. Read-your-writes, at the cost of a little primary load.

    SET pin:ana 1 EX 5
  6. When writes outgrow one primary, only more servers help. A router hashes the shard key, tenant_id, and sends each tenant to one shard. Partitioning would stay on one server.

    shard = hash(tenant_id) % 2Predict the next step Each shard keeps its own id sequence. What happens to order ids? (They stay unique everywhere · The same id exists on both)
  7. Two sequences would issue the same numbers. Use UUIDv7 instead: the app creates it with no shared counter, and its time prefix keeps new keys together in the index.

    id = uuidv7()

How do you choose the data model before scaling anything?

Start from the questions the system must answer, not from a SQL versus NoSQL label. A relational database lets you query flexibly and answer questions you have not thought of yet, at the cost of joins. A NoSQL store such as DynamoDB is designed around the most common queries, and the AWS guide warns that queries outside those planned ways can be expensive and slow.

Read more (1)

So list the access patterns with a number beside each: how often, how much data, how fast. If one Postgres server with the right indexes serves them all, stop there. Add a specialised store only for the single pattern it cannot serve, because every extra datastore is another thing to back up, patch and monitor. And store each fact once: every copy you keep needs something that keeps it in step.

Why is an index the first fix, and what does it cost?

An index is a second, sorted copy of some columns with pointers back to the rows. Without one, finding a tenant's newest orders means reading every page of the table; with a B-tree on the right columns, Postgres walks a short path and reads the matching rows already in order. A missing index looks exactly like a table that is too big, which is why it comes before any new server.

Read more (1)

Column order decides whether it helps. In a multicolumn B-tree, equality conditions on the leading columns, plus a range on the first column without one, limit how much of the index is scanned; conditions further right are only checked along the way. So put the columns you compare with = first and the sort or range column last. The cost is paid on writes: each insert adds an entry to every index, so keep only the ones a hot query needs.

What do read replicas buy, and why do they return stale data?

A replica receives the primary's write-ahead log and replays it. In Postgres, a hot standby runs read-only queries and its transactions can never write, so replicas add read capacity, never write capacity. The documentation is plain that there is a measurable delay between primary and standby: the standby is eventually consistent with the primary.

Read more (1)

That delay becomes a bug the moment a user writes and then reads. The commit lands on the primary, the next page reads a replica that has not replayed it yet, and the user's own record is missing. Read-your-writes is the guarantee that a process sees its own earlier writes, and the cheap way to keep it is to send a user's reads to the primary for a few seconds after they write. Watch replay_lag in pg_stat_replication: for an asynchronous standby it approximates how long recent transactions take to become visible there.

When do you partition, when do you shard, and how do IDs change?

Partitioning splits one large logical table into smaller physical pieces inside one database server. The engine routes each row, queries that filter on the partition key skip the pieces that cannot match, and dropping an old partition is far faster than a bulk DELETE. Postgres's rule of thumb is that it pays off once the table is larger than the server's physical memory. It adds no hardware: it is still one machine.

Read more (2)

Sharding spreads the rows across several servers, so each shard brings its own CPU, memory and disk. It is the only step that adds write capacity, and it moves routing into your system: a shard router computes the owner from a shard key. Pick the key your most common query carries, such as tenant_id in a multi-tenant app, so one request touches one shard. A report across all tenants must then ask every shard and merge the answers, a single huge tenant still lands on one shard, and with hash modulo N, changing N moves most keys.

IDs change too. A sequence is one counter in one place, and two shards with their own sequences will hand out the same numbers. UUIDv4 needs no counter but, per RFC 9562, has poor index locality: inserts land at random places in the B-tree. UUIDv7 starts with a 48-bit millisecond timestamp, so any node can create one without coordination and new keys still sort together.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

scripts/generate-blog-sources.mjs:10-12
 * This derives them rather than asking an author to restate them, for one
 * reason: structured data must mirror what the reader sees. A hand-maintained
 * citation list is free to drift from the visible block; a derived one cannot.

This site has no database, but it keeps a derived copy: lib/blog-sources.json is generated from the sources block that ends every post, so the citations in the structured data cannot drift from what readers see. That is the contract of an index or a replica too: a copy stays trustworthy only while it is derived from one source of truth and never edited by hand.

package.json:12
"prebuild": "node scripts/generate-blog-sources.mjs --check && node scripts/game/check-placements.mjs && node scripts/game/check-imports.mjs && node scripts/check-message-keys.mjs",

And the build will not ship a stale copy. prebuild runs the generator with --check first, which exits non-zero when the committed JSON no longer matches the posts. A replica that falls behind serves stale reads; a derived file that falls behind fails the build here, the stricter of the two choices.

lib/blog-meta.ts:9685-9690
  // The date a reader sees comes from monthKey + year; the date Google sees
  // comes from datePublished here (JSON-LD, sitemap lastmod, both feeds).
  // Checking key sets alone let those drift: 168 of 386 posts had disagreed,
  // several by ten months, so a card read "May 2025" while its own structured
  // data said 2024-07-15. Nothing surfaced it because both values were
  // individually valid.

What storing one fact twice costs. Each post's date lived in two registries, and 168 of 386 posts had drifted apart while each copy looked valid on its own. assertBlogParity() now throws on a mismatch, which fails the build. In a database the same lesson shapes the model: keep a fact in one place, or pay for whatever keeps the copies in step.

What breaks

Mistakes that cost real time

  • Sharding before indexing

    A missing index looks exactly like a table that is too big. Shard it and every shard runs the same full scan, now with cross-shard queries and per-shard operations on top. Read the EXPLAIN output first.

  • Reading your own write from a replica

    The commit is on the primary, the next read hits a replica that has not replayed it, and the record seems lost. Send that user's reads to the primary for a few seconds after a write. Making every commit wait for a synchronous standby to apply it, synchronous_commit = remote_apply, also works, at the price of slower writes.

  • One tenant bigger than a shard

    Hashing spreads many keys evenly but never splits one. A tenant with ten times the rows of the rest makes its shard hot while the others idle. Design keys that spread traffic and avoid hot spots, and give an outsized tenant a shard of its own.

  • Random UUIDs as the primary key

    UUIDv4 values land at random places in the B-tree, so a write-heavy table touches pages all over the index instead of one end. RFC 9562 calls this poor index locality; UUIDv7 keeps the uniqueness without the scatter.

  • A unique key on a partitioned table

    Postgres requires a unique or primary key on a partitioned table to include every partition key column, because each partition's index can only enforce uniqueness within itself. A key on id alone is rejected, so you end up with (id, created_at), and id is no longer unique across partitions by constraint.

Partitioning or sharding: what each one changes
Where the pieces liveInside one database serverOn separate servers
Who routes a rowThe database engineYour shard router
Adds write capacity?No: still one machineYes: each shard has its own
Joins and transactionsNormal, on one nodeHard across shards
Main reason to use itScan size and cheap retentionWrites or data beyond one server
Try it yourself

Do it on your own machine

You need Docker and Node. Run Postgres 18 locally, watch an index change a query plan, see UUIDv7 values sort by time, and count what a modulo shard router moves.

  1. Start Postgres 18 and open psql.

    docker run --rm -d --name pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:18
    docker exec -it pg psql -U postgres
  2. Create a million orders across 500 tenants, then read the plan for one tenant's newest orders. Expect a Seq Scan and a Sort that touch thousands of buffers.

    CREATE TABLE orders (
      id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
      tenant_id int NOT NULL,
      created_at timestamptz NOT NULL,
      items int NOT NULL
    );
    INSERT INTO orders (tenant_id, created_at, items)
    SELECT g % 500, now() - g * interval '1 minute', g % 7 + 1
    FROM generate_series(1, 1000000) AS g;
    ANALYZE orders;
    EXPLAIN (ANALYZE, BUFFERS)
    SELECT * FROM orders WHERE tenant_id = 42
    ORDER BY created_at DESC LIMIT 20;
  3. Add the composite index and run the same EXPLAIN again. On my run the buffers fell from about 7,400 to 23, and the plan became an Index Scan Backward with no Sort.

    CREATE INDEX ON orders (tenant_id, created_at);
    EXPLAIN (ANALYZE, BUFFERS)
    SELECT * FROM orders WHERE tenant_id = 42
    ORDER BY created_at DESC LIMIT 20;
  4. Ask Postgres 18 for three UUIDv7 values. The first 12 hex digits are the creation time in milliseconds, so values made together start the same and sort in creation order.

    SELECT uuidv7() FROM generate_series(1, 3);
  5. Save router.mjs and run node router.mjs. Tenant 7 lands on shard 1 of 2, and going from 2 to 3 shards moves about two thirds of all tenants, which is why real routers use a lookup table or consistent hashing.

    // router.mjs: which shard owns a tenant, and what moves when you add a shard
    import { createHash } from "node:crypto";
    const shardOf = (tenantId, shards) =>
      createHash("sha256").update(String(tenantId)).digest().readUInt32BE(0) % shards;
    const tenants = 100_000;
    let moved = 0;
    for (let t = 1; t <= tenants; t += 1) {
      if (shardOf(t, 2) !== shardOf(t, 3)) moved += 1;
    }
    console.log(`tenant 7 lives on shard ${shardOf(7, 2)} of 2`);
    console.log(`going from 2 to 3 shards moves ${((moved / tenants) * 100).toFixed(1)}% of tenants`);
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.

  1. 1.A tenant's order list got slow at 2 million rows. CPU is fine and one query dominates. What do you try first?
  2. 2.Users save a record, and the next page says it does not exist; a refresh shows it. Reads go to a replica. What is the best fix?
  3. 3.Your multi-tenant app shards by tenant_id. One tenant now has ten times the orders of any other. What happens?
Think it through

Answer in your head, then check

  1. 1.What is the difference between partitioning and sharding?Show the answer

    Answer:Partitioning splits a table inside one server and the engine routes the rows. Sharding spreads the data across servers and your router does; only sharding adds write capacity.

  2. 2.Why do read replicas not help a write-heavy database?Show the answer

    Answer:A hot standby runs only read-only queries. Every write still goes to the one primary, which also has to ship it to each replica.

  3. 3.A query filters tenant_id = 7 and sorts by created_at. Why is an index on (tenant_id, created_at) a good fit?Show the answer

    Answer:The equality column leads, so the scan is limited to one tenant's entries, and those entries are already sorted by created_at, so Postgres reads them in order without a Sort.

Read the module

Every post in this module, in order

The lesson above is the map. Each post goes deep on one part of it, with the full worked examples.

  1. SQL vs NoSQL: How to Choose a Database for a New SystemHow to choose between SQL and NoSQL for a new system: the four NoSQL families, modelling from access patterns, and when Postgres JSONB is enough.
  2. Database Indexing Explained: How B-Trees Work in PostgresHow database indexes work and why B-trees dominate: page-fanout arithmetic, real Postgres EXPLAIN output, composite column order and the write cost of every index.
  3. Database Replication Explained: Leader-Follower, Sync vs AsyncHow database replication works: WAL shipping, sync versus async commit trade-offs, failover, split-brain, and when multi-leader or leaderless makes sense.Animated diagram
  4. Read Replicas Return Stale Data: Handling Replication LagWhy a read replica shows old rows right after a write, how to measure lag in Postgres, and how to route reads so users always see their own changes.
  5. Database Partitioning vs Sharding: What Is the Difference?Partitioning splits a table inside one database server, sharding spreads data across several servers. Postgres DDL, EXPLAIN output and a shard router.
  6. Distributed ID Generator: Snowflake vs UUIDv7 vs ULIDHow to generate unique IDs across many servers: auto-increment limits, UUIDv4 index damage, UUIDv7 under RFC 9562, Snowflake's 41/10/12 layout, clock rollback and block allocation.
  7. Bloom Filter Explained: Where It Fits in System DesignA Bloom filter is a bit array plus k hash functions that answers maybe or definitely not. See the m and k formulas, a measured error rate and real uses.