System Design NotesRohit’s interview study guide
Chapter 04

Data Storage

Choosing and shaping the data layer at the design level: access patterns, replication, sharding and hot keys, secondary indexes across shards, object storage, search indexes and the specialised stores.

9 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Choosing a database in an interview

Added

In a design interview the database question is rarely "SQL or NoSQL". It's "which questions must be cheap, how often are they asked, and how much data is there?" A candidate who names a product first ("we'll use Cassandra") and justifies it afterwards sounds like they're guessing. A candidate who lists the reads and writes, puts numbers on them and then picks the store that answers those questions cheaply sounds like an engineer, even if they pick the same product.

A useful mental model: every database is an index somebody already built for you. A B-tree in PostgreSQL makes "rows with this key, or this range of keys" cheap, and its transactions make "change these five rows together" safe. A hash-partitioned key-value store makes "get this key" cheap at almost any scale and makes everything else your problem. An inverted index makes "documents containing these words" cheap. Object storage makes "store and serve this 3 MB file" cheap. Choosing a database is choosing which questions get the precomputed answer, so you have to know the questions first.

So the order in the interview is always the same: access patterns, then numbers, then the store. Write the access patterns as short lines on the board before any boxes appear:

Access patternShapeHow often
Place an order (order + items + stock change)multi-row write, must be atomic14/s at peak
Show a customer's recent orderskey + range (customer, newest first)1,400/s at peak
Search products by text and filtersfull-text + facets500/s
Store and serve product photoslarge immutable blobs2,000 GETs/s, mostly from a CDN
Revenue per region per dayscan + aggregate over historya few times an hour

Each line points at a kind of store: atomic multi-row writes point at a relational database, text search at an inverted index, blobs at object storage, big aggregates at a column store or warehouse fed from the main database.

A short decision path

Once the access patterns are on the board, a few questions settle the choice. Ask them in this order, because the earlier ones are harder to change later:

AskIf the answer is…Lean towards
Must several records change together, correctly (money, stock, bookings)?yesa relational database (PostgreSQL, MySQL), or distributed SQL if it must span regions
Does it fit on one primary at peak, with headroom for 2–3 years?yesthat relational primary plus read replicas and a cache; stop here
no, because of writes or sizea natively partitioned store (Cassandra/ScyllaDB, DynamoDB, MongoDB sharded) or a sharded SQL layer (Vitess, Citus)
Are the reads by key or key range, known in advance?yeskey-value or wide-column, designed around those keys
ad hoc, many joins, changing questionsrelational
Is it text relevance, filters and facets?yesa search index next to the source of truth
Is it large files?yesobject storage, metadata in the database
Is it time-ordered measurements read as aggregates?yesa time-series store
Is it many-hop relationships (friends of friends, fraud rings)?yesa graph store, or precomputed results
Is it big scans and aggregates over history?yesa column store or warehouse fed by CDC

The Databases course has the deeper version of this guide, with real query timings; in the interview, the short version plus the numbers is what you need. The second question, "does it fit on one primary?", is the one candidates skip, and it's the one the numbers answer:

one-primary.mjs
// Does one relational primary (plus read replicas) carry this workload, or do the numbers force something else?
const DAY = 86_400;

// Rules of thumb for one well-tuned primary on a large instance (not measurements; tune for your hardware).
const ONE_PRIMARY = { writesPerSec: 5_000, dataTB: 5 };

// peak: busiest hour as a multiple of the daily average (3 for human traffic; sensors report at a steady rate).
const scenarios = [
  { name: "shop orders", dau: 2e6, writesPerUser: 0.2, readsPerUser: 20, rowBytes: 1_000, years: 5, peak: 3 },
  { name: "chat messages", dau: 50e6, writesPerUser: 40, readsPerUser: 200, rowBytes: 300, years: 5, peak: 3 },
  { name: "IoT metrics", devices: 1e6, samplesPerDevicePerDay: DAY / 10, readsPerDay: 2e6, rowBytes: 50, years: 1, peak: 1 },
];

const fmt = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : n.toFixed(0));

for (const s of scenarios) {
  const writesPerDay = s.dau ? s.dau * s.writesPerUser : s.devices * s.samplesPerDevicePerDay;
  const readsPerDay = s.dau ? s.dau * s.readsPerUser : s.readsPerDay;
  const peakWrites = (writesPerDay / DAY) * s.peak;
  const peakReads = (readsPerDay / DAY) * s.peak;
  const dataTB = (writesPerDay * 365 * s.years * s.rowBytes) / 1e12;

  const problems = [];
  if (peakWrites > ONE_PRIMARY.writesPerSec) problems.push("write rate");
  if (dataTB > ONE_PRIMARY.dataTB) problems.push("data size");
  const verdict = problems.length ? `outgrows one primary (${problems.join(", ")})` : "fits one primary + replicas";

  console.log(`${s.name}: peak ${fmt(peakWrites)} writes/s, ${fmt(peakReads)} reads/s, ${dataTB.toFixed(2)} TB after ${s.years} y`);
  console.log(`  -> ${verdict}`);
}
Result
shop orders: peak 14 writes/s, 1.4k reads/s, 0.73 TB after 5 y
  -> fits one primary + replicas
chat messages: peak 69.4k writes/s, 347.2k reads/s, 1095.00 TB after 5 y
  -> outgrows one primary (write rate, data size)
IoT metrics: peak 100.0k writes/s, 23 reads/s, 157.68 TB after 1 y
  -> outgrows one primary (write rate, data size)
What’s happening
  1. Each scenario turns users and behaviour into the three numbers a database cares about: peak writes per second, peak reads per second and total bytes. writesPerDay / 86,400 is the average rate; multiplying by peak (3 for human traffic, 1 for sensors that report on a fixed clock) gives the busiest hour.
  2. The shop: 2M daily users placing 0.2 orders each is 400,000 orders a day, about 4.6 per second on average and 14 per second at peak. Five years of 1 KB rows is 0.73 TB. One relational primary handles that with room to spare, and read replicas absorb the 1,400 reads/s. This is the honest answer for most business systems, and saying it out loud scores points: you aren't sharding a database that fits on one machine.
  3. Chat: 50M users sending 40 messages each is 2 billion messages a day, 69,400 writes/s at peak, and 1,095 TB over five years. That outgrows one primary on both counts by an order of magnitude, so the design needs a store that partitions writes across many machines (a wide-column store such as Cassandra or ScyllaDB, or a sharded relational setup), and the conversation moves to the partition key.
  4. IoT metrics: 1M devices reporting every 10 s is a steady 100,000 writes/s of tiny rows, almost never read individually, 158 TB a year. The shape (append-only, time-ordered, read as aggregates) points at a time-series store with compression and downsampling, not a general-purpose database. See Time-series, wide-column and graph stores.
  5. ONE_PRIMARY holds rules of thumb, not measurements: a well-tuned PostgreSQL or MySQL primary on a large instance comfortably takes thousands of simple writes per second and a few terabytes. Real limits depend on row size, indexes, transactions and hardware, so say "roughly" and move on; the verdict only matters when you're within a factor of two of the line, and then you'd benchmark.
  6. The point of the model: the store falls out of the numbers and the shapes. If you changed the chat scenario to 500k daily users, it would drop to about 700 writes/s, which one PostgreSQL primary handles easily. Its 11 TB over five years is still past the model's 5 TB line, so the plan would be to archive old messages or partition by conversation in a year or two, not to shard on day one.
AdvancedNaming a product is not a reason
AdvancedPolyglot persistence: one design, several stores

Real systems usually end up with several stores, each holding the data in the shape one access pattern needs. For an e-commerce site:

DataStoreWhy this one
Orders, payments, stock, customersPostgreSQL (source of truth)transactions, constraints, ad hoc queries
Carts, sessions, rate-limit countersRedissub-millisecond key access; losing some is tolerable
Product searchElasticsearch / OpenSearchinverted index, relevance, facets
Product photosobject storage + CDNcheap, durable, served at the edge
Analytics events and reportsa column store or warehousescans over billions of rows

The cost is that every extra store is a copy you must keep in sync and a system you must operate. Name the source of truth for each piece of data and how the copies are fed: change data capture or a transactional outbox, not "the service writes to both" (see Distributed transactions: two-phase commit, sagas and the outbox). And say how stale each copy may be: search results a second behind are fine; a stock count a second behind may oversell.

Postgres or Cassandra for chat history?

A chat product has 300,000 daily users who each send 30 messages a day. Messages are always read by conversation, newest first. The interviewer asks whether you'd use PostgreSQL or Cassandra. What do you say?

Show answer

PostgreSQL is fine at this size. The numbers decide it, not the word "chat".

What’s happening
  1. Writes: 300,000 × 30 = 9 million messages a day, about 104 per second on average and roughly 310 per second at a 3× peak. That's far below what one relational primary handles.
  2. Storage: at 300 bytes a message, 9M × 300 B × 365 is about 1 TB a year. Five years is 5 TB, near the edge of the rule of thumb, so you'd plan for partitioning by conversation or archiving old messages to cheaper storage, but not on day one.
  3. The access pattern, "messages in conversation X, newest first", is an index on (conversation_id, created_at); one B-tree range read per screen.
  4. Cassandra's strengths (spreading 70k writes/s over many nodes, multi-datacentre replication) solve a problem this product doesn't have yet, and its costs (no joins, queries limited to the key design, more operations work) are paid from day one.
  5. The answer that scores: "PostgreSQL now, with conversation_id as the future partition key, so if we grow 50× we can shard by conversation or move messages to a wide-column store without changing how the app reads them."

#Modelling data for access patterns

Added

With a relational database you usually model the facts (customers, orders, products, normalised so each fact lives once) and let the query planner work out how to answer questions with joins. At scale, and in any key-value or wide-column store, you model the questions instead: list the access patterns, then design keys, tables and copies so that each question is answered by one key lookup or one contiguous range read. The data is stored in the shape it's read.

The mental model: store the answer, not just the facts. A normalised schema is a filing cabinet with one copy of every document; to answer "the feed with author names and like counts" you pull a dozen folders and count pages. A denormalised model keeps a ready-made answer sheet. Reading is now one grab, but every copy is a promise: when the facts change, every answer sheet that contains them must be updated.

Whether that trade is worth it depends on the ratio of reads to writes and on how many copies a write must touch:

WorkloadTypical shapeModelling moves
Read-heavy (feeds, product pages, profiles: 100:1 or more)the same few queries, millions of timesdenormalise the fields every read needs, precompute counters, cache rendered results, build read-optimised views
Write-heavy (events, metrics, logs, chat messages)append-mostly, read in rangesappend-only rows keyed for the read path, few secondary indexes, batch writes, aggregate later
Mixed with strong invariants (orders, payments, bookings)small transactions over several recordskeep normalised, use transactions and constraints, denormalise only into derived read models

Here's the classic read-heavy case: a feed showing each post's author name and like count, modelled both ways.

denormalise.mjs
// Rows and index entries touched per day for a feed of posts showing "author name" and "like count", in two data models.
// Seeded PRNG (mulberry32) so the output is the same on every run.
const mulberry32 = (a) => () => {
  a = (a + 0x6d2b79f5) | 0;
  let t = Math.imul(a ^ (a >>> 15), 1 | a);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const rand = mulberry32(42);

const POSTS = 10_000, AUTHORS = 2_000, PAGE = 20;
const authorOf = Array.from({ length: POSTS }, () => Math.floor(rand() * AUTHORS));
// Likes are skewed: most posts get a few, a few posts get thousands (Pareto-like tail).
const likes = Array.from({ length: POSTS }, () => Math.floor(5 / Math.pow(rand(), 0.9)));
const postsByAuthor = new Map();
for (const a of authorOf) postsByAuthor.set(a, (postsByAuthor.get(a) ?? 0) + 1);

const day = { pageReads: 500_000, likeWrites: 50_000, authorRenames: 200 };

// Normalised: posts(id, author_id, body), authors(id, name), likes(post_id, user_id).
// A page reads 20 posts + 20 authors + COUNT(*) over each post's likes (one index entry per like).
// Measure it on 1,000 sample pages of 20 random posts.
let sampled = 0;
for (let p = 0; p < 1_000; p++) for (let i = 0; i < PAGE; i++) sampled += likes[Math.floor(rand() * POSTS)];
const avgLikesOnPage = sampled / (1_000 * PAGE);
const normalised = {
  pageRead: PAGE + PAGE + PAGE * avgLikesOnPage,
  likeWrite: 1,
  rename: 1,
};

// Denormalised: posts also store author_name and like_count; a like updates the counter too,
// and a rename rewrites every post by that author.
const avgPostsPerAuthor = POSTS / postsByAuthor.size;
const denormalised = { pageRead: PAGE, likeWrite: 2, rename: 1 + avgPostsPerAuthor };

for (const [name, m] of Object.entries({ normalised, denormalised })) {
  const total = day.pageReads * m.pageRead + day.likeWrites * m.likeWrite + day.authorRenames * m.rename;
  console.log(
    `${name.padEnd(12)} page read ${m.pageRead.toFixed(0).padStart(6)} entries | like ${m.likeWrite} | ` +
      `rename ${m.rename.toFixed(1)} | per day ${(total / 1e6).toFixed(1)}M`,
  );
}
console.log(`avg likes on a feed post ${avgLikesOnPage.toFixed(0)}, avg posts per author ${avgPostsPerAuthor.toFixed(1)}`);
Result
normalised   page read    552 entries | like 1 | rename 1.0 | per day 276.1M
denormalised page read     20 entries | like 2 | rename 6.0 | per day 10.1M
avg likes on a feed post 26, avg posts per author 5.0
What’s happening
  1. The seeded data has 10,000 posts by 2,000 authors. Like counts are skewed: 5 / rand()^0.9 gives most posts a handful of likes and a few posts thousands, as in real feeds. The median post has about 9 likes, the mean is 28.
  2. Normalised: to render a page of 20 posts, the database reads 20 post rows, 20 author rows (to get the names) and, for each post, COUNT(*) over its likes, which reads one index entry per like. Measured over 1,000 sample pages that averages 26 likes per post, so 552 entries per page read. A like costs 1 row, a rename costs 1 row.
  3. Denormalised: each post row also stores author_name and like_count. A page read is just the 20 post rows. A like now writes 2 rows (the like and the counter), and a rename rewrites the author plus every post they wrote: 1 + 5.0 = 6 rows.
  4. Over a day of 500,000 page reads, 50,000 likes and 200 renames, the normalised model touches 276.1M rows and entries; the denormalised one 10.1M, 27 times less. The extra write cost (50,000 extra counter updates and 1,000 extra post rewrites) is lost in the noise because reads outnumber writes 10 to 1 and each read touched hundreds of entries.
  5. The trade is not free. The counter row of a popular post becomes a point of contention (every like updates the same row), the copy of author_name can be briefly stale after a rename, and if the rename fan-out were 50,000 posts instead of 5 it would need to run as a background job. If likes outnumbered page views, the same arithmetic would favour the normalised model.
AdvancedDesigning keys so one read answers one question

In a key-value or wide-column store you don't have joins or a query planner, so the access pattern must be built into the key. The common pattern is a partition key that groups everything one query needs, plus a sort key that orders it the way it's read (DynamoDB's partition and sort key, Cassandra's partition key and clustering columns):

key-design.mjs
// Designing keys so one query answers an access pattern: a partition key + a sorted key (DynamoDB/Cassandra style).
// Access pattern: "orders of a customer in a date range, newest first".
const items = [];
const customers = ["C#1", "C#2", "C#3"];
let n = 0;
for (const c of customers)
  for (let day = 1; day <= 30; day++)
    for (let k = 0; k < 3; k++) items.push({ pk: c, sk: `ORDER#2026-10-${String(day).padStart(2, "0")}#${String(++n).padStart(4, "0")}` });
items.push({ pk: "C#2", sk: "PROFILE", name: "Maya", city: "Toronto" }); // the profile sits in the same partition

// The store keeps each partition's items sorted by sk (a B-tree or an SSTable), so a range is a seek plus a short scan.
const partitions = new Map();
for (const it of items) (partitions.get(it.pk) ?? partitions.set(it.pk, []).get(it.pk)).push(it);
for (const list of partitions.values()) list.sort((a, b) => (a.sk < b.sk ? -1 : a.sk > b.sk ? 1 : 0));

let seeks = 0, examined = 0;
function query(pk, from, to, { newestFirst = false, limit = Infinity } = {}) {
  const list = partitions.get(pk) ?? [];
  let lo = 0, hi = list.length; // binary search for the first sk >= from
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    seeks++;
    if (list[mid].sk < from) lo = mid + 1;
    else hi = mid;
  }
  const out = [];
  for (let i = lo; i < list.length && list[i].sk <= to; i++, examined++) out.push(list[i]);
  return (newestFirst ? out.reverse() : out).slice(0, limit);
}

const week = query("C#2", "ORDER#2026-10-08", "ORDER#2026-10-14~", { newestFirst: true, limit: 5 });
console.log(week.map((o) => o.sk).join("\n"));
console.log(`total items ${items.length}: ${seeks} binary-search steps, then ${examined} items scanned`);

examined = 0;
const scan = items.filter((it) => (examined++, it.pk === "C#2" && it.sk >= "ORDER#2026-10-08" && it.sk <= "ORDER#2026-10-14~"));
console.log(`same answer by scanning everything: ${scan.length} matches, examined ${examined}`);
console.log(query("C#2", "PROFILE", "PROFILE")[0]);
Result
ORDER#2026-10-14#0132
ORDER#2026-10-14#0131
ORDER#2026-10-14#0130
ORDER#2026-10-13#0129
ORDER#2026-10-13#0128
total items 271: 6 binary-search steps, then 21 items scanned
same answer by scanning everything: 21 matches, examined 271
{ pk: 'C#2', sk: 'PROFILE', name: 'Maya', city: 'Toronto' }
What’s happening
  1. Every item has a partition key pk (the customer) and a sort key sk. Order items use ORDER#<date>#<id>, so sorting the strings sorts the orders by date. The customer's profile lives in the same partition under sk = "PROFILE"; one partition holds everything about one customer.
  2. The store keeps each partition's items sorted by sk: on disk that's a B-tree or an SSTable. Finding the start of a range is a binary search (6 steps over the 91 items of C#2), and the range is read in order until sk passes the end: 21 items.
  3. The end bound ORDER#2026-10-14~ uses ~, which sorts after # and digits, so it means "anything that starts with ORDER#2026-10-14". This is how begins_with and BETWEEN conditions on sort keys work.
  4. newestFirst reverses the range (DynamoDB's ScanIndexForward: false, Cassandra's ORDER BY ... DESC on a clustering column) and limit: 5 stops early, so a "latest 5 orders this week" screen reads only what it shows.
  5. Without the key design the same question is a scan of all 271 items in the table, and at a billion items that's the difference between 3 ms and a timeout. The flip side: a question the keys weren't designed for ("all orders over $500 last week, any customer") has no cheap answer at all, and needs a secondary index (see Secondary indexes in a sharded system) or a copy of the data in another shape.
AdvancedPrecomputed views and how they stay fresh

A precomputed view is any stored answer derived from the source data: a like_count column, a materialised view, a per-user feed list, a daily revenue table, a search index. How you keep it fresh decides how stale it can be and what can go wrong:

Refresh methodStalenessRisk
Same transaction as the write (counter UPDATE beside the INSERT)nonecontention on hot rows; only works inside one database
Database trigger or materialised view refreshnone to minuteshidden write cost; full refreshes get slow
Change data capture or outbox feeding a consumer that updates the viewusually sub-secondconsumer lag; needs idempotent updates and a rebuild path
Periodic batch jobminutes to a daystale between runs; cheap and simple

The read model built from events is the CQRS idea (see Event-driven architecture, event sourcing and CQRS). Whatever the method, keep the source of truth clear and make every view rebuildable from it, because sooner or later a bug will corrupt one.

#Replication: leader-follower, multi-leader and leaderless

Added

Replication keeps copies of the same data on several machines. It buys three things: availability (if one machine dies, another has the data), read scale (reads spread over the copies) and latency (a copy near the user). It doesn't buy write scale: every copy must apply every write. Copying data that never changes is trivial; the whole subject is about how changes reach the copies, and what a reader sees while they're on their way.

The three designs differ in one question: who is allowed to accept a write? A useful mental model is a shared document in an office. Leader-follower: one editor makes all changes and photocopies go out to everyone else, who only read. Multi-leader: each office has its own editor, and the editors post their changes to each other, so two editors can change the same paragraph at once. Leaderless: anyone can write on any copy, and a reader checks several copies and trusts the newest.

Leader-follower writes Leader Follower Follower change log reads: leader or followers no write conflicts Multi-leader EU writes US writes Leader EU Leader US same row edited in both: conflict local writes in each region conflicts must be resolved Leaderless client (W=2, R=2) A B C write to all 3, ack after 2 C missed it (dashed) R + W > N: reads overlap repair fixes stale copies
Who accepts writes decides everything else: one leader means no conflicts, several leaders means conflicts, no leader means quorums and repair.
TopologyWho accepts writesConflictsTypical systemsChoose it when
Leader-follower (primary-replica)one leader per dataset or per shardnone: the leader orders all writesPostgreSQL, MySQL, MongoDB replica sets, Redis, Kafka partitionsalmost always; it's the default
Multi-leaderone leader per region or deviceyes: concurrent writes to the same dataMySQL/Postgres bidirectional setups, CouchDB, offline-first apps, collaborative editorswrites must be local in several regions, or devices write while offline
Leaderlessany replica (the client or a coordinator writes to several)yes, resolved by versions or last-write-winsCassandra, ScyllaDB, Riak, Amazon's original Dynamovery high write availability across failures matters more than simple consistency

The second decision is when a write counts as done:

ModeThe leader acknowledges after…On leader failureCost
Asynchronouswriting locallywrites not yet copied are lost if a follower is promotedlowest latency; the common default
Semi-synchronousat least one follower has received itno acknowledged write is lost (one copy survives), as long as it stays semi-synchronous: MySQL falls back to asynchronous if no follower acknowledges within a timeout (10 s by default)one extra network round trip per commit
Synchronous to a quoruma majority has it (Raft, Paxos, Aurora's storage)nothing acknowledged is lost; a new leader is elected safelyround trip to the majority; a minority partition can't write

Amazon Aurora is a good example of the last row: its storage keeps six copies across three Availability Zones, acknowledges a write once 4 of 6 have it and reads with 3 of 6, so it survives losing a whole zone for writes and a zone plus one more copy for reads (AWS Database Blog, "Amazon Aurora under the hood: quorums and correlated failure", and the SIGMOD papers of 2017 and 2018).

The everyday problem with asynchronous followers isn't failover; it's replication lag. A user saves something, the next page reads from a follower that hasn't applied it yet, and their change appears to have vanished. Here's how often that happens, and what two common fixes cost:

read-your-writes.mjs
// Asynchronous leader-follower replication: how often does a user fail to see their own write?
// Seeded PRNG (mulberry32); times are simulated milliseconds, not measured.
const mulberry32 = (a) => () => {
  a = (a + 0x6d2b79f5) | 0;
  let t = Math.imul(a ^ (a >>> 15), 1 | a);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const rand = mulberry32(7);

// Replication lag per write: usually 5-50 ms, but 2% of the time a follower is behind by 0.5-5 s
// (a big transaction, a vacuum, a backup, a network blip).
const lag = () => (rand() < 0.02 ? 500 + rand() * 4_500 : 5 + rand() * 45);

// One sample = a user saves, then reads back after a think time of 50 ms (page redirect) to 3 s,
// from the follower the load balancer picks. The same samples are replayed under each routing policy.
const samples = Array.from({ length: 100_000 }, () => ({ lag: lag(), readAt: 50 + rand() * 2_950 }));

const policies = {
  "any follower": () => "follower",
  "leader for 1 s after own write": (s) => (s.readAt < 1_000 ? "leader" : "follower"),
  // Compare the follower's applied log position with the position of the user's last write
  // (MySQL GTID, Postgres LSN, MongoDB operationTime); a session token carries the latter.
  "follower only if caught up": (s) => (s.lag <= s.readAt ? "follower" : "leader"),
};

for (const [name, route] of Object.entries(policies)) {
  let stale = 0, leaderReads = 0;
  for (const s of samples) {
    if (route(s) === "leader") leaderReads++;
    else if (s.lag > s.readAt) stale++; // the follower hasn't applied the write yet
  }
  const pct = (n) => `${((100 * n) / samples.length).toFixed(2)}%`;
  console.log(`${name.padEnd(31)} stale reads ${pct(stale).padStart(6)}   reads on leader ${pct(leaderReads).padStart(6)}`);
}
Result
any follower                    stale reads  1.51%   reads on leader  0.00%
leader for 1 s after own write  stale reads  0.89%   reads on leader 32.29%
follower only if caught up      stale reads  0.00%   reads on leader  1.51%
What’s happening
  1. Each sample is one user saving and then reading back their own data. Replication lag is usually 5–50 ms, but 2% of the time the follower is 0.5–5 s behind (a long transaction, a vacuum or backup, a network blip). The user's read comes 50 ms to 3 s after the write. All three policies replay the same 100,000 seeded samples, so the differences are only the policy.
  2. Any follower: a read is stale when the follower's lag is longer than the user's think time. That happens in 1.51% of reads-after-write: rare on average, but a user who saves and refreshes a dozen times a day will hit it every week, and it looks like data loss.
  3. Leader for 1 s after your own write: every read in the first second goes to the leader, which is 32% of these reads, and stale reads only drop to 0.89%, because the lag tail (up to 5 s) is longer than the 1 s window. A time window is a guess about lag; it fails exactly when lag is unusual.
  4. Follower only if caught up: the client remembers the log position of its last write (a MySQL GTID, a PostgreSQL LSN, MongoDB's operationTime in a causally consistent session) and a follower may serve the read only once it has applied that position; otherwise the read goes to the leader. That gives 0% stale reads and sends only the 1.51% that were behind to the leader.
  5. The general lesson: read-your-writes is a promise per user, not a property of the database. You get it by routing with knowledge of what this user wrote, which is cheaper and stronger than reading everything from the leader. If you changed the lag tail from 2% to 20% (a follower that's always struggling), the third policy would quietly shift about 15% of reads to the leader (the lagging fifth, minus the reads that come late enough to find the follower caught up), which is why replicas that fall behind should be taken out of rotation, not just tolerated.
AdvancedMulti-leader: when two writes collide

With more than one leader, two users can change the same record at the same time in different places, and both leaders accept it. When the changes meet, something has to decide the outcome. The simplest rule, last write wins (LWW) by timestamp, is also the most dangerous:

multi-leader-lww.mjs
// Two leaders (one per region) accept writes locally and exchange them asynchronously.
// Conflict rule: last write wins (LWW) by the writer's wall-clock timestamp.
class Leader {
  constructor(name, clockSkewMs) {
    this.name = name;
    this.skew = clockSkewMs; // how far this region's clock is off from true time
    this.data = new Map(); // key -> { value, ts, origin }
    this.outbox = [];
  }
  write(key, value, trueTime) {
    const rec = { value, ts: trueTime + this.skew, origin: this.name };
    this.apply(key, rec);
    this.outbox.push([key, rec]); // shipped to the other leader later
  }
  apply(key, rec) {
    const cur = this.data.get(key);
    if (!cur || rec.ts > cur.ts || (rec.ts === cur.ts && rec.origin > cur.origin)) this.data.set(key, rec);
    else console.log(`  ${this.name}: drops "${rec.value}" (ts ${rec.ts}) in favour of "${cur.value}" (ts ${cur.ts})`);
  }
  shipTo(other) {
    for (const [k, rec] of this.outbox.splice(0)) other.apply(k, rec);
  }
}

// The EU clock runs 300 ms fast; the US clock is accurate (NTP keeps clocks close, not equal).
const eu = new Leader("eu", +300);
const us = new Leader("us", 0);

// True time 1000: Ana in Europe renames the doc. True time 1200: Ben in the US renames it, after Ana.
eu.write("doc:42:title", "Q4 plan (Ana)", 1_000);
us.write("doc:42:title", "Q4 plan v2 (Ben)", 1_200);
console.log("before replication:", eu.data.get("doc:42:title").value, "|", us.data.get("doc:42:title").value);

eu.shipTo(us);
us.shipTo(eu);
console.log("after replication: ", eu.data.get("doc:42:title").value, "|", us.data.get("doc:42:title").value);
console.log("both regions agree, but Ben's later edit is gone and nobody was told");
Result
before replication: Q4 plan (Ana) | Q4 plan v2 (Ben)
  eu: drops "Q4 plan v2 (Ben)" (ts 1200) in favour of "Q4 plan (Ana)" (ts 1300)
after replication:  Q4 plan (Ana) | Q4 plan (Ana)
both regions agree, but Ben's later edit is gone and nobody was told
What’s happening
  1. Each region's leader applies writes locally and queues them in an outbox to ship later. That's the point of multi-leader: Ana in Europe and Ben in the US both get a fast local write, with no cross-ocean round trip.
  2. Ana renames the document at true time 1,000 ms; Ben renames it 200 ms later. Ben's edit is genuinely the later one. But the EU server's clock runs 300 ms fast, so Ana's write is stamped 1,300 and Ben's 1,200. Clocks synchronised by NTP are typically close, not equal; a few milliseconds is normal, hundreds happen after a bad sync or a VM pause.
  3. Before replication each region shows its own user's title. Then the outboxes are exchanged. The US leader receives Ana's write stamped 1,300, which is "newer" than Ben's 1,200, and overwrites Ben's title. The EU leader receives Ben's write and drops it as older.
  4. Both regions now agree on "Q4 plan (Ana)", so the system has converged, which is all LWW promises. But Ben's later edit has been silently discarded, and no error was raised anywhere.
  5. Better options, by need: avoid conflicts by routing all writes for one record to one "home" region; keep both versions and let the application or user merge them (Dynamo-style siblings, Git); or use data types that merge without loss, such as CRDT counters and sets, covered in Conflict resolution: last-write-wins and CRDTs. LWW is acceptable only for data where losing a concurrent update is harmless, like "last seen at".
AdvancedLeaderless replication in brief

In a leaderless store the client (or any node acting as coordinator) sends each write to all N replicas of a key and treats it as successful when W acknowledge; a read asks R replicas and takes the newest version. If R + W > N, every read set overlaps every write set in at least one replica, so a read normally sees the latest acknowledged write. Cassandra exposes this per query as consistency levels (ONE, QUORUM, ALL); with N = 3, QUORUM writes and reads give 2 + 2 > 3.

Replicas that missed writes are repaired three ways: read repair (a read that sees an old copy writes the new one back), hinted handoff (another node holds writes for a dead replica and delivers them when it returns) and anti-entropy (background comparison of replicas with Merkle trees, see Merkle trees). The quorum maths and its edge cases (sloppy quorums, concurrent writes) are simulated in Quorums: R + W > N.

#Partitioning and sharding strategies

Added

When the write rate or the data size outgrows one machine, replication stops helping, because every replica still applies every write. The answer is partitioning: split the data so each machine owns a subset of the keys and handles only their reads and writes. Ten machines that each own a tenth of the keys take roughly ten times the writes. The names vary by product (shard in MongoDB and Elasticsearch, partition in Cassandra, DynamoDB and Kafka, region in HBase, tablet in Bigtable), but the idea is the same, and each partition is usually also replicated for availability.

Everything depends on the function that maps a key to its partition, and there are three families. The mental model is a post office sorting mail into bins. Range partitioning sorts by postcode: bin 1 takes A0A–C9Z, bin 2 takes D0A–G9Z, and so on, so neighbouring postcodes share a bin and "all of one street" is one bin. Hash partitioning sorts by a scrambled version of the address, which spreads letters evenly but scatters a street over every bin. A directory is a clerk with a lookup table: "this sender's mail goes to bin 7", which is flexible but means every letter consults the clerk.

App servers stateless Router key → shard mongos · vtgate Config service shard map, versioned Shard 1 hash 0x0000–0x3FFF replica Shard 2 hash 0x4000–0x7FFF replica Shard 3 hash 0x8000–0xBFFF replica Shard 4 hash 0xC000–0xFFFF replica solid: query has the shard key → one shard dashed: no shard key → every shard (scatter-gather)
The router turns a key into a shard using a versioned map; queries that carry the shard key touch one shard, the rest touch them all.
StrategyKey → partitionGood atBad atUsed by
Rangesorted key ranges, e.g. [a, f), [f, m)range scans in key order; splitting busy rangeshot spots when keys arrive in order (time, auto-increment)HBase, Bigtable, MongoDB ranged sharding, CockroachDB, Spanner
Hashhash(key) mapped to ranges of hash space or to bucketsspreading load evenly, whatever the key orderrange scans across keys (neighbours are scattered)Cassandra, DynamoDB, MongoDB hashed sharding, Redis Cluster
Directory (lookup)a table: key or tenant → shardplacing big tenants deliberately, moving one tenantthe directory is a dependency on every request; must be cached and kept consistentmany SaaS platforms (tenant → database), Vitess lookup vindexes

The interesting question is how each behaves on real keys, which are never uniform. Here are two realistic workloads on 8 shards:

shard-skew.mjs
// Range vs hash sharding on realistic keys: how much of the load lands on the busiest of 8 shards?
// Seeded PRNG (mulberry32), so the output is the same on every run.
const mulberry32 = (a) => () => {
  a = (a + 0x6d2b79f5) | 0;
  let t = Math.imul(a ^ (a >>> 15), 1 | a);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const rand = mulberry32(2026);
const fnv1a = (s) => { let h = 0x811c9dc5; for (const c of s) h = Math.imul(h ^ c.charCodeAt(0), 0x01000193); return h >>> 0; };

const SHARDS = 8, OPS = 200_000;
const hashShard = (key) => fnv1a(String(key)) % SHARDS;
const rangeShard = (bounds) => (key) => { let i = 0; while (i < SHARDS - 1 && key >= bounds[i]) i++; return i; };

// Workload 1: events keyed by timestamp. The table holds 80 days; ranges were split evenly
// over those 80 days (10 days per shard). Today's writes all carry today's time.
const DAY = 86_400_000, start = Date.UTC(2026, 7, 1);
const timeBounds = Array.from({ length: SHARDS - 1 }, (_, i) => start + (i + 1) * 10 * DAY);
const eventKeys = Array.from({ length: OPS }, () => start + 79 * DAY + Math.floor(rand() * DAY));

// Workload 2: requests keyed by user id. 1M users with Zipf-like activity (a few users are very busy,
// most are quiet), unrelated to id order, plus one celebrity (id 777) who gets 5% of all traffic.
// Ranges split the id space evenly (125k ids per shard).
const USERS = 1_000_000;
const userBounds = Array.from({ length: SHARDS - 1 }, (_, i) => (i + 1) * (USERS / SHARDS));
const rank = () => Math.floor(Math.pow(USERS, rand())); // log-uniform rank: rank 1 is the busiest
const idOf = (r) => (r * 387_420_489) % USERS; // scatter ranks over ids (3^18 is coprime with 1M)
const userKeys = Array.from({ length: OPS }, () => (rand() < 0.05 ? 777 : idOf(rank())));

function report(label, keys, shardOf) {
  const load = new Array(SHARDS).fill(0);
  for (const k of keys) load[shardOf(k)]++;
  const pct = load.map((n) => Math.round((100 * n) / keys.length));
  const maxOverMean = Math.max(...load) / (keys.length / SHARDS);
  console.log(`${label.padEnd(20)} ${pct.map((p) => String(p).padStart(4)).join("")}   busiest ${maxOverMean.toFixed(2)}x mean`);
}

console.log(`${"".padEnd(20)} ${Array.from({ length: SHARDS }, (_, i) => `s${i}`.padStart(4)).join("")}   (% of operations)`);
report("timestamps, range", eventKeys, rangeShard(timeBounds));
report("timestamps, hash", eventKeys, hashShard);
report("user ids, range", userKeys, rangeShard(userBounds));
report("user ids, hash", userKeys, hashShard);
const counts = new Map();
for (const k of userKeys) counts.set(k, (counts.get(k) ?? 0) + 1);
const top = [...counts].sort((a, b) => b[1] - a[1]).slice(0, 4);
console.log("hottest user ids:", top.map(([k, n]) => `${k} ${((100 * n) / OPS).toFixed(1)}% -> s${hashShard(k)}`).join(", "));
Result
                       s0  s1  s2  s3  s4  s5  s6  s7   (% of operations)
timestamps, range       0   0   0   0   0   0   0 100   busiest 8.00x mean
timestamps, hash       12  12  12  13  13  13  13  13   busiest 1.01x mean
user ids, range        16  10  13  15  11  12  13  11   busiest 1.28x mean
user ids, hash         12  13  12  12  21  13   8  10   busiest 1.67x mean
hottest user ids: 777 5.0% -> s4, 420489 4.8% -> s4, 840978 2.8% -> s1, 261467 1.9% -> s3
What’s happening
  1. Timestamps, range: the table holds 80 days and the ranges were split evenly, 10 days per shard. That looks balanced on disk, but today's writes all carry today's timestamp, so 100% of writes go to shard 7 and the other seven sit idle. This is the classic range-partitioning failure, and it happens with any key that grows over time: timestamps, auto-increment IDs, and time-ordered IDs such as UUIDv7, ULIDs and Snowflake IDs.
  2. Timestamps, hash: hashing the same keys spreads writes almost perfectly, 12–13% per shard, busiest 1.01× the mean. The cost is that "events between 10:00 and 11:00" is now a query to every shard.
  3. User IDs: activity is Zipf-like (a few users are very busy, most are quiet) and unrelated to the ID's numeric order, plus a celebrity, user 777, with 5% of all traffic. Range partitioning over evenly split ID ranges gives a busiest shard at 1.28× the mean: the hottest users happen to land in different ranges.
  4. User IDs, hash is worse here: 1.67×, with shard 4 at 21%. The last line shows why: the two hottest keys, 777 (5.0%) and 420489 (4.8%), both hash to shard 4. Hashing evens out the number of keys per shard; it can't split the traffic of a single key, and where the few hot keys land is luck.
  5. The lesson to say out loud: hash partitioning fixes skew caused by key order; nothing in the partitioning function fixes a hot key. A hot key needs its own treatment (caching, splitting the key, isolating it), covered in Resharding and hot partitions.
AdvancedChoosing a shard key

The shard key is the hardest decision to change later, because changing it means moving every row. A good key satisfies four tests:

  • The main queries include it, so most requests go to one shard. For a SaaS product that's tenant_id; for chat, conversation_id; for a shop's order history, customer_id.
  • High cardinality: millions of distinct values, so data can spread over many shards and be rebalanced in small pieces. country (about 200 values) or status (5 values) can never spread beyond that many shards.
  • Even frequency: no single value carries a large share of traffic or data. One giant tenant on a tenant_id key needs special handling.
  • Not monotonic under range partitioning: a key that always increases sends all new writes to the last range. Hash it, or prefix it with something that spreads (a bucket number, the device ID).

Common choices and what they cost:

SystemShard keyWhyWhat it costs
Chat messages(conversation_id), rows sorted by message IDevery read is "this conversation, newest first"a huge group chat is a hot, large partition; add a time bucket (see Time-series, wide-column and graph stores)
Multi-tenant SaaStenant_idevery query is within one tenant; tenant data moves as a unitthe biggest tenants need dedicated shards (directory placement)
Orderscustomer_id"my orders" is one shard"all orders today" and per-merchant reports scatter; feed a warehouse
IoT readings(device_id, day)spreads writes, keeps one device's day togethercross-device queries scatter; partitions per day add up
URL shortenershort_code (hashed)every request is a lookup by codenone to speak of: it's the ideal key-value workload
Range or hash for a URL shortener?

A URL shortener stores short_code → long_url. Every redirect looks up one code; nothing ever reads a range of codes. Codes are generated from a counter and base62-encoded, so new codes are consecutive (abc12, abc13, …). Range or hash partitioning on short_code?

Show answer

Hash. Nothing needs ranges, and consecutive codes would make range partitioning hot.

What’s happening
  1. The only access pattern is a point lookup by code, so the one thing range partitioning offers, cheap scans of neighbouring keys, is never used.
  2. Codes come from a counter, so they're consecutive in sort order. Under range partitioning, every new code (and new codes get most of the traffic, because they've just been shared) lands in the last range: the timestamp problem from the model, with a different key.
  3. Hashing the code spreads both new and old codes evenly. The busiest shard is then decided only by hot individual links (a viral URL), which a cache in front absorbs.
  4. If the codes were random instead of counter-based, range partitioning would also spread well, but hash is still the safer default because it doesn't depend on how codes are generated.

#Resharding and hot partitions

Added

Partitioning is decided once; data and traffic keep changing. Two different problems show up, and they need different fixes. Growth: there's simply more data or traffic than the current shards can hold, so you need more shards and some data must move. Hot spots: one partition, often one key, gets far more traffic than the rest, and adding shards doesn't help at all, because the hot key still lives on exactly one of them.

Resharding hurts because it moves data while the system is serving traffic. A live move goes like this:

  1. Publish a new version of the shard map that says which partitions are moving where (the old map stays authoritative for now).
  2. Copy a snapshot of each moving partition to its new home.
  3. Stream the changes made during the copy from the source's log (binlog, WAL, oplog, CDC) until the copy is within seconds.
  4. Briefly block writes to the moving partition, apply the last changes, switch the routing to the new map, and unblock. This cut-over is measured in seconds or less.
  5. Verify, keep the old copy for a while for rollback, then delete it.

Every step must be repeatable and safe to abort, which is why teams use tools that automate it (Vitess for MySQL, MongoDB's balancer, the managed splitting inside DynamoDB, Bigtable and Spanner) rather than writing their own. Two designs keep the amount of moved data small. Many fixed logical partitions placed onto fewer machines: keys always hash to the same logical partition, and adding machines moves whole partitions. Notion sharded PostgreSQL into 480 logical shards on 32 machines in 2021 and moved to 96 machines with 5 logical shards each in 2023; they chose 480 because it divides evenly in many ways (Notion engineering blog, "Herding elephants" 2021 and "Building and scaling Notion's data lake" 2024). Consistent hashing reaches the same goal without a fixed partition count, and its movement is measured in Consistent hashing. Dynamic range splitting splits a range when it grows too big or too busy: MongoDB's default range (chunk) size is 128 MB (MongoDB 9.0 manual), and HBase and Bigtable split regions and tablets the same way.

None of those help a single hot key. The mental model for a hot key is one checkout lane with a celebrity in it: opening more lanes does nothing for that queue. You have to split the celebrity's work across lanes. For a write-hot counter, that means key salting: store the counter as N sub-keys, let each writer pick one, and sum them on read.

hot-key-split.mjs
// A celebrity's like counter takes 30% of 10,000 writes/s on a table hashed over 16 partitions.
// Each partition can absorb 1,000 writes/s (DynamoDB's documented per-partition write limit).
// Fix: split the hot key into N sub-keys ("salting"); writers pick one at random, readers sum all N.
// Seeded PRNG (mulberry32).
const mulberry32 = (a) => () => {
  a = (a + 0x6d2b79f5) | 0;
  let t = Math.imul(a ^ (a >>> 15), 1 | a);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const fnv1a = (s) => { let h = 0x811c9dc5; for (const c of s) h = Math.imul(h ^ c.charCodeAt(0), 0x01000193); return h >>> 0; };

const PARTITIONS = 16, WRITES_PER_SEC = 10_000, HOT_SHARE = 0.3, LIMIT = 1_000;
const partitionOf = (key) => fnv1a(key) % PARTITIONS;

function oneSecond(salt) {
  const rand = mulberry32(99); // same traffic for every run
  const pick = mulberry32(5); // separate stream for choosing a sub-key, so the traffic itself doesn't change
  const load = new Array(PARTITIONS).fill(0);
  const counts = new Map();
  for (let i = 0; i < WRITES_PER_SEC; i++) {
    let key = rand() < HOT_SHARE ? "likes:post:celebrity" : `likes:post:${Math.floor(rand() * 50_000)}`;
    if (key.endsWith("celebrity") && salt > 1) key += `#${Math.floor(pick() * salt)}`; // pick a sub-key
    load[partitionOf(key)]++;
    counts.set(key, (counts.get(key) ?? 0) + 1);
  }
  // Reading the celebrity's total now costs `salt` reads, one per sub-key.
  const subKeys = salt > 1 ? Array.from({ length: salt }, (_, i) => `likes:post:celebrity#${i}`) : ["likes:post:celebrity"];
  const total = subKeys.reduce((sum, k) => sum + (counts.get(k) ?? 0), 0);
  const throttled = load.reduce((sum, n) => sum + Math.max(0, n - LIMIT), 0);
  const used = new Set(subKeys.map(partitionOf)).size;
  console.log(
    `salt ${String(salt).padStart(2)}: busiest partition ${String(Math.max(...load)).padStart(5)} w/s, ` +
      `throttled ${String(throttled).padStart(4)} w/s, hot key on ${used} partition(s), read = ${subKeys.length} gets -> ${total}`,
  );
}

for (const salt of [1, 4, 16]) oneSecond(salt);
Result
salt  1: busiest partition  3463 w/s, throttled 2463 w/s, hot key on 1 partition(s), read = 1 gets -> 3054
salt  4: busiest partition  1239 w/s, throttled  804 w/s, hot key on 4 partition(s), read = 4 gets -> 3054
salt 16: busiest partition   822 w/s, throttled    0 w/s, hot key on 10 partition(s), read = 16 gets -> 3054
What’s happening
  1. The table is hashed over 16 partitions, and each partition can absorb 1,000 writes/s, DynamoDB's documented per-partition write limit (DynamoDB Developer Guide, "Burst and adaptive capacity", 2026). Total traffic is 10,000 writes/s, well under the table's 16,000 capacity, but 30% of it is likes on one celebrity post.
  2. Salt 1 (no split): the celebrity's key lives on one partition, which receives 3,463 writes/s: its 3,054 plus its share of ordinary keys. 2,463 writes/s are throttled even though 15 partitions are mostly idle. Adding more partitions wouldn't change this; it's one key.
  3. Salt 4: writes go to likes:post:celebrity#0 … #3, which hash to 4 partitions. The busiest partition drops to 1,239 writes/s, and throttling to 804 writes/s. Better, but 3,054 / 4 ≈ 760 writes/s per sub-key on top of normal traffic is still too close to the limit.
  4. Salt 16: the busiest partition carries 822 writes/s, nothing is throttled. The 16 sub-keys landed on only 10 distinct partitions (hashes collide), which is why you pick N comfortably larger than hot_rate / limit. The traffic is the same in all three runs (the sub-key choice uses its own PRNG stream), and the total read back is the same 3,054 each time.
  5. The price is on the read side: reading the count now takes 16 gets instead of 1 (in practice a batch get, or a cached total refreshed every second). That's the usual trade: split write-hot keys and pay on reads; for read-hot keys do the opposite, caching or replicating the one value widely (see Hot keys and cache stampedes).
AdvancedFixed logical shards: moving the minimum

With many fixed logical shards, adding machines only moves whole logical shards from busy hosts to new ones. Here's Notion's 480 on a growing fleet, compared with hash % hosts:

logical-shards.mjs
// Fixed logical shards on a changing number of hosts (Notion's layout: 480 logical shards on 32, later 96, hosts).
// Keys always hash to the same logical shard; resharding only moves whole logical shards between hosts.
const LOGICAL = 480;
const fnv1a = (s) => { let h = 0x811c9dc5; for (const c of s) h = Math.imul(h ^ c.charCodeAt(0), 0x01000193); return h >>> 0; };
const keys = Array.from({ length: 100_000 }, (_, i) => `space:${i}`);
const logicalOf = keys.map((k) => fnv1a(k) % LOGICAL);

// Spread logical shards over hosts, moving as few as possible: hosts above the new fair share give
// their highest-numbered shards to the new hosts.
function rebalance(assign, hosts) {
  const next = [...assign];
  const target = (h) => Math.floor(LOGICAL / hosts) + (h < LOGICAL % hosts ? 1 : 0);
  const owned = Array.from({ length: hosts }, () => []);
  next.forEach((h, s) => owned[h].push(s));
  const spare = owned.flatMap((list, h) => list.splice(target(h))); // shards above each host's fair share
  owned.forEach((list, h) => {
    while (list.length < target(h)) {
      const shard = spare.pop(); // new hosts take the spare shards
      next[shard] = h;
      list.push(shard);
    }
  });
  return next;
}

let assign = Array.from({ length: LOGICAL }, (_, s) => s % 32); // 32 hosts x 15 shards
for (const hosts of [40, 48, 96]) {
  const next = rebalance(assign, hosts);
  const movedShards = next.filter((h, s) => h !== assign[s]).length;
  const movedKeys = logicalOf.filter((s) => next[s] !== assign[s]).length;
  const perHost = new Set(Array.from({ length: hosts }, (_, h) => next.filter((x) => x === h).length));
  const prev = Math.max(...assign) + 1;
  const modMoved = keys.filter((k) => fnv1a(k) % prev !== fnv1a(k) % hosts).length;
  console.log(
    `${prev} -> ${hosts} hosts: ${movedShards} logical shards move (${((100 * movedKeys) / keys.length).toFixed(1)}% of keys), ` +
      `shards per host ${[...perHost].join("/")}; hash % hosts would move ${((100 * modMoved) / keys.length).toFixed(1)}%`,
  );
  assign = next;
}
Result
32 -> 40 hosts: 96 logical shards move (19.9% of keys), shards per host 12; hash % hosts would move 79.9%
40 -> 48 hosts: 80 logical shards move (16.6% of keys), shards per host 10; hash % hosts would move 83.3%
48 -> 96 hosts: 240 logical shards move (50.0% of keys), shards per host 5; hash % hosts would move 50.1%
What’s happening
  1. Every key hashes to one of 480 logical shards, and that mapping never changes. Only the second mapping, logical shard → host, changes during a reshard, and it's a small table (480 entries) that the router caches.
  2. rebalance gives each host its fair share (480 / hosts, plus one for the first 480 % hosts hosts). Hosts with more than their share hand the surplus to new hosts; nothing else moves.
  3. 32 → 40 hosts: 96 logical shards move, 19.9% of keys, which is the minimum possible (the 8 new hosts must end up with 8/40 = 20% of the data). Every host ends with exactly 12 shards, because 480 divides by 40. With plain hash % hosts, 79.9% of keys would change host.
  4. 40 → 48: 80 shards move (16.6%, again the minimum, 8/48), every host has 10. Modulo would move 83.3%.
  5. 48 → 96: doubling the fleet must move half the data either way, so both move about 50%, and every host has 5 shards, Notion's 2023 layout. This is why 480 was chosen: 32, 40, 48, 60, 80, 96, 120 and 160 hosts all get an equal number of shards. With 500 logical shards, 48 hosts would get 10 or 11, a permanent 10% imbalance.
AdvancedWhen the store splits for you

Managed stores handle much of this themselves, and it's worth knowing what they do and don't promise. DynamoDB throttles a partition that receives more than 3,000 reads or 1,000 writes per second; adaptive capacity shifts unused throughput to hot partitions, and it can isolate a frequently accessed item onto its own partition, but a single item still can't exceed those per-partition maximums (DynamoDB Developer Guide, 2026). MongoDB's balancer moves 128 MB ranges between shards, but a range holding a single shard-key value can't be split (a "jumbo chunk"). The pattern is the same everywhere: the system can split ranges and move partitions; it can't split one key. That's the application's job, by changing the key.

#Secondary indexes in a sharded system

Added

Partitioning by a key makes queries by that key cheap: one shard. Every other attribute is a problem. Orders are sharded by customer_id, and support wants "all disputed orders", or the login service wants "the user with this email". The rows that match could be on any shard. There are two ways to index them, and they put the cost in opposite places.

A local index (also called document-partitioned) is the obvious one: every shard indexes its own rows. Writes are cheap and consistent, because the row and its index entry live on the same machine and change in the same transaction. Reads by the indexed attribute must ask every shard and merge the answers: scatter-gather. A global index (term-partitioned) is partitioned by the indexed value itself: all status = 'disputed' entries live on one index partition, all emails starting with a on another. A read asks one index partition, then fetches the matching rows from wherever they live. A write now touches two machines (the row's shard and the index partition), so the index is either updated asynchronously and briefly stale, or kept in step with a distributed transaction.

The mental model is a library with many branches. A local index is every branch keeping its own card catalogue: adding a book is easy, but to find every copy of a title you phone every branch and wait for the slowest to answer. A global index is one central catalogue: one call finds every copy, but every new book must also be registered centrally, and for a while after a book arrives the central catalogue doesn't know about it.

Local index: scatter-gather status = disputed ask all, merge Shard 1 idx: o7 Shard 2 idx: — Shard 3 idx: o2 writes: local, consistent reads: N calls, wait for the slowest Global index: lookup + fetch status = disputed 1. ask the index Index partition disputed: o2, o7 Shard 1 Shard 2 Shard 3 2. fetch o7, o2 async update writes: row + async index update reads: 1 + (shards holding matches)
A local index makes writes cheap and reads fan out; a global index makes reads targeted and writes cross machines.

The cost of scatter-gather isn't only the extra calls. Waiting for the slowest of N replies means the query's latency is the maximum of N latencies, and the maximum of many samples lives in the tail. This model compares the two designs as the cluster grows:

scatter-gather.mjs
// Query by a non-key attribute ("orders with status = 'disputed'") on N shards.
// Local index: every shard indexes its own rows, so the query asks all N shards and waits for the slowest.
// Global index: one index partition knows the matching ids; then fetch rows from the few shards that hold them.
// Simulated latencies from a seeded PRNG (mulberry32), not measurements.
const mulberry32 = (a) => () => {
  a = (a + 0x6d2b79f5) | 0;
  let t = Math.imul(a ^ (a >>> 15), 1 | a);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const rand = mulberry32(11);

// One shard call: 2-8 ms usually; 1% of calls hit a GC pause, a compaction or a cold cache: 50-150 ms.
const call = () => (rand() < 0.01 ? 50 + rand() * 100 : 2 + rand() * 6);
const QUERIES = 20_000;
const MATCH_SHARDS = 3; // the matching rows live on 3 shards

const pct = (arr, p) => [...arr].sort((a, b) => a - b)[Math.floor((p / 100) * (arr.length - 1))];

const ms = (arr) => [50, 90, 99].map((p) => pct(arr, p).toFixed(0).padStart(4)).join("");
console.log("       |   local index (scatter-gather)  |   global index (lookup + fetch)");
console.log("shards | calls/query  p50 p90 p99 (ms)  | calls/query  p50 p90 p99 (ms)");
for (const N of [4, 16, 64]) {
  const local = [], global = [];
  for (let q = 0; q < QUERIES; q++) {
    local.push(Math.max(...Array.from({ length: N }, call))); // scatter to all N, gather when the last replies
    const fetch = Math.max(...Array.from({ length: Math.min(N, MATCH_SHARDS) }, call));
    global.push(call() + fetch); // index lookup, then parallel fetches
  }
  console.log(`${String(N).padStart(6)} | ${String(N).padStart(11)} ${ms(local)}       | ${String(1 + Math.min(N, MATCH_SHARDS)).padStart(11)} ${ms(global)}`);
}
const slow = (N) => (100 * (1 - 0.99 ** N)).toFixed(0);
console.log(`chance at least one of N calls is slow: N=4 ${slow(4)}%, N=16 ${slow(16)}%, N=64 ${slow(64)}%`);
Result
       |   local index (scatter-gather)  |   global index (lookup + fetch)
shards | calls/query  p50 p90 p99 (ms)  | calls/query  p50 p90 p99 (ms)
     4 |           4    7   8 124       |           4   12  15 130
    16 |          16    8  84 144       |           4   12  15 131
    64 |          64    8 134 148       |           4   12  15 133
chance at least one of N calls is slow: N=4 4%, N=16 15%, N=64 47%
What’s happening
  1. One shard call takes 2–8 ms, except that 1% of calls hit something slow (a GC pause, a compaction, a cold cache) and take 50–150 ms. These are simulated, seeded latencies; the shape (fast body, rare slow tail) is what real services look like.
  2. Local index: each query calls all N shards in parallel and finishes when the last one answers. At 4 shards, p50 is 7 ms and p90 8 ms: most queries miss the slow 1%. At 16 shards p90 jumps to 84 ms, and at 64 shards to 134 ms, because the last line's maths says a query that touches 64 shards has a 47% chance that at least one call is slow. The median stays low until that chance passes 50%, then it falls off a cliff too.
  3. Global index: one call to the index partition, then parallel fetches from the 3 shards that hold the matching rows: 4 calls whatever the cluster size. Its p50 (12 ms) is higher than the local index at 4 shards, because it makes two sequential round trips; its p90 stays at 15 ms at 16 and 64 shards.
  4. The calls/query column is the other cost: at 64 shards every local-index query costs 64 shard calls, so adding shards doesn't add query capacity for this query, it subtracts it. Scatter-gather queries limit how far a sharded system scales.
  5. Both designs share a p99 around 125–150 ms, because both still make several calls and any slow call shows up there. Fixing that tail is a separate tool: hedged requests (send a second copy after the p95 time and take the first answer), as described in Dean and Barroso's "The Tail at Scale" (Communications of the ACM, 2013).
AdvancedWhat "global" costs on the write side

A global index is a second copy of part of each row, stored somewhere else and updated after the base write. Two consequences, modelled with a fake clock:

global-index-lag.mjs
// A global secondary index is updated asynchronously after the base write commits (DynamoDB GSIs,
// Cassandra materialized views, most hand-built index tables). Simulated clock in milliseconds.
const base = new Map(); // orderId -> order (on the order's own shard)
const byStatus = new Map(); // status -> Set(orderId) (on the index partition for that status)
const pending = []; // index updates waiting to be applied: { at, op, status, id }
let now = 0;
const writes = { base: 0, index: 0 };
const INDEX_LAG = 300; // ms; normally well under a second, longer when the index is throttled

function put(order) {
  const old = base.get(order.id);
  base.set(order.id, order);
  writes.base++;
  if (old?.status === order.status) return;
  // Changing an indexed value costs two index writes: delete the old entry, add the new one.
  if (old) pending.push({ at: now + INDEX_LAG, op: "del", status: old.status, id: order.id });
  pending.push({ at: now + INDEX_LAG, op: "add", status: order.status, id: order.id });
}
function tick(ms) {
  now += ms;
  for (const u of pending.filter((u) => u.at <= now)) {
    const set = byStatus.get(u.status) ?? byStatus.set(u.status, new Set()).get(u.status);
    u.op === "add" ? set.add(u.id) : set.delete(u.id);
    writes.index++;
  }
  pending.splice(0, pending.length, ...pending.filter((u) => u.at > now));
}
const query = (status) => [...(byStatus.get(status) ?? [])].sort();

put({ id: "o1", status: "paid" });
put({ id: "o2", status: "paid" });
tick(1_000);
console.log("t=1000 paid:", query("paid"), "disputed:", query("disputed"));

put({ id: "o2", status: "disputed" }); // a support agent opens a dispute...
tick(50);
console.log("t=1050 paid:", query("paid"), "disputed:", query("disputed"), "<- index not updated yet");
tick(300);
console.log("t=1350 paid:", query("paid"), "disputed:", query("disputed"));
console.log(`writes: ${writes.base} base + ${writes.index} index (the status change cost 1 + 2)`);
Result
t=1000 paid: [ 'o1', 'o2' ] disputed: []
t=1050 paid: [ 'o1', 'o2' ] disputed: [] <- index not updated yet
t=1350 paid: [ 'o1' ] disputed: [ 'o2' ]
writes: 3 base + 4 index (the status change cost 1 + 2)
What’s happening
  1. put writes the order on its own shard immediately and queues the index changes with a 300 ms delay, the way DynamoDB global secondary indexes and most hand-built index tables are updated: asynchronously, after the base write commits.
  2. At t = 1,000 both orders are paid and the index agrees: paid: [o1, o2].
  3. A support agent marks o2 as disputed. Fifty milliseconds later the base row says disputed, but the index still lists o2 under paid and disputed is empty. An agent who refreshes the "disputed orders" queue right after filing the dispute doesn't see it. By t = 1,350 the index has caught up.
  4. Changing an indexed value cost two index writes (remove from paid, add to disputed) on top of the base write, so the counters read 3 base + 4 index. DynamoDB documents exactly this: an update that changes an index key attribute needs one delete and one put in the index, and each index consumes its own write capacity (DynamoDB Developer Guide, "Using Global Secondary Indexes", 2026).
  5. Design consequences: a screen that must show the user's own change right away should read the base row by key (which is consistent) rather than the index; every extra global index multiplies write cost; and if an index can't keep up with writes, DynamoDB throttles the base table writes, so an under-provisioned index can take down the write path.
AdvancedWhat real systems do
SystemSecondary indexReads by the indexed attributeConsistency
MongoDB (sharded)local: each shard indexes its own documentswithout the shard key, the router broadcasts to every shardconsistent with the document
Elasticsearch / OpenSearchthe whole index is document-partitioned into shardsevery search goes to every shard, then a top-k mergenear-real-time (refresh, usually 1 s)
Cassandralocal secondary indexes (SAI in Cassandra 5.0)scatter to the nodes; best when combined with the partition keylocal to each node
DynamoDB LSIlocal: same partition key, different sort key; up to 5 per tableone partitionstrongly consistent reads available
DynamoDB GSIglobal: its own partition key; default quota 20 per tableone index partitioneventually consistent only
Hand-built index tablee.g. users_by_email(email → user_id), sharded by emailone lookup, then fetchwhatever you build: async, or in a transaction

(DynamoDB figures: Developer Guide, "Quotas" and "Using Global Secondary Indexes", 2026.)

The hand-built table is how you get global uniqueness in a sharded system: to make email unique across all shards, insert into users_by_email first with a conditional "only if absent" write, and only then create the user row. A local unique index can only promise uniqueness within one shard.

#Object storage for files and media

Added

Photos, videos, PDFs, backups and exports don't belong in a database. A 3 MB photo in a table row bloats every backup and replica, fills the buffer pool with bytes nobody queries, and makes the database serve traffic a CDN should. Object storage (Amazon S3, Google Cloud Storage, Azure Blob Storage, Cloudflare R2, MinIO) is built for exactly this: a flat namespace of keys mapped to immutable blobs with a little metadata, read and written over HTTP, scaling to any number of objects, cheap per gigabyte and very durable. You don't edit an object in place; you write a new one (or a new version) under a key.

The mental model is a cloakroom. The cloakroom (the bucket) keeps the coats (the bytes); the database keeps the ticket (the key) and everything you want to query about the coat: who owns it, its size and type, whether processing has finished. The application asks the database "which photos does Maya have?", gets keys back, and the client fetches the bytes from the cloakroom, or better, from a CDN in front of it.

The numbers that shape designs, from AWS's S3 documentation (October 2026):

PropertyS3 figure
Durability (designed for)99.999999999% (11 nines) for S3 Standard and the other classes; Standard availability 99.99%
Consistencystrong read-after-write for PUTs, overwrites, deletes and LIST, since December 2020
Single PUTup to 5 GB; above about 100 MB AWS recommends multipart
Multipart uploadup to 10,000 parts of 5 MiB to 5 GiB each (the last part may be smaller)
Maximum object size50 TB (raised from 5 TB in December 2025)
Request rateat least 3,500 PUT/COPY/POST/DELETE and 5,500 GET/HEAD per second per partitioned prefix, scaling gradually, with 503 Slow Down responses while it scales
Presigned URL lifetime1 s to 7 days (604,800 s) with Signature V4; shorter if signed with temporary credentials

The design move that matters most is not sending the bytes through your servers. Uploading a 200 MB video through an API server ties up a connection and a worker for minutes and doubles the bandwidth bill. Instead, the server hands the client a presigned URL: a URL to one specific object, for one specific operation, valid for a few minutes, signed with the server's secret. The client uploads straight to storage, and the storage service checks the signature.

Client browser / app API server signs URLs only Database media: pending → ready Object storage uploads/u42/9f1c.jpg Queue Worker thumbnails Viewers CDN caches renditions 1. ask to upload 3. get presigned URL 2. pending row 4. PUT bytes 5. event 6. mark ready 6. write renditions 7. GET miss → origin
The API server signs; the bytes go straight from the client to storage, and from storage to viewers through the CDN.

The flow, in the order it happens (the diagram draws seven arrows; here they're grouped into five steps):

  1. The client asks the API for an upload (POST /media with the file's type and size). The API checks the user may upload, picks the key (uploads/u42/9f1c.jpg, never the user's file name), and inserts a media row with status = 'pending'.
  2. The API returns a presigned PUT URL for exactly that key, valid for a few minutes.
  3. The client uploads the bytes directly to the bucket. Large files use multipart upload, with one presigned URL per part, so parts upload in parallel and a failed part is retried alone.
  4. The bucket emits an "object created" event to a queue. A worker checks the file (type, size, malware scan), writes the renditions (thumbnails, transcodes) back to storage, and marks the row ready with its size and checksum.
  5. Viewers get URLs that point at the CDN, which fetches from the bucket only on a miss. Private media uses short-lived signed CDN URLs or signed cookies instead of a public bucket.

What does a presigned URL actually contain? It's the request itself plus an HMAC signature computed with the secret key, and you can build one with nothing but node:crypto. This model follows AWS's Signature Version 4 query-string algorithm and uses the worked example and the example credentials published in AWS's S3 API documentation, so its output can be checked against the docs:

presign.mjs
// An S3 presigned URL (AWS Signature Version 4, query-string form), with node:crypto only.
// Inputs are the worked example in AWS's S3 API docs ("Authenticating Requests: Using Query Parameters"),
// including AWS's published example credentials, so the result can be checked against the docs.
import { createHash, createHmac } from "node:crypto";

const sha256 = (s) => createHash("sha256").update(s).digest("hex");
const hmac = (key, s) => createHmac("sha256", key).update(s).digest();
const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);

function presign({ method, bucket, key, region, accessKeyId, secretAccessKey, amzDate, expiresSec }) {
  const host = `${bucket}.s3.amazonaws.com`;
  const date = amzDate.slice(0, 8);
  const scope = `${date}/${region}/s3/aws4_request`;
  const params = {
    "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
    "X-Amz-Credential": `${accessKeyId}/${scope}`,
    "X-Amz-Date": amzDate,
    "X-Amz-Expires": String(expiresSec),
    "X-Amz-SignedHeaders": "host",
  };
  const query = Object.keys(params).sort().map((k) => `${enc(k)}=${enc(params[k])}`).join("&");
  // 1. Canonical request: exactly what is being allowed (method, object path, query, signed headers).
  const canonical = [method, `/${key.split("/").map(enc).join("/")}`, query, `host:${host}`, "", "host", "UNSIGNED-PAYLOAD"].join("\n");
  // 2. String to sign: algorithm, time, scope and a hash of the canonical request.
  const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256(canonical)].join("\n");
  // 3. Signing key: the secret is never sent; it's folded through date, region and service.
  let k = hmac(`AWS4${secretAccessKey}`, date);
  for (const part of [region, "s3", "aws4_request"]) k = hmac(k, part);
  const signature = createHmac("sha256", k).update(stringToSign).digest("hex");
  return { url: `https://${host}/${key}?${query}&X-Amz-Signature=${signature}`, stringToSign, signature };
}

const { url, stringToSign, signature } = presign({
  method: "GET",
  bucket: "examplebucket",
  key: "test.txt",
  region: "us-east-1",
  accessKeyId: "AKIAIOSFODNN7EXAMPLE",
  secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  amzDate: "20130524T000000Z",
  expiresSec: 86_400,
});
console.log(stringToSign);
console.log(url.replace("?", "\n  ?").replaceAll("&", "\n  &"));
console.log("matches the AWS docs:", signature === "aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404");
Result
AWS4-HMAC-SHA256
20130524T000000Z
20130524/us-east-1/s3/aws4_request
3bfa292879f6447bbcda7001decf97f4a54dc650c8942174ae0a9121cf58ad04
https://examplebucket.s3.amazonaws.com/test.txt
  ?X-Amz-Algorithm=AWS4-HMAC-SHA256
  &X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request
  &X-Amz-Date=20130524T000000Z
  &X-Amz-Expires=86400
  &X-Amz-SignedHeaders=host
  &X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404
matches the AWS docs: true
What’s happening
  1. presign starts from the request it wants to allow: GET of test.txt in examplebucket, valid for 86400 s from 20130524T000000Z. The X-Amz-* parameters say which algorithm signed it, which key (by its ID, never the secret), when, and for how long. They're sorted and percent-encoded so the signer and the verifier build exactly the same string.
  2. The canonical request joins the method, the object path, that query string, the signed headers (host) and UNSIGNED-PAYLOAD (the signer doesn't know the bytes that will be uploaded). Changing any of these, even the method, produces a different signature.
  3. The string to sign is the algorithm, the timestamp, the scope 20130524/us-east-1/s3/aws4_request and the SHA-256 of the canonical request: 3bfa2928…ad04, the same value printed in AWS's example.
  4. The signing key is derived by HMAC-ing "AWS4" + secret with the date, then the region, the service and aws4_request. A leaked signing key is therefore only good for one day, one region and one service, and the secret itself is never sent anywhere.
  5. The final HMAC is the signature, aeeed9bb…d404, and the last line confirms it equals the signature in AWS's documentation. The URL is just the parameters plus that signature, so it can be handed to any client; it grants this operation on this object until it expires, nothing more. Your API server's job shrinks to an authorisation check and a few microseconds of hashing.
AdvancedThe storage side: what a presigned URL prevents

Storage verifies a presigned URL by rebuilding the canonical request from the request that actually arrived and recomputing the signature with its copy of the secret. Anything the client changes breaks the match. This model uses the same algorithm with a fake clock:

verify-presigned.mjs
// The storage side of a presigned URL: rebuild the canonical request from the request that actually arrived,
// re-derive the signature with the secret, and check the expiry. Fake clock; AWS's published example keys.
import { createHash, createHmac, timingSafeEqual } from "node:crypto";

const SECRETS = { AKIAIOSFODNN7EXAMPLE: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" }; // what the store knows
const sha256 = (s) => createHash("sha256").update(s).digest("hex");
const hmac = (key, s) => createHmac("sha256", key).update(s).digest();
const enc = (s) => encodeURIComponent(s);

function sign(method, host, path, params, secret) {
  const query = Object.keys(params).sort().map((k) => `${enc(k)}=${enc(params[k])}`).join("&");
  const canonical = [method, path, query, `host:${host}`, "", "host", "UNSIGNED-PAYLOAD"].join("\n");
  const [, date, region] = params["X-Amz-Credential"].split("/");
  const toSign = ["AWS4-HMAC-SHA256", params["X-Amz-Date"], `${date}/${region}/s3/aws4_request`, sha256(canonical)].join("\n");
  let k = hmac(`AWS4${secret}`, date);
  for (const part of [region, "s3", "aws4_request"]) k = hmac(k, part);
  return { query, sig: createHmac("sha256", k).update(toSign).digest("hex") };
}
const parseAmzDate = (d) => Date.UTC(+d.slice(0, 4), +d.slice(4, 6) - 1, +d.slice(6, 8), +d.slice(9, 11), +d.slice(11, 13), +d.slice(13, 15));

// The app server presigns a PUT for one key, valid for 10 minutes.
const params = {
  "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
  "X-Amz-Credential": "AKIAIOSFODNN7EXAMPLE/20261024/us-east-1/s3/aws4_request",
  "X-Amz-Date": "20261024T120000Z",
  "X-Amz-Expires": "600",
  "X-Amz-SignedHeaders": "host",
};
const host = "photos-bucket.s3.amazonaws.com";
const { query, sig } = sign("PUT", host, "/uploads/u42/9f1c.jpg", params, SECRETS.AKIAIOSFODNN7EXAMPLE);
const url = `https://${host}/uploads/u42/9f1c.jpg?${query}&X-Amz-Signature=${sig}`;

function storageReceives(method, url, nowMs) {
  const u = new URL(url);
  const p = Object.fromEntries(u.searchParams);
  const given = p["X-Amz-Signature"];
  delete p["X-Amz-Signature"];
  const secret = SECRETS[p["X-Amz-Credential"].split("/")[0]];
  if (!secret) return "403 unknown access key";
  const expected = sign(method, u.host, u.pathname, p, secret).sig;
  if (!timingSafeEqual(Buffer.from(given, "hex"), Buffer.from(expected, "hex"))) return "403 SignatureDoesNotMatch";
  if (nowMs > parseAmzDate(p["X-Amz-Date"]) + 1000 * Number(p["X-Amz-Expires"])) return "403 expired";
  return "200 OK, object stored";
}

const t = (hhmm) => Date.UTC(2026, 9, 24, +hhmm.slice(0, 2), +hhmm.slice(3));
const attempts = [
  ["PUT at 12:05", "PUT", url, "12:05"],
  ["GET with the PUT URL", "GET", url, "12:05"],
  ["PUT to a different key", "PUT", url.replace("9f1c.jpg", "evil.exe"), "12:05"],
  ["Expires edited to 1 day", "PUT", url.replace("Expires=600", "Expires=86400"), "12:05"],
  ["PUT at 12:11", "PUT", url, "12:11"],
];
for (const [label, method, u, at] of attempts) console.log(`${label.padEnd(24)} -> ${storageReceives(method, u, t(at))}`);
Result
PUT at 12:05             -> 200 OK, object stored
GET with the PUT URL     -> 403 SignatureDoesNotMatch
PUT to a different key   -> 403 SignatureDoesNotMatch
Expires edited to 1 day  -> 403 SignatureDoesNotMatch
PUT at 12:11             -> 403 expired
What’s happening
  1. The app server presigns a PUT to uploads/u42/9f1c.jpg, valid for 600 s from 12:00:00 UTC. The verifier knows the secret for AKIAIOSFODNN7EXAMPLE (AWS's documented example key), just as S3 knows your keys.
  2. PUT at 12:05: the rebuilt canonical request matches what was signed, the signature matches, and 12:05 is before 12:10, so the upload is accepted. timingSafeEqual compares signatures in constant time so an attacker can't learn the right signature byte by byte from response timing.
  3. GET with the PUT URL: the method is part of the canonical request, so a URL that allows uploading can't be used to download (or to DELETE). PUT to a different key: the path is part of it too, so a client can't reuse its URL to overwrite someone else's object or upload evil.exe.
  4. Expires edited to 1 day: the expiry is one of the signed parameters, so stretching it breaks the signature. The client can't extend its own permission.
  5. PUT at 12:11: the signature is still valid, but the time is past X-Amz-Date + X-Amz-Expires, so it's refused. Keep upload URLs short-lived (minutes); a leaked URL is a capability anyone can use until it expires.
AdvancedEstimating media storage

The numbers for a photo-sharing service, with real S3 prices:

media-estimate.mjs
// Photo-sharing estimate: storage, request rate and monthly bill, with and without lifecycle rules.
// Prices: AWS S3 price list for US East (N. Virginia), published 2026-10-15. Standard uses the over-500 TB
// tier ($0.021; the first 50 TB cost $0.023 and the next 450 TB $0.022), which nearly all of this volume is in.
const PRICE = { standard: 0.021, ia: 0.0125, glacierIR: 0.004, putPer1k: 0.005, getPer1k: 0.0004 }; // USD
const GB = 1e9;

const uploadsPerDay = 10e6;
const originalBytes = 3e6; // 3 MB phone photo
const renditions = [400e3, 100e3, 20e3]; // large, medium, thumbnail (generated by a worker)
const peakFactor = 3;
const viewsPerDay = 500e6; // most served by the CDN; assume 5% miss and hit the bucket
const cdnMissRate = 0.05;

const bytesPerUpload = originalBytes + renditions.reduce((a, b) => a + b, 0);
const objectsPerUpload = 1 + renditions.length;
const putsPerSecPeak = ((uploadsPerDay * objectsPerUpload) / 86_400) * peakFactor;
const getsPerSecPeak = ((viewsPerDay * cdnMissRate) / 86_400) * peakFactor;
const tbPerYear = (uploadsPerDay * bytesPerUpload * 365) / 1e12;

console.log(`per upload: ${objectsPerUpload} objects, ${(bytesPerUpload / 1e6).toFixed(2)} MB`);
console.log(`peak: ${Math.round(putsPerSecPeak)} PUT/s, ${Math.round(getsPerSecPeak)} GET/s at the bucket`);
console.log(`storage added: ${tbPerYear.toFixed(0)} TB per year`);
console.log(`S3 prefixes needed at peak (3,500 PUT/s each): ${Math.ceil(putsPerSecPeak / 3_500)}`);

// Bill in month 12 (11 full months of history plus the current month). Lifecycle rule: originals move to
// Standard-IA after 30 days and to Glacier Instant Retrieval after 90; renditions stay in Standard (they're viewed).
const monthGB = (uploadsPerDay * 30 * bytesPerUpload) / GB;
const origShare = originalBytes / bytesPerUpload;
let allStandard = 0, withLifecycle = 0;
for (let age = 0; age < 12; age++) {
  allStandard += monthGB * PRICE.standard;
  const origClass = age < 1 ? PRICE.standard : age < 3 ? PRICE.ia : PRICE.glacierIR;
  withLifecycle += monthGB * (origShare * origClass + (1 - origShare) * PRICE.standard);
}
const requests = ((uploadsPerDay * objectsPerUpload * 30) / 1e3) * PRICE.putPer1k + ((viewsPerDay * cdnMissRate * 30) / 1e3) * PRICE.getPer1k;
const usd = (n) => `$${Math.round(n).toLocaleString("en-US")}`;
console.log(`month 12 storage: all Standard ${usd(allStandard)}, with lifecycle ${usd(withLifecycle)}`);
console.log(`month 12 requests: ${usd(requests)} (PUTs dominate)`);

// Multipart upload for a large video: at most 10,000 parts of 5 MiB to 5 GiB each.
const video = 20 * 2 ** 30, part = 64 * 2 ** 20;
console.log(`20 GiB video in 64 MiB parts: ${Math.ceil(video / part)} parts (limit 10,000)`);
Result
per upload: 4 objects, 3.52 MB
peak: 1389 PUT/s, 868 GET/s at the bucket
storage added: 12848 TB per year
S3 prefixes needed at peak (3,500 PUT/s each): 1
month 12 storage: all Standard $266,112, with lifecycle $113,112
month 12 requests: $6,300 (PUTs dominate)
20 GiB video in 64 MiB parts: 320 parts (limit 10,000)
What’s happening
  1. Assumptions: 10M uploads a day of 3 MB originals, each with three renditions (400 KB, 100 KB, 20 KB) made by the worker, 500M views a day of which 5% miss the CDN, and a 3× peak. Every upload creates 4 objects, 3.52 MB.
  2. Request rates: 1,389 PUTs/s and 868 GETs/s at the bucket at peak. Against S3's 3,500 PUT/s per partitioned prefix that's 1 prefix: object storage request rates are rarely the bottleneck, as long as the CDN takes the views. Keys that start with a spread-out value (uploads/<user>/... or a short hash) give S3 room to partition if traffic grows.
  3. Storage grows by 12,848 TB a year, about 12.8 PB. That's the number that drives cost, and it's why the database holds only metadata: one 200-byte row per object would be about 2.9 TB a year, which is large but ordinary.
  4. Month 12's storage bill is $266,112 if everything stays in S3 Standard, at the over-500 TB rate of $0.021 per GB-month. A lifecycle rule that moves originals (85% of the bytes) to Standard-IA after 30 days and Glacier Instant Retrieval after 90, while renditions stay in Standard because they're viewed, brings it to $113,112, 58% less. Prices are from AWS's S3 price list for US East (N. Virginia), published 15 October 2026; IA and Glacier IR also charge per-GB retrieval fees and have minimum storage durations (30 and 90 days), which this estimate leaves out.
  5. Requests cost $6,300 a month, mostly PUTs ($0.005 per 1,000 against $0.0004 per 1,000 GETs). A 20 GiB video uploaded in 64 MiB parts is 320 parts, far under the 10,000-part limit. At 64 MiB a part the limit is reached at 625 GiB, so bigger files need bigger parts, and a file near the 50 TB maximum needs parts close to 5 GiB.

Lifecycle rules are bucket configuration, not code. This is the JSON form aws s3api put-bucket-lifecycle-configuration accepts, for the policy above plus two housekeeping rules:

lifecycle.json
{
  "Rules": [
    {
      "ID": "originals-to-colder-storage",
      "Filter": { "Prefix": "uploads/" },
      "Status": "Enabled",
      "Transitions": [
        { "Days": 30, "StorageClass": "STANDARD_IA" },
        { "Days": 90, "StorageClass": "GLACIER_IR" }
      ]
    },
    {
      "ID": "abort-abandoned-multipart-uploads",
      "Filter": {},
      "Status": "Enabled",
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
    },
    {
      "ID": "expire-temporary-exports",
      "Filter": { "Prefix": "tmp/" },
      "Status": "Enabled",
      "Expiration": { "Days": 1 }
    }
  ]
}
What’s happening
  1. The first rule applies only to keys under uploads/, where the originals live (uploads/u42/9f1c.jpg in the flow above), so renditions, written under their own prefix (say renditions/) because the CDN keeps serving them, stay in Standard. That's one reason to plan the key layout up front: lifecycle rules, permissions and replication rules all select objects by prefix (or by tag).
  2. Transitions move objects to cheaper classes by age since creation. Moving data costs a transition request per object and, for IA and Glacier classes, minimum storage durations, so don't transition objects that will be deleted soon.
  3. AbortIncompleteMultipartUpload deletes parts of uploads that were started and never completed. Those parts are billed as storage but invisible in normal listings, a classic source of mystery storage costs in upload-heavy apps.
  4. Expiration deletes objects outright after a day, for exports and other temporary files. Combined with a pending row in the database, a nightly job can also delete rows (and objects) whose uploads never finished.

The metadata row that goes with each object is small and boring, which is the point:

ColumnExampleWhy
id9f1c…primary key; also the last part of the object key
owner_id42authorisation, "my photos" queries
object_keyuploads/u42/9f1c.jpgwhere the bytes are; never derived from user input
statuspending / ready / faileduploads finish asynchronously
content_type, bytes, sha256image/jpeg, 3014332, …verified by the worker, not taken from the client
created_at2026-10-24T12:00:05Zlifecycle and cleanup of stuck uploads

#Search with an inverted index

Added

WHERE description LIKE '%noise cancelling%' has to read every row, because a B-tree index is sorted by the start of the value and a pattern that starts with % can't use it. It also can't rank: a product that mentions the words once in passing comes back alongside the one that's about exactly that. Search engines solve both problems with an inverted index: instead of mapping each document to its words, map each word to the list of documents that contain it, the postings list.

The mental model is the index at the back of a book. You don't read the book to find "replication"; you look the word up and get page numbers. A search engine does the same, with two additions: it normalises words so "Headphones", "headphone" and "HEADPHONES" are the same entry (the analyzer, applied identically when indexing and when searching), and it scores each matching document so the best ones come first. The standard score is BM25: a term counts more when it's rare across the collection (inverse document frequency), more when it appears several times in a document but with diminishing returns, and less in long documents that mention everything.

inverted-index.mjs
// A tiny search engine: tokenise, build postings lists, rank with BM25 (Lucene/Elasticsearch defaults k1=1.2, b=0.75).
const docs = {
  1: "Wireless noise cancelling headphones with 30 hour battery",
  2: "Wired headphones for studio monitoring",
  3: "Noise cancelling earbuds, wireless charging case",
  4: "Bluetooth speaker with deep bass and 12 hour battery",
  5: "Over-ear headphones, wireless, foldable, with noise cancelling and a travel case for flights",
  6: "Phone case with wireless charging support",
};

const STOP = new Set(["with", "for", "and", "a", "the", "of"]);
// Analyzer: lowercase, split on anything that isn't a letter or digit, drop stopwords, strip a plural "s" (not "ss").
const stem = (t) => (t.length > 3 && t.endsWith("s") && !t.endsWith("ss") ? t.slice(0, -1) : t);
const analyze = (text) => text.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t && !STOP.has(t)).map(stem);

// Inverted index: term -> postings list of [docId, term frequency], plus each doc's length in terms.
const index = new Map();
const docLen = new Map();
for (const [id, text] of Object.entries(docs)) {
  const terms = analyze(text);
  docLen.set(id, terms.length);
  const tf = new Map();
  for (const t of terms) tf.set(t, (tf.get(t) ?? 0) + 1);
  for (const [t, n] of tf) (index.get(t) ?? index.set(t, []).get(t)).push([id, n]);
}
const N = docLen.size;
const avgLen = [...docLen.values()].reduce((a, b) => a + b, 0) / N;

function search(query, { mode = "or", k1 = 1.2, b = 0.75 } = {}) {
  const qTerms = [...new Set(analyze(query))];
  const scores = new Map(), matched = new Map();
  for (const t of qTerms) {
    const postings = index.get(t) ?? [];
    const idf = Math.log(1 + (N - postings.length + 0.5) / (postings.length + 0.5)); // rare terms weigh more
    for (const [id, tf] of postings) {
      const norm = tf + k1 * (1 - b + (b * docLen.get(id)) / avgLen); // long docs need more repeats to score
      scores.set(id, (scores.get(id) ?? 0) + (idf * tf * (k1 + 1)) / norm);
      matched.set(id, (matched.get(id) ?? 0) + 1);
    }
  }
  return [...scores]
    .filter(([id]) => mode === "or" || matched.get(id) === qTerms.length)
    .sort((x, y) => y[1] - x[1])
    .map(([id, s]) => `doc ${id} ${s.toFixed(3)}`);
}

console.log("postings 'wireless':", JSON.stringify(index.get("wireless")));
console.log("postings 'headphone':", JSON.stringify(index.get("headphone")));
console.log("analyze:", analyze("Noise-Cancelling Headphones"));
console.log("OR  'wireless noise cancelling headphones':", search("wireless noise cancelling headphones").join(", "));
console.log("AND 'wireless noise cancelling headphones':", search("wireless noise cancelling headphones", { mode: "and" }).join(", "));
console.log("OR  'battery case':", search("battery case").join(", "));
Result
postings 'wireless': [["1",1],["3",1],["5",1],["6",1]]
postings 'headphone': [["1",1],["2",1],["5",1]]
analyze: [ 'noise', 'cancelling', 'headphone' ]
OR  'wireless noise cancelling headphones': doc 1 2.444, doc 5 2.066, doc 3 1.888, doc 2 0.823, doc 6 0.488
AND 'wireless noise cancelling headphones': doc 1 2.444, doc 5 2.066
OR  'battery case': doc 1 0.998, doc 4 0.998, doc 6 0.765, doc 3 0.716, doc 5 0.568
What’s happening
  1. Analyzing: analyze lowercases the text, splits on anything that isn't a letter or a digit, drops stopwords and strips a plural s (but not ss, or "wireless" would become "wireles"). "Noise-Cancelling Headphones" becomes noise, cancelling, headphone. Real analyzers (Elasticsearch's standard plus a stemmer, synonyms, language rules) are richer, but the rule is the same: query and documents go through the same analyzer, or they won't meet.
  2. Indexing: each document's terms are counted and appended to each term's postings list as [docId, termFrequency]. wireless → docs 1, 3, 5, 6; headphone → docs 1, 2, 5. Each document's length (number of terms) is stored too. Building the index costs work at write time so that a query only reads the postings of its own terms, never the documents themselves.
  3. OR query "wireless noise cancelling headphones": every document containing any term gets a score, summed over the terms it contains. idf gives headphone (in 3 of 6 docs) and wireless (4 of 6) different weights; rarer terms count more. Doc 1 contains all four terms and scores 2.444; doc 5 also contains all four but scores 2.066; doc 3 lacks headphone and scores 1.888; doc 6 matches only wireless and scores 0.488.
  4. AND query: only documents that matched every query term survive, docs 1 and 5. Search engines do this efficiently by intersecting sorted postings lists, walking them in step and skipping ahead, without scoring documents that can't match.
  5. "battery case": docs 1 and 4 tie at 0.998: both contain battery once, and both are exactly 7 terms long, so BM25 can't tell them apart. Real engines break ties with other signals (popularity, recency, business rules), which is where most of the work in a product search goes.
Why does doc 5 rank below doc 1?

Doc 1 and doc 5 both contain all four query terms exactly once, yet doc 1 scores 2.444 and doc 5 scores 2.066. Why?

Show answer

Length normalisation. Doc 5 has more terms, so each match counts for less.

What’s happening
  1. BM25's per-term score is idf × tf × (k1 + 1) / (tf + k1 × (1 − b + b × docLen / avgLen)). With tf = 1 for every term in both docs, only docLen differs.
  2. After analysis doc 1 has 7 terms and doc 5 has 10 (over ear headphone wireless foldable noise cancelling travel case flight); the average over the six docs is 6.5.
  3. For doc 1, 1 − b + b × 7 / 6.5 is 1.058, so each term's denominator is 1 + 1.2 × 1.058 = 2.27. For doc 5 it's 0.25 + 0.75 × 10 / 6.5 = 1.404, giving 1 + 1.2 × 1.404 = 2.68. The numerators are identical, so doc 1's score is 2.68 / 2.27 = 1.18 times doc 5's, and indeed 2.444 / 2.066 = 1.18.
  4. The intuition: a long document that mentions "wireless" once is less likely to be about wireless than a short one that does. b = 0.75 sets how strongly length counts (0 ignores length, 1 fully normalises), and k1 = 1.2 sets how quickly repeated terms stop adding score. Both are the defaults in Elasticsearch's BM25 similarity (Elasticsearch reference, "Similarity settings", 2026).
AdvancedSearch as part of a system

The inverted index is almost never the source of truth. It's a derived, rebuildable copy of data that lives in the main database, shaped for one kind of question:

App writes, searches Primary DB source of truth CDC / outbox change stream Indexer Search cluster refresh ≈ 1 s shard 1 shard 2 shard 3 search: every shard top-k merge
The database stays the source of truth; the search index is a derived copy fed by a change stream and rebuilt when its mapping changes.
  • Feeding it: changes flow from the database through change data capture or a transactional outbox to an indexer that writes search documents. Avoid "the service writes to the database and to the search index": if the second write fails, the two disagree silently and nothing repairs it.
  • Freshness: Elasticsearch and OpenSearch are near-real-time: new documents become searchable at the next refresh, by default every second for indices that have received a search in the last 30 seconds (Elasticsearch reference, "Near real-time search"). A user who adds a product and searches for it within a second may not find it; read it back by ID instead.
  • Partitioning: a search index is document-partitioned: each shard holds a full inverted index of its own documents, so every query goes to every shard and the coordinator merges each shard's top-k. That's the local-index trade-off from Secondary indexes in a sharded system: writes stay local, queries fan out, and the tail latency grows with the shard count. Size shards so the count stays modest, and add replicas for query throughput.
  • Rebuilding: changing the analyzer or a field mapping means reindexing. Build a new index from the source of truth in the background, then switch an alias from the old index to the new one atomically.
  • Results: the search returns IDs, scores and highlighted snippets. Prices and stock should come from the database or a cache, not from a copy that may be a second stale.

Internally, the index is written in immutable segments (Lucene's design, underneath both Elasticsearch and OpenSearch): new documents go into a small new segment, deletes are recorded as markers, and background merges combine segments and drop deleted documents. That's why indexing is fast and searches see changes only after a refresh, and why heavy update traffic on a search index costs more than it looks.

#Time-series, wide-column and graph stores

Added

Three kinds of data have a shape so particular that a store built around it beats a general-purpose database by a wide margin. Time-series data is measurements stamped with a time: metrics, sensor readings, prices. It's appended, almost never updated, read as aggregates over time ranges, and the recent past is far hotter than the old. Wide-column data is a huge number of rows grouped under a partition key and sorted within it, written at very high rates across many machines: messages per conversation, events per device, activity per user. Graph data is entities whose relationships are the point: who follows whom, which accounts share a device, which permissions inherit from which.

The mental model: each store bakes one access pattern into its storage layout. A time-series store lays data out by series and time, compresses neighbouring points against each other, and throws old detail away on a schedule. A wide-column store lays rows out by partition key and keeps them sorted, so "this partition, this range" is one sequential read anywhere in a cluster of hundreds of nodes. A graph store keeps each node's edges next to the node, so following an edge is a pointer hop rather than an index lookup.

StoreData shapeGood atBad atExamples
Time-series(series, timestamp) → value, append-onlyhigh ingest, compression, range aggregates, retention and downsamplingupdates, joins, high-cardinality labels (millions of series per metric)Prometheus, InfluxDB, TimescaleDB, VictoriaMetrics; ClickHouse for events
Wide-columnpartition key → rows sorted by clustering keymassive write rates, linear scale-out, multi-datacentre replicationqueries outside the key design, joins, ad hoc analyticsCassandra, ScyllaDB, HBase, Bigtable; DynamoDB is similar
Graphnodes and edges with propertiesmulti-hop traversals, path and pattern queriesaggregates over everything, very high write rates, sharding (edges cross partitions)Neo4j, Amazon Neptune, JanusGraph

Time-series first, because its most important design decision, downsampling, has a trap in it. Raw points are kept briefly; older data is kept as rollups (one point per minute or hour). What you store in each rollup decides which questions you can still answer:

rollups.mjs
// Downsampling a latency metric: raw request latencies -> 1-minute rollups -> 1-hour rollup.
// Seeded PRNG (mulberry32). Traffic is uneven: a busy minute has 50x the requests of a quiet one.
const mulberry32 = (a) => () => {
  a = (a + 0x6d2b79f5) | 0;
  let t = Math.imul(a ^ (a >>> 15), 1 | a);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const rand = mulberry32(3);

// 60 minutes. Minutes 20-29 are a traffic spike (5,000 requests each, slow: 120-400 ms);
// the other minutes are quiet (100 requests each, fast: 20-60 ms).
const raw = [];
for (let m = 0; m < 60; m++) {
  const spike = m >= 20 && m < 30;
  for (let i = 0; i < (spike ? 5_000 : 100); i++) raw.push({ m, ms: spike ? 120 + rand() * 280 : 20 + rand() * 40 });
}

const p99 = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(0.99 * (xs.length - 1))];

// 1-minute rollups keep count, sum, min, max (all mergeable) and that minute's p99 (not mergeable).
const minutes = Array.from({ length: 60 }, (_, m) => {
  const xs = raw.filter((r) => r.m === m).map((r) => r.ms);
  return { count: xs.length, sum: xs.reduce((a, b) => a + b, 0), min: Math.min(...xs), max: Math.max(...xs), p99: p99(xs), avg: 0 };
});
for (const r of minutes) r.avg = r.sum / r.count;

const all = raw.map((r) => r.ms);
const trueAvg = all.reduce((a, b) => a + b, 0) / all.length;
const avgOfAvgs = minutes.reduce((a, r) => a + r.avg, 0) / 60;
const fromSumCount = minutes.reduce((a, r) => a + r.sum, 0) / minutes.reduce((a, r) => a + r.count, 0);
const avgOfP99s = minutes.reduce((a, r) => a + r.p99, 0) / 60;
const maxOfP99s = Math.max(...minutes.map((r) => r.p99));

console.log(`requests in the hour: ${all.length}`);
console.log(`hour average   true ${trueAvg.toFixed(1)} ms | sum/count of rollups ${fromSumCount.toFixed(1)} ms | avg of minute avgs ${avgOfAvgs.toFixed(1)} ms`);
console.log(`hour p99       true ${p99(all).toFixed(1)} ms | avg of minute p99s ${avgOfP99s.toFixed(1)} ms | max of minute p99s ${maxOfP99s.toFixed(1)} ms`);
console.log(`hour min/max   ${Math.min(...minutes.map((r) => r.min)).toFixed(1)} / ${Math.max(...minutes.map((r) => r.max)).toFixed(1)} ms (exact from rollups)`);
Result
requests in the hour: 55000
hour average   true 240.3 ms | sum/count of rollups 240.3 ms | avg of minute avgs 76.7 ms
hour p99       true 397.0 ms | avg of minute p99s 115.5 ms | max of minute p99s 398.0 ms
hour min/max   20.0 / 400.0 ms (exact from rollups)
What’s happening
  1. The seeded hour has 50 quiet minutes with 100 fast requests each (20–60 ms) and a 10-minute spike, minutes 20–29, with 5,000 slow requests each (120–400 ms): 55,000 requests in total, most of them in the spike.
  2. Each 1-minute rollup stores count, sum, min and max, which are mergeable: the hour's count is the sum of counts, the hour's sum is the sum of sums, min of mins, max of maxes. So the hour average rebuilt as sum / count is 240.3 ms, exactly the true value, and the hour's min/max (20.0 / 400.0 ms) are exact too.
  3. Averaging the 60 minute averages gives 76.7 ms, wrong by a factor of three. It weights a quiet minute of 100 requests the same as a spike minute of 5,000, so the spike that users actually felt nearly disappears. Dashboards that "average the averages" across time or across hosts make exactly this mistake.
  4. Percentiles are worse: they can't be merged at all. The true hour p99 is 397.0 ms; the average of minute p99s is 115.5 ms, and even the max of minute p99s (398.0 ms) is only close here by luck. To keep percentiles through rollups, store a mergeable summary instead of the percentile: a histogram with fixed buckets (Prometheus histograms), or a sketch such as t-digest or HDR histogram, which can be added together and queried for any percentile later.
  5. The design rule: decide which questions old data must answer before choosing what a rollup keeps. Count, sum, min, max and a histogram cover almost every dashboard; a stored p99 per minute covers none of them over longer ranges.
AdvancedSizing a metrics store

How much do retention tiers save? An estimate for a mid-sized fleet:

metrics-storage.mjs
// Metrics platform estimate: raw samples with short retention, rollups kept longer.
// Prometheus documents 1-2 bytes per compressed sample; use 2 to be safe. A rollup point keeps
// count, sum, min and max (4 values); assume each compresses like a raw sample (2 bytes).
const hosts = 10_000, seriesPerHost = 500, scrapeSec = 15;
const series = hosts * seriesPerHost;
const samplesPerSec = series / scrapeSec;
const DAY = 86_400;

const tiers = [
  { name: "raw (15 s)", stepSec: scrapeSec, keepDays: 15, bytesPerPoint: 2 },
  { name: "1-min rollup", stepSec: 60, keepDays: 90, bytesPerPoint: 4 * 2 },
  { name: "1-hour rollup", stepSec: 3_600, keepDays: 2 * 365, bytesPerPoint: 4 * 2 },
];

console.log(`${(series / 1e6).toFixed(1)}M active series, ${Math.round(samplesPerSec).toLocaleString("en-US")} samples/s ingested`);
let total = 0;
for (const t of tiers) {
  const points = series * ((t.keepDays * DAY) / t.stepSec);
  const tb = (points * t.bytesPerPoint) / 1e12;
  total += tb;
  console.log(`${t.name.padEnd(14)} kept ${String(t.keepDays).padStart(3)} d: ${tb.toFixed(1).padStart(6)} TB`);
}
const rawForever = (series * ((2 * 365 * DAY) / scrapeSec) * 2) / 1e12;
console.log(`all tiers ${total.toFixed(1)} TB vs raw kept 2 years ${rawForever.toFixed(1)} TB`);
Result
5.0M active series, 333,333 samples/s ingested
raw (15 s)     kept  15 d:    0.9 TB
1-min rollup   kept  90 d:    5.2 TB
1-hour rollup  kept 730 d:    0.7 TB
all tiers 6.7 TB vs raw kept 2 years 42.0 TB
What’s happening
  1. 10,000 hosts × 500 series each is 5.0M active series, scraped every 15 s, so 333,333 samples/s are ingested. Each label combination (host, endpoint, status) is its own series, so cardinality, not request volume, is what sizes a metrics system.
  2. Raw samples cost 2 bytes each, the top of the 1–2 bytes per sample the Prometheus documentation gives for its compressed storage. That compression comes from the Gorilla techniques: timestamps stored as delta-of-deltas (almost always 0 for a regular scrape) and values XOR-ed with the previous value; Facebook's Gorilla paper (VLDB 2015) reports an average of 1.37 bytes per point. Fifteen days of raw data (Prometheus's default retention) is 0.9 TB.
  3. One-minute rollups keep count, sum, min and max (4 values at an assumed 2 bytes each) for 90 days: 5.2 TB, the largest tier, because there are many points and each holds four values. One-hour rollups for two years are only 0.7 TB.
  4. All tiers together are 6.7 TB, against 42.0 TB for keeping raw samples for two years: about six times less, and queries over months read hourly points instead of millions of raw ones. The 1-minute tier's retention is the main cost knob here.
AdvancedWide-column: bounding a partition

A wide-column store is fast because a partition (all rows with one partition key) lives together on a few replicas, sorted by the clustering key. The flip side is that a partition must not grow without bound: a huge partition is slow to compact, repair and read, and it's always on the same few machines. Discord's message store is the textbook example: messages are partitioned by channel plus a static time bucket and sorted by Snowflake message ID. In 2022 Discord moved those trillions of messages from 177 Cassandra nodes to 72 ScyllaDB nodes, and p99 latency for fetching historical messages went from 40–125 ms to 15 ms (Discord engineering blog, "How Discord Stores Trillions of Messages", 2023). The bucket size is the design decision:

partition-size.mjs
// Wide-column chat messages: PRIMARY KEY ((channel_id, bucket), message_id).
// How big does one partition get, and which time bucket keeps the busiest channel under the
// common Cassandra guideline of ~100 MB and ~100,000 rows per partition (a rule of thumb, not a hard limit)?
const MB = 1e6;
const rowBytes = 250; // message text, author, ids, plus per-row overhead (assumption)
const channels = [
  { name: "family group", msgsPerDay: 40 },
  { name: "busy team channel", msgsPerDay: 5_000 },
  { name: "huge server #general", msgsPerDay: 400_000 },
];
const GUIDE = { bytes: 100 * MB, rows: 100_000 };

const BUCKETS = [["3 years", 3 * 365], ["10 days", 10], ["1 day", 1], ["1 hour", 1 / 24]];
const fmtRows = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(0)}k` : String(n));

console.log(`bucket ->${"".padEnd(20)}${BUCKETS.map(([label]) => label.padStart(11)).join("")}`);
for (const c of channels) {
  const cells = BUCKETS.map(([, days]) => {
    const rows = Math.round(c.msgsPerDay * days);
    const ok = rows <= GUIDE.rows && rows * rowBytes <= GUIDE.bytes;
    return `${fmtRows(rows)} ${ok ? "ok " : "BIG"}`.padStart(11);
  });
  console.log(`${c.name.padEnd(29)}${cells.join("")}`);
}
console.log(`(at ${rowBytes} bytes a row, ${GUIDE.rows.toLocaleString("en-US")} rows is ${(GUIDE.rows * rowBytes) / MB} MB, so the row guideline binds first)`);

// One bucket size for every channel: the largest bucket that keeps the busiest channel within the guideline.
const busiest = Math.max(...channels.map((c) => c.msgsPerDay));
const maxDays = Math.min(GUIDE.rows / busiest, GUIDE.bytes / rowBytes / busiest);
console.log(`largest safe bucket for the busiest channel: ${(maxDays * 24).toFixed(1)} hours`);
// Cost of small buckets: reading the last 50 messages of a quiet channel walks back through empty buckets.
const quiet = channels[0];
console.log(`with 1-hour buckets, '${quiet.name}' has ~${(quiet.msgsPerDay / 24).toFixed(1)} messages per bucket: ` +
  `about ${Math.ceil(50 / (quiet.msgsPerDay / 24))} bucket reads to fill one screen of 50`);
Result
bucket ->                        3 years    10 days      1 day     1 hour
family group                     44k ok     400 ok      40 ok       2 ok
busy team channel               5.5M BIG    50k ok      5k ok     208 ok
huge server #general          438.0M BIG   4.0M BIG   400k BIG    17k ok
(at 250 bytes a row, 100,000 rows is 25 MB, so the row guideline binds first)
largest safe bucket for the busiest channel: 6.0 hours
with 1-hour buckets, 'family group' has ~1.7 messages per bucket: about 30 bucket reads to fill one screen of 50
What’s happening
  1. The primary key ((channel_id, bucket), message_id) means the partition is one channel's messages in one time bucket, sorted by message ID. The guideline is the long-standing Cassandra rule of thumb from DataStax's documentation: keep partitions under about 100 MB and 100,000 rows. Newer Cassandra versions tolerate more, but large partitions still make compaction and repair slow.
  2. No bucket (one partition per channel for 3 years): the family group's 44k rows are fine, but the busy team channel reaches 5.5M rows and the huge server's #general 438M: an unbounded partition on a fixed set of replicas.
  3. 10-day buckets fix the team channel (50k) but #general still produces 4.0M rows per bucket; 1-day buckets still give 400k. Only 1-hour buckets keep the busiest channel under the guideline (17k). At 250 bytes a row the row limit binds first (100,000 rows is 25 MB).
  4. One bucket size for everyone must be set by the busiest channel: at most 6.0 hours. The cost lands on quiet channels: with 1-hour buckets, the family group has about 1.7 messages per bucket, so loading one screen of 50 messages walks back through about 30 partitions, mostly empty.
  5. Ways out: choose the bucket per channel based on its message rate (stored with the channel), or use a coarse bucket and split only the few huge channels. Either way the read path must know how to compute the buckets to read, which is the price of bounding partitions.
AdvancedGraph: why hops explode

Graph queries are about paths: friends of friends, accounts that share a card with a known fraudster, who can reach this document through group memberships. The cost of a traversal is the number of edges it follows, and on social-style graphs that number explodes after two hops:

friends-of-friends.mjs
// How much work is "people within k hops"? A seeded random social graph (mulberry32):
// 100,000 users, each with ~150 friends, mostly within their own community of 1,000 people.
const mulberry32 = (a) => () => {
  a = (a + 0x6d2b79f5) | 0;
  let t = Math.imul(a ^ (a >>> 15), 1 | a);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const rand = mulberry32(8);
const USERS = 100_000, COMMUNITY = 1_000, FRIENDS = 75; // each adds 75 edges, so the average degree is ~150

const adj = Array.from({ length: USERS }, () => new Set());
for (let u = 0; u < USERS; u++) {
  const base = u - (u % COMMUNITY);
  for (let i = 0; i < FRIENDS; i++) {
    // 90% of friendships stay inside the community, 10% reach anywhere
    const v = rand() < 0.9 ? base + Math.floor(rand() * COMMUNITY) : Math.floor(rand() * USERS);
    if (v !== u) adj[u].add(v), adj[v].add(u);
  }
}

// Breadth-first search, counting edges followed: in a graph store each one is a pointer hop;
// in a relational store each one is an index lookup in a friendships(user_id, friend_id) table.
function within(start, hops) {
  const seen = new Set([start]);
  let frontier = [start], edges = 0;
  for (let h = 1; h <= hops; h++) {
    const next = [];
    for (const u of frontier)
      for (const v of adj[u]) {
        edges++;
        if (!seen.has(v)) seen.add(v), next.push(v);
      }
    frontier = next;
    console.log(`  ${h} hop${h > 1 ? "s" : " "}: ${String(seen.size - 1).padStart(6)} people reached, ${String(edges).padStart(9)} edges followed`);
  }
}
const degrees = adj.map((s) => s.size);
console.log(`average friends: ${(degrees.reduce((a, b) => a + b, 0) / USERS).toFixed(0)}`);
within(42, 4);
Result
average friends: 141
  1 hop :    142 people reached,       142 edges followed
  2 hops:   4622 people reached,     20262 edges followed
  3 hops:  97219 people reached,    654700 edges followed
  4 hops:  99999 people reached,  13733322 edges followed
What’s happening
  1. The seeded graph has 100,000 users in communities of 1,000; each user adds 75 friendships, 90% inside their community and 10% anywhere, giving 141 friends on average. Real social graphs have this clustered-with-shortcuts shape.
  2. 1 hop: user 42's 142 friends, 142 edges followed. In a relational store that's one index range scan on friendships(user_id, friend_id); in a graph store, a walk along one node's adjacency list.
  3. 2 hops: 4,622 people (mostly the user's own community), but 20,262 edges followed. Friends-of-friends is still interactive in either kind of store, and it's how "people you may know" starts.
  4. 3 hops reach 97,219 people, 97% of the whole graph, after following 654,700 edges; 4 hops reach everyone and follow 13.7M edges. Past two hops the question "who's connected to me?" stops being selective: the answer is everyone, and the cost is the whole graph.
  5. A graph database makes each hop cheap (edges are stored with the node, so no index lookup per step), which matters for 3–6 hop path queries with selective filters (fraud rings that share a device and an address, permission checks through nested groups). It doesn't change the fan-out. For a feature like "people you may know" at scale, precompute candidates offline in batch, and use a graph store when the queries are genuinely about paths and patterns.

Written fresh for interviews (there was no Google Doc for this one). Runnable models were run on Node 26; figures and limits were checked against sources in October 2026.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.