System Design NotesRohit’s interview study guide
Chapter 11

Case Studies: Social and Real-Time

Five designs where many people read and write the same things at once — a news feed, chat, search autocomplete, likes and comments, and a leaderboard — each worked from requirements to trade-offs, with the numbers that decide it computed in code.

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

#Design a news feed

Added

A news feed (a "home timeline") shows you the recent posts of everyone you follow, newest or best first, the moment you open the app. Storing posts is the easy part: a post is written once and kept forever. The hard part is assembling a different list for every reader, from hundreds of sources each, billions of times a day, in a couple of hundred milliseconds. Every design decision in this case study is about when that assembly happens: when the post is written, or when the feed is read.

A good mental model is a post office. With fan-out on write (push), when someone posts, the post office puts a slip with the post's ID into the mailbox of every follower; reading your feed is just emptying your own mailbox. With fan-out on read (pull), nothing is delivered; every time you want news, you walk to the house of each person you follow and read their noticeboard, then sort what you found. Push makes reads cheap and writes expensive; pull is the opposite. Celebrities break the post office: one letter from an account with 100 million followers means 100 million mailbox slips. So real feeds are a hybrid — deliver to mailboxes for ordinary accounts, and let readers go and look at the handful of celebrity noticeboards themselves.

Fan-out on write (push) Author posts Follower 1 inbox Follower 2 inbox … N inboxes 1 post = N writes · 1 read = 1 list Fan-out on read (pull) Reader loads Followee 1 posts Followee 2 posts … M post lists 1 post = 1 write · 1 read = M lists merged
Push pays at write time, once per follower. Pull pays at read time, once per account followed.

The whole case study turns on one measurement: how much work each approach does on a realistic follow graph, where most accounts have a few dozen followers and a few have nearly everyone. In the interview you'd reach this in the deep dive, but it's first here because every later section leans on its numbers. The model builds a seeded follow graph of 20,000 users (a scaled-down social network: follower counts drawn from a heavy-tailed Pareto distribution, so a few accounts are vastly more popular), then counts the work for pure push, pure pull, and the hybrid at three celebrity thresholds.

fanout.mjs
// Fan-out on write vs on read vs hybrid, on a seeded follow graph with celebrities.
function mulberry32(a) {
  return () => {
    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 USERS = 20_000;
const pareto = (alpha) => 1 / Math.pow(1 - rand(), 1 / alpha); // heavy tail, minimum 1

// Popularity decides who gets followed: a few accounts are vastly more popular than the rest.
const cumulative = [];
let total = 0;
for (let i = 0; i < USERS; i++) cumulative.push((total += pareto(1.5)));
const pickByPopularity = () => {
  const x = rand() * total;
  let lo = 0, hi = USERS - 1;
  while (lo < hi) { const mid = (lo + hi) >> 1; if (cumulative[mid] < x) lo = mid + 1; else hi = mid; }
  return lo;
};
const following = Array.from({ length: USERS }, (_, u) => {
  const want = Math.min(1000, Math.floor(20 * pareto(2))); // most follow 20-60 accounts, a few 1,000
  const set = new Set();
  while (set.size < want) { const v = pickByPopularity(); if (v !== u) set.add(v); }
  return [...set];
});
const followers = new Array(USERS).fill(0);
for (const list of following) for (const v of list) followers[v]++;
const edges = followers.reduce((a, b) => a + b, 0);
const ranked = [...followers].sort((a, b) => b - a);
console.log(`${USERS} users, ${edges} follows; median followers ${ranked[USERS / 2]}, top 3: ${ranked.slice(0, 3).join(", ")}`);

// Every user posts once and loads the feed 10 times a day. Authors with >= T followers are "celebrities":
// their posts are not pushed; readers pull them at read time. T = Infinity is pure push, T = 0 pure pull.
function design(T) {
  const celeb = followers.map((f) => f >= T);
  let pushWrites = 0, worstPost = 0, pulledPerReader = 0, worstRead = 0;
  followers.forEach((f, v) => { if (!celeb[v]) { pushWrites += f; worstPost = Math.max(worstPost, f); } });
  for (const list of following) {
    const pulled = list.filter((v) => celeb[v]).length + (T > 0 ? 1 : 0); // + own inbox
    pulledPerReader += pulled;
    worstRead = Math.max(worstRead, pulled);
  }
  const celebs = celeb.filter(Boolean).length;
  return { celebs, pushWrites, worstPost, readsPerLoad: pulledPerReader / USERS, worstRead };
}
console.log("design              celebs  inbox writes/day  worst post  lists read/load  worst load");
for (const [name, T] of [["push (on write)", Infinity], ["hybrid T=5,000", 5000], ["hybrid T=1,000", 1000], ["hybrid T=200", 200], ["pull (on read)", 0]]) {
  const d = design(T);
  console.log(`${name.padEnd(18)}${String(d.celebs).padStart(7)}${String(d.pushWrites).padStart(18)}${String(d.worstPost).padStart(12)}${d.readsPerLoad.toFixed(2).padStart(17)}${String(d.worstRead).padStart(12)}`);
}
Result
20000 users, 791735 follows; median followers 21, top 3: 17310, 12569, 8907
design              celebs  inbox writes/day  worst post  lists read/load  worst load
push (on write)         0            791735       17310             1.00           1
hybrid T=5,000          6            735280        3326             3.82           7
hybrid T=1,000         40            676633         979             6.76          39
hybrid T=200          348            565647         199            12.30         186
pull (on read)      20000                 0           0            39.59        1000
What’s happening
  1. The graph is built first. Each user gets a popularity weight from pareto(1.5), and each user follows 20 to 1,000 accounts picked in proportion to popularity (a binary search over the running total of weights). The result is the shape real networks have: the median account has 21 followers, but the top three have 17,310, 12,569 and 8,907 out of 20,000 users. The seeded PRNG (mulberry32(42)) makes it the same graph every run.
  2. design(T) treats every author with at least T followers as a celebrity. A normal author's post is pushed: one inbox insert per follower, so pushWrites is the sum of normal authors' follower counts and worstPost is the biggest single fan-out. A reader pulls one list per celebrity they follow, plus their own inbox.
  3. Pure push (T = Infinity): 791,735 inbox inserts for 20,000 posts — an average write amplification of about 40, the mean follower count. Reads are perfect: 1 list per feed load. But the worst post fans out to 17,310 inboxes, and that one post is where push hurts: at real scale it's 100 million writes for one tweet.
  4. Pure pull (T = 0): no inbox writes at all, but every feed load merges 39.59 lists on average and 1,000 for the heaviest followers. Feeds are read about ten times as often as they're written, so this multiplies the expensive operation.
  5. Hybrid, T = 1,000: just 40 accounts (0.2% of users) become celebrities. Total inbox writes drop by 15% (791,735 → 676,633), but the worst single post drops 18× (17,310 → 979), because the celebrities are exactly the accounts with the giant fan-outs. Readers now merge 6.76 lists on average (their inbox plus the celebrities they follow) and 39 at worst.
  6. Lowering the threshold to 200 keeps cutting writes, but read cost climbs (12.30 lists per load) for diminishing write savings. That curve is the trade-off you choose a point on: make the worst write small enough to meet the freshness target, and stop before reads get expensive.
  7. What it demonstrates: write amplification and read cost aren't fixed properties of push and pull; they depend on the follower distribution. The heavy tail is why a hybrid wins — a tiny set of accounts causes the worst write cost, and readers follow only a handful of them.
AdvancedFeed requirements and scope

Requirements

Ask before designing. A reasonable set for "design the Twitter/Instagram home feed":

Functional

  • A user can publish a post (text, plus optional images or video uploaded separately).
  • A user can follow and unfollow accounts.
  • The home feed shows posts from followed accounts, newest first in version 1, with ranking as an extension; it pages with infinite scroll.
  • An author sees their own new post in their feed immediately.
  • Out of scope unless asked: ads, search, notifications (see Design a notification system), direct messages, comments and likes (a separate case study below: Design a comments and likes system).

Non-functional

  • Scale: 300 million daily active users (the assumption the estimates use).
  • Feed load latency: p99 under 300 ms at the API, which is a target we set, not a standard.
  • Freshness: a new post reaches followers' feeds within about 5 seconds normally. Twitter described the same goal in 2013 ("under 5 seconds, but it doesn't always work", High Scalability's write-up of Raffi Krikorian's Timelines at Scale talk).
  • Availability over consistency: a feed that's a few seconds stale is fine; a feed that fails to load is not. The exception is read-your-own-writes for the author.
  • Durability: posts are never lost. The feed itself is derived data — it can be rebuilt from posts and follows — so losing a cached feed is an inconvenience, not data loss.
AdvancedFeed estimates: QPS, fan-out, cache size

Estimates

The assumptions: 300 M DAU, each posting 0.5 times and loading the feed 10 times a day, a mean of 200 followers per author (the mean, which celebrities pull well above the median), a 3× peak, 1 KB per post without media, and home timelines capped at 800 post IDs of 16 bytes each.

feed-estimates.mjs
// News feed: assumptions in, QPS, storage and cache size out.
const DAU = 300e6;
const postsPerUserPerDay = 0.5;
const feedLoadsPerUserPerDay = 10;
const meanFollowers = 200;          // mean, not median: celebrities pull it up
const peakFactor = 3;
const postBytes = 1_000;            // text + metadata; media lives in object storage
const inboxEntries = 800;           // post IDs kept per home timeline
const entryBytes = 16;              // 8-byte post ID + 8-byte score/timestamp
const S = 86_400;

const posts = DAU * postsPerUserPerDay;
const loads = DAU * feedLoadsPerUserPerDay;
const fmt = (n) => (n >= 1e9 ? (n / 1e9).toFixed(1) + " B" : n >= 1e6 ? (n / 1e6).toFixed(1) + " M" : n >= 1e3 ? (n / 1e3).toFixed(1) + " k" : n.toFixed(0));
console.log(`posts:            ${fmt(posts)}/day = ${fmt(posts / S)}/s avg, ${fmt((posts / S) * peakFactor)}/s peak`);
console.log(`feed loads:       ${fmt(loads)}/day = ${fmt(loads / S)}/s avg, ${fmt((loads / S) * peakFactor)}/s peak`);
console.log(`push fan-out:     ${fmt(posts * meanFollowers)} inbox inserts/day = ${fmt((posts * meanFollowers) / S)}/s avg`);
console.log(`pull read cost:   ${fmt(loads * meanFollowers)} list reads/day if every load merges every followee`);
console.log(`post storage:     ${(posts * postBytes / 1e9).toFixed(0)} GB/day, ${(posts * postBytes * 365 / 1e12).toFixed(0)} TB/year (before replication)`);
const cacheBytes = DAU * inboxEntries * entryBytes;
console.log(`timeline cache:   ${(inboxEntries * entryBytes / 1e3).toFixed(1)} KB per user, ${(cacheBytes / 1e12).toFixed(2)} TB for all DAU`);
console.log(`cache nodes:      ${Math.ceil((cacheBytes * 3) / 64e9)} at 64 GB usable each, with 3 replicas`);
Result
posts:            150.0 M/day = 1.7 k/s avg, 5.2 k/s peak
feed loads:       3.0 B/day = 34.7 k/s avg, 104.2 k/s peak
push fan-out:     30.0 B inbox inserts/day = 347.2 k/s avg
pull read cost:   600.0 B list reads/day if every load merges every followee
post storage:     150 GB/day, 55 TB/year (before replication)
timeline cache:   12.8 KB per user, 3.84 TB for all DAU
cache nodes:      180 at 64 GB usable each, with 3 replicas
What’s happening
  1. Posts: 300 M × 0.5 = 150 M a day, which is only 1.7 k/s on average and 5.2 k/s at peak. Writing posts is not the problem.
  2. Feed loads: 300 M × 10 = 3 B a day, 34.7 k/s average and 104.2 k/s at peak. Reads outnumber posts 20 to 1, which is the first argument for doing work at write time.
  3. Push fan-out: every post becomes 200 inbox inserts, so 30 B inserts a day, 347.2 k/s on average. That's the bill for push. For scale: Twitter reported around 300 K QPS for timeline reads and roughly 5 k tweets/s on average in 2013, with fan-out to a million followers taking about 3.5 s at p50 (High Scalability, 2013) — the same order of magnitude.
  4. Pull read cost: if every load merged every followee's list, that's 600 B list reads a day. That's 20× the push bill, which is why pure pull doesn't survive these numbers.
  5. Post storage is modest: 150 GB a day, 55 TB a year before replication. Media dominates real storage and lives in object storage behind a CDN (see Object storage for files and media).
  6. The timeline cache: 800 × 16 bytes = 12.8 KB per user, 3.84 TB for all daily users, so about 180 cache nodes at 64 GB usable each with 3 replicas. Storing IDs, not posts, is what keeps this affordable — 800 full posts per user would be 60× bigger.
AdvancedFeed API sketch

API

Output
POST   /v1/posts                        { text, mediaIds[] }        -> 201 { postId, createdAt }
       Idempotency-Key: <uuid>
GET    /v1/feed?cursor=<opaque>&limit=20                            -> 200 { items[], nextCursor }
       items[i] = { postId, author{id,name,avatarUrl}, text, media[], likeCount, likedByMe, createdAt }
PUT    /v1/users/{userId}/followers/me                             -> 204   (follow; repeat-safe)
DELETE /v1/users/{userId}/followers/me                             -> 204   (unfollow)

The cursor is opaque to the client: it encodes the position in the feed (the score and post ID of the last item returned), so new posts arriving at the top don't shift the page boundaries the way an offset would (see Pagination at the API level). Follow is a PUT on a sub-resource, so retrying it is harmless; posting takes an idempotency key so a retried request doesn't publish twice (see Idempotency keys for APIs). Each feed item comes back hydrated — author, counts and "did I like it" are filled in by the feed service, so the client makes one request per page.

AdvancedFeed data model and stores

Data model

DataKey → valueStoreWhy
Postspost_id → author, text, media keys, created_atSharded SQL or wide-column, sharded by post_idWritten once, read by ID; Snowflake IDs sort by time
Author's postsauthor_id → recent post_ids, newest firstWide-column (partition author_id)Pull for celebrities, rebuilding feeds, profile pages
Follows(follower_id, followee_id) and the reverse (followee_id, follower_id)Sharded SQL or a graph serviceFan-out needs "followers of X"; pull needs "who do I follow" — two directions, two indexes
Home timelinefeed:{user_id} → up to 800 (post_id, score)Redis sorted set or listDerived, rebuildable, in memory
Post cachepost_id → hydrated postRedis / MemcachedHydration reads; hot posts are hot keys

post_id is a Snowflake-style ID (timestamp in the high bits, see Unique ID generation), so sorting by ID is sorting by time and the feed cache can use it directly as the score in version 1. The follow graph is stored twice, once per direction, because the two queries partition differently: "followers of a celebrity" is one huge partition read by the fan-out workers; "accounts I follow" is a small read on every pull. This is modelling data for access patterns: duplicate the data so each access is one partition.

AdvancedFeed architecture: write and read paths

High-level design

Clients API gateway auth · limits Post service store, then emit Posts store by post_id Event log PostCreated Fan-out workers skip celebrities Social graph followers Feed cache 800 IDs per user Feed service merge · rank · hydrate Celebrity posts pulled at read Post cache + counts, authors post feed
Writes go through a log to the fan-out workers; reads touch only caches. The two paths share nothing but the caches.

Walk it as two paths, out loud:

  1. Write path. The client posts through the gateway. The post service stores the post (the durable part, done before replying), adds it to the author's own post list, and appends a PostCreated event to a log such as Kafka. It replies to the client straight away; fan-out is asynchronous, so a celebrity's post doesn't make their request take a minute. This is pub/sub and fan-out at work.
  2. Fan-out. Workers consume the log. For each post they check the author's follower count: below the threshold T, they page through the follower list from the social graph and insert (post_id, score) into each follower's feed cache, in pipelined batches (Twitter batched 4,000 destinations at a time in 2013). At or above T, they do nothing — the post already sits in the author's own list, which acts as the "celebrity posts" cache.
  3. Read path. The feed service reads the user's feed cache (one sorted-set read), and the recent posts of each celebrity they follow (6.76 lists on average at T = 1,000 in the model), merges them by score, optionally ranks them, then hydrates the page — post bodies, authors, like counts and "liked by me" — from caches, in batched multi-gets.
AdvancedDeep dive: celebrities and choosing the threshold

The threshold isn't a magic constant; derive it from the freshness requirement. If the fan-out tier can sustain a few million inbox inserts a second in total, and one post may use at most a slice of that, then a post must finish within the 5-second target: an author with 100 M followers needs 100 M inserts, which at even 1 M inserts/s is 100 s, far over budget. An author with 10 k followers is 10 ms of work. So T sits somewhere where T / (per-post insert rate) stays well inside the freshness target — in practice somewhere in the tens to hundreds of thousands of followers at real scale, and the model shows why the exact value matters less than you'd think: the celebrity set is tiny, so readers pay for only a few extra lists either way.

Details that come up in a follow-up question:

  • Inactive followers are most of a celebrity's audience at any moment. Twitter (2013) kept timelines in Redis only for users active in the last 30 days; anyone else gets their feed rebuilt on return. Skipping inactive users cuts push writes for everyone, not just celebrities.
  • Hysteresis: an account that hovers around T shouldn't flip between push and pull on every new follower, or readers will see duplicates (pushed once, then pulled). Mark accounts as celebrities above T and only revert well below it, and de-duplicate by post_id in the merge anyway.
  • The mirror problem: users who follow thousands of accounts are cheap under push (one inbox) but expensive under pull. The hybrid keeps them on push, which is another reason the default is push.
  • A celebrity's post is a hot key: millions of readers merge the same celebrity list and hydrate the same post. Replicate it into local caches and coalesce requests (see Hot keys and cache stampedes).
AdvancedDeep dive: ranking the merged feed

Deep dive: ranking

A chronological feed sorts by post_id. A ranked feed adds a scoring step between merge and hydrate, in three stages: candidate generation (the merged inbox and celebrity posts, plus out-of-network recommendations), scoring (a model predicts how likely you are to engage with each candidate), and filtering and mixing (remove blocked authors and already-seen posts, don't show five posts from one author in a row). At Twitter in March 2023, each "For You" request pulled about 1,500 candidates, roughly half in-network and half out-of-network, and scored them with a neural network of about 48 million parameters (Twitter's engineering blog post on its recommendation algorithm). In an interview you don't build the model; you show where it sits and what it costs. A hand-written score makes the trade-off visible:

rank.mjs
// A hand-written ranking score: engagement x affinity x time decay. Real feeds learn the weights.
const now = 100; // hours on a fake clock
const candidates = [
  { id: "p1", author: "close friend", hoursOld: 1, likes: 3, comments: 1, affinity: 0.9 },
  { id: "p2", author: "celebrity", hoursOld: 2, likes: 52_000, comments: 4_100, affinity: 0.05 },
  { id: "p3", author: "coworker", hoursOld: 0.2, likes: 0, comments: 0, affinity: 0.3 },
  { id: "p4", author: "close friend", hoursOld: 20, likes: 40, comments: 12, affinity: 0.9 },
  { id: "p5", author: "news page", hoursOld: 5, likes: 900, comments: 300, affinity: 0.2 },
  { id: "p6", author: "old classmate", hoursOld: 3, likes: 15, comments: 30, affinity: 0.1 },
];

const engagement = (p) => Math.log10(1 + p.likes + 4 * p.comments); // log: 52k likes isn't 17,000x better than 3
const decay = (p) => 1 / Math.pow(p.hoursOld + 2, 1.5);           // gravity: older posts sink
const score = (p) => (0.2 + engagement(p)) * (0.1 + p.affinity) * decay(p);

const chronological = [...candidates].sort((a, b) => a.hoursOld - b.hoursOld);
const ranked = [...candidates].sort((a, b) => score(b) - score(a));
console.log("chronological:", chronological.map((p) => p.id).join(" "));
console.log("ranked:       ", ranked.map((p) => p.id).join(" "));
for (const p of ranked) {
  console.log(`${p.id} ${p.author.padEnd(14)} posted at t=${(now - p.hoursOld).toFixed(1).padStart(5)}h  engagement ${engagement(p).toFixed(2)}  decay ${decay(p).toFixed(3)}  score ${score(p).toFixed(4)}`);
}
Result
chronological: p3 p1 p2 p6 p5 p4
ranked:        p1 p2 p5 p6 p3 p4
p1 close friend   posted at t= 99.0h  engagement 0.90  decay 0.192  score 0.2123
p2 celebrity      posted at t= 98.0h  engagement 4.84  decay 0.125  score 0.0944
p5 news page      posted at t= 95.0h  engagement 3.32  decay 0.054  score 0.0571
p6 old classmate  posted at t= 97.0h  engagement 2.13  decay 0.089  score 0.0417
p3 coworker       posted at t= 99.8h  engagement 0.00  decay 0.306  score 0.0245
p4 close friend   posted at t= 80.0h  engagement 1.95  decay 0.010  score 0.0208
What’s happening
  1. Six candidates arrive from the merge. Chronological order is just hoursOld ascending: p3 p1 p2 p6 p5 p4 — the coworker's 12-minute-old post with no engagement goes first.
  2. engagement is the log of likes + 4 × comments (comments count more, they take more effort). The log stops the celebrity's 52,000 likes from swamping everything: it scores 4.84 against the close friend's 0.90, about 5× rather than 17,000×.
  3. decay is a gravity term, 1 / (age + 2)^1.5 (the shape Hacker News popularised): a 1-hour-old post gets 0.192, a 20-hour-old one 0.010. The + 2 stops a brand-new post from getting an infinite boost.
  4. affinity is how much this reader interacts with this author. The close friend (0.9) wins the top slot with modest engagement; the celebrity (0.05) still comes second on sheer engagement; the coworker's fresh but empty post drops to fifth.
  5. The close friend's 20-hour-old post (p4) has more engagement than p1 but comes last: decay of 0.010 beats everything. Every weight here is a product decision, and a real system learns them from engagement data instead of hand-tuning.
  6. What it demonstrates: ranking reorders the merged candidates; it doesn't change where they come from. The architecture keeps the merge cheap so that the ranking stage has time to spend.
AdvancedDeep dive: cache misses, deletes and new follows

The feed cache is derived data, so every awkward case is solved by rebuilding or filtering at read time rather than by editing millions of inboxes:

  • Cache miss (a returning inactive user, or a lost cache node): fall back to pull once — read the recent posts of everyone they follow, merge, write the result into their feed cache — then let push take over again. That's slow (one load), so rate-limit rebuilds so a cache node failure doesn't trigger millions at once.
  • Deleted posts: don't chase the ID through 10 million inboxes. Mark the post deleted in the posts store; hydration drops it, and the inbox entry ages out of the 800-entry cap.
  • Unfollow: filter the unfollowed author at read time (the feed service already knows the follow list), and clean the inbox lazily.
  • New follow: backfill the followee's latest 20 or so posts into the follower's inbox, so the follow has a visible effect immediately.
  • Privacy changes (a post becomes followers-only, an account blocks you): check at hydration, every time. The inbox holds IDs, never permissions.
AdvancedFeed trade-offs: push, pull, hybrid

Trade-offs and what to say

Push (fan-out on write)Pull (fan-out on read)Hybrid
Cost per postone insert per follower (791,735 total in the model)one writepush for normal authors (676,633 at T = 1,000)
Cost per feed loadone listone list per followee (39.59 average)inbox + celebrities followed (6.76)
Worst casecelebrity post: 17,310 inserts, 100 M at real scaleheavy follower: 1,000 listsbounded on both sides (979 / 39)
Freshnessdelay grows with follower countalways freshfresh; celebrity posts are freshest
Memoryan inbox per active user (3.84 TB)none beyond post listsinbox per active user
Inactive userswasted writes unless skippedfreeskip them in push
AdvancedGotcha: fanning out inside the request
A 50 M-follower account posts, and your fan-out tier sustains 1 M inbox inserts per second in total. How long until the last follower sees it under pure push, and what does the hybrid change?
Show answer

Pure push: 50 seconds at best, with every other author's posts queued behind it. Under the hybrid: one write, and followers see it on their next load.

What’s happening
  1. Pure push needs one insert per follower: 50 M inserts.
  2. At 1 M inserts/s for the whole tier, that's 50 M / 1 M = 50 s — ten times the 5-second freshness target — and only if nothing else is posting. Other authors' fan-outs queue behind it, so everyone's freshness suffers.
  3. Under the hybrid, the author is above the threshold, so the post is written once (to the posts store and the author's list) and nothing is fanned out.
  4. Each follower's next feed load merges this author's recent posts with their inbox, so the post appears as soon as it's stored — fresher than push ever managed.
  5. The cost moved to reads: each of those followers' loads reads one more list. With readers following only a few celebrities each, that's a few extra cache reads per load instead of a 50-second write.
AdvancedClient side: the news feed UI

For the client side of this design — infinite scroll, virtualisation, the "new posts" banner and optimistic likes — see Design a news feed UI.

#Design a chat system

Added

A chat system (WhatsApp, Messenger, Slack, Discord) delivers messages between people in one-to-one and group conversations: instantly when the recipient is online, and reliably later when they're not, in the same order for everyone, with "sent", "delivered" and "read" ticks and an online indicator. The real-time part gets the attention, but the part interviewers probe is reliability under unreliable connections: phones drop off the network all the time, messages get retried, devices come back after a week, and the conversation must still look identical on every device.

The mental model that makes it simple: each conversation is a shared notebook with numbered pages. Only the server numbers pages, one after another — page 41, page 42 — so there is exactly one order. Every device keeps a bookmark: the last page number it has. Catching up after being offline is "give me every page after 41". Receipts are bookmarks reported back: "I've received up to page 42", "I've read up to page 40". There's no need to remember a status for every message for every member; two numbers per member say it all.

Conversation g1 #1 #2 #3 #4 #5 #6 next seq = 7 alice delivered 6 · read 6 bob delivered 6 · read 4 carol (offline) delivered 2 · read 2
One counter per conversation orders the messages; two numbers per member (upper bar: delivered up to, lower bar: read up to) are all the receipts.

The model at the heart of the design is a tiny chat server with exactly those pieces: a per-conversation sequence counter, deduplication of retried sends, live push to connected members, delivered and read watermarks per member, and sync by last-seen sequence for a device that was offline.

chat-seq.mjs
// Per-conversation sequence numbers, receipts as watermarks, and offline sync by last-seen sequence.
class ChatServer {
  convs = new Map();
  online = new Map(); // user -> function that pushes a message down their connection

  create(id, members) {
    this.convs.set(id, { members, seq: 0, log: [], byClientId: new Map(), delivered: new Map(), read: new Map() });
  }
  send(convId, from, clientMsgId, text) {
    const c = this.convs.get(convId);
    if (c.byClientId.has(clientMsgId)) return c.byClientId.get(clientMsgId); // a retry: same seq, no new message
    const msg = { seq: ++c.seq, from, text };
    c.log.push(msg);
    c.byClientId.set(clientMsgId, msg.seq);
    this.ack(convId, from, "read", msg.seq); // the sender has obviously seen their own message
    for (const m of c.members) if (m !== from && this.online.has(m)) this.online.get(m)(convId, msg);
    return msg.seq;
  }
  ack(convId, user, kind, upTo) { // one number per member per conversation, never a row per message
    const c = this.convs.get(convId);
    const kinds = kind === "read" ? ["delivered", "read"] : ["delivered"];
    for (const k of kinds) c[k].set(user, Math.max(c[k].get(user) ?? 0, upTo));
  }
  connect(user, lastSeen, onPush) { // lastSeen: { convId: seq } kept on the device
    this.online.set(user, onPush);
    const missed = {};
    for (const [id, c] of this.convs) if (c.members.includes(user)) missed[id] = c.log.filter((m) => m.seq > (lastSeen[id] ?? 0));
    return missed;
  }
  status(convId, seq) {
    const c = this.convs.get(convId);
    const others = c.members.filter((m) => m !== c.log[seq - 1].from);
    const count = (k) => others.filter((m) => (c[k].get(m) ?? 0) >= seq).length;
    return `seq ${seq}: delivered ${count("delivered")}/${others.length}, read ${count("read")}/${others.length}`;
  }
}

const server = new ChatServer();
server.create("g1", ["alice", "bob", "carol"]);
server.connect("alice", {}, () => {});
server.connect("bob", {}, (conv, m) => {
  console.log(`bob   <- ${conv} #${m.seq} ${m.from}: ${m.text}`);
  server.ack(conv, "bob", "delivered", m.seq);
});
// carol is offline: her phone last synced before anything was sent.

console.log("alice sends a-1 ->", server.send("g1", "alice", "a-1", "dinner at 7?"));
console.log("alice retries a-1 ->", server.send("g1", "alice", "a-1", "dinner at 7?"));
console.log("alice sends a-2 ->", server.send("g1", "alice", "a-2", "booking for 3"));
server.ack("g1", "bob", "read", 2);
console.log(server.status("g1", 1), "|", server.status("g1", 2));

const missed = server.connect("carol", { g1: 0 }, (conv, m) => console.log(`carol <- ${conv} #${m.seq} ${m.from}: ${m.text}`));
console.log("carol reconnects with g1:0, gets", missed.g1.map((m) => `#${m.seq} ${m.text}`).join(", "));
server.ack("g1", "carol", "delivered", missed.g1.at(-1).seq);
console.log(server.status("g1", 2));
server.ack("g1", "carol", "read", 2);
console.log(server.status("g1", 2));
console.log("bob sends b-1 ->", server.send("g1", "bob", "b-1", "I'm in"));
Result
bob   <- g1 #1 alice: dinner at 7?
alice sends a-1 -> 1
alice retries a-1 -> 1
bob   <- g1 #2 alice: booking for 3
alice sends a-2 -> 2
seq 1: delivered 1/2, read 1/2 | seq 2: delivered 1/2, read 1/2
carol reconnects with g1:0, gets #1 dinner at 7?, #2 booking for 3
seq 2: delivered 2/2, read 1/2
seq 2: delivered 2/2, read 2/2
carol <- g1 #3 bob: I'm in
bob sends b-1 -> 3
What’s happening
  1. create("g1", …) sets up the conversation with seq: 0, an empty log, a byClientId map for deduplication, and the two watermark maps. Alice and Bob connect; Carol doesn't, so she's not in online.
  2. Alice sends with client message ID a-1. send assigns seq 0 → 1, appends to the log, records a-1 → 1, marks Alice as having read her own message, and pushes to every online member except the sender. That push runs inside send, which is why bob <- g1 #1 prints before alice sends a-1 -> 1. Bob's handler acks delivered up to 1.
  3. Alice's app didn't see the reply (a network blip) and retries with the same a-1. byClientId already has it, so send returns 1 again without appending or pushing. The client generated the ID, so the retry is recognisable — this is what makes "at-least-once sending" display exactly once (see Idempotency and exactly-once).
  4. a-2 gets seq 2, Bob receives it and then reads up to 2. Status for both messages: delivered 1/2, read 1/2 — Bob has both, Carol has neither. Notice ack is Math.max: a late "delivered 1" can never move a watermark backwards.
  5. Carol's phone reconnects with its bookmark { g1: 0 }. connect returns every message with seq > 0: #1 and #2. She acks delivered up to 2, so seq 2 becomes delivered 2/2, read 1/2 (Bob read it, Carol hasn't yet); after she reads, read 2/2 — that's the "blue ticks".
  6. Bob sends b-1; it gets seq 3 and is pushed live to Carol, who is now online. From here her device keeps its bookmark at 3.
  7. What it demonstrates: the server-assigned sequence gives one order for everyone; the client ID makes retries safe; the bookmark makes offline sync a single range query; and watermarks make receipts two numbers per member instead of a row per message per member.
AdvancedChat requirements and scope

Requirements

Functional

  • One-to-one and group conversations; groups up to 1,024 members (the limit WhatsApp uses, per its help centre as cited in 2026 guides).
  • Send text; media (photos, video, files) is uploaded to object storage and the message carries a reference.
  • Real-time delivery to online recipients; offline recipients get a push notification and the messages on their next connection.
  • Multiple devices per user (phone, laptop), each in sync.
  • Sent / delivered / read receipts, typing indicators, online / last-seen presence.
  • Persistent history: scroll back through old messages.

Non-functional

  • Scale: 500 M DAU sending 40 messages each per day (the estimate's assumptions).
  • Latency: p99 under 500 ms from send to on-screen for an online recipient (a target we set).
  • Ordering: every member sees a conversation's messages in the same order.
  • No loss, no duplicates on screen: a message acknowledged to the sender is durable; retries never show twice.
  • Availability: the system degrades to "delivered later" rather than "lost".
  • Out of scope unless asked: end-to-end encryption internals and voice and video calls. With E2E the server forwards ciphertext, so routing and ordering don't change: sequence numbers, receipts and sync work on encrypted bodies. Three things do change. Multi-device means each message is encrypted separately for every device (WhatsApp's 2021 engineering post on multi-device calls this client fan-out, with Signal's Sender Keys for groups). A WhatsApp-style server keeps a message only until it's delivered (its privacy policy: delivered messages are deleted from its servers, undelivered ones are kept encrypted for up to 30 days), so history lives on the devices and in their backups, not in a server-side store. And server-side search goes away, because the server can't read what it would index.
AdvancedChat estimates: messages, connections, receipts

Estimates

Assumptions: 500 M DAU, 40 messages sent per user per day, a 3× peak, 300 bytes per stored text message (ciphertext, IDs, timestamps) with 3 replicas, 30% of DAU connected at the busiest moment, and 500 k connections per gateway server as a planning figure with headroom. The second half prices receipts for one busy 1,000-member group.

chat-estimates.mjs
// Chat: messages, storage, connections, and what receipts cost.
const DAU = 500e6;
const messagesSentPerUserPerDay = 40;
const peakFactor = 3;
const messageBytes = 300;           // ciphertext + IDs + timestamps, text only (media goes to object storage)
const replicas = 3;
const peakOnlineShare = 0.3;        // share of DAU holding a connection at the busiest moment
const connectionsPerGateway = 500_000; // a planning figure with headroom, not a record
const S = 86_400;

const msgs = DAU * messagesSentPerUserPerDay;
console.log(`messages:     ${(msgs / 1e9).toFixed(0)} B/day = ${(msgs / S / 1e3).toFixed(0)} k/s avg, ${(msgs / S * peakFactor / 1e3).toFixed(0)} k/s peak`);
const perDay = msgs * messageBytes * replicas;
console.log(`storage:      ${(perDay / 1e12).toFixed(0)} TB/day with ${replicas} replicas, ${(perDay * 365 / 1e15).toFixed(1)} PB/year`);
const conns = DAU * peakOnlineShare;
console.log(`connections:  ${(conns / 1e6).toFixed(0)} M open at peak -> ${Math.ceil(conns / connectionsPerGateway)} gateway servers`);

// Receipts in a 1,000-member group that gets 2,000 messages a day.
const members = 1000, groupMsgs = 2000, readSessionsPerMemberPerDay = 10;
const perMessageRows = groupMsgs * (members - 1) * 2;           // a delivered row and a read row per recipient
const watermarkWrites = members * readSessionsPerMemberPerDay * 2; // each session acks "up to seq N" once
console.log(`receipts:     per-message rows ${(perMessageRows / 1e6).toFixed(1)} M/day vs watermark updates ${(watermarkWrites / 1e3).toFixed(0)} k/day (${Math.round(perMessageRows / watermarkWrites)}x fewer)`);
console.log(`fan-out:      one message in that group = ${members - 1} deliveries; ${groupMsgs} messages = ${((groupMsgs * (members - 1)) / 1e6).toFixed(1)} M/day`);
Result
messages:     20 B/day = 231 k/s avg, 694 k/s peak
storage:      18 TB/day with 3 replicas, 6.6 PB/year
connections:  150 M open at peak -> 300 gateway servers
receipts:     per-message rows 4.0 M/day vs watermark updates 20 k/day (200x fewer)
fan-out:      one message in that group = 999 deliveries; 2000 messages = 2.0 M/day
What’s happening
  1. 500 M × 40 = 20 B messages a day: 231 k/s average, 694 k/s at peak. For comparison, WhatsApp said it delivered roughly 100 B messages a day in October 2020 (Mark Zuckerberg on the earnings call, reported by TechCrunch) — five times this design.
  2. Storage: 20 B × 300 bytes × 3 replicas = 18 TB a day, 6.6 PB a year. History is the expensive part of chat. That's why the message store must be a horizontally scalable, write-optimised store, and why old messages often move to cheaper storage tiers.
  3. Connections: 150 M simultaneous connections at peak, so 300 gateway servers at 500 k each. The record is much higher — WhatsApp's January 2012 blog post showed over 2 million TCP connections on one FreeBSD/Erlang server — but WhatsApp later stopped chasing connections per box to keep headroom for load growth (High Scalability, 2014). Plan for failover: when a gateway dies, its connections reconnect to the others.
  4. Receipts for the 1,000-member group with 2,000 messages a day: storing a delivered row and a read row per recipient per message is 4.0 M rows a day for one group. Watermarks — each member's device acks "up to seq N" once per reading session, ten sessions a day, for delivered and read — are 20 k updates. 200× fewer, and storage is two numbers per member, forever.
  5. Fan-out: one message in that group is 999 deliveries; the group's day is 2.0 M deliveries. That's the group equivalent of the news feed's write amplification, and the reason big groups get special handling in a deep dive below.
AdvancedChat API: WebSocket frames and HTTP

API

Messaging runs over a WebSocket per device (see Real-time options: polling, long polling, SSE, WebSockets); history and setup are plain HTTP.

Output
WebSocket frames (JSON or protobuf), client -> server
  { type: "send",    convId, clientMsgId, body, mediaRef? }
  { type: "ack",     convId, kind: "delivered" | "read", upToSeq }
  { type: "typing",  convId }
  { type: "sync",    since: { convId: lastSeq, ... } }        on (re)connect
WebSocket frames, server -> client
  { type: "sent",    convId, clientMsgId, seq, serverTs }     the sender's tick
  { type: "message", convId, seq, from, body, serverTs }
  { type: "receipt", convId, userId, kind, upToSeq }
  { type: "presence", userId, status, lastSeen }

HTTP
  POST /v1/conversations                         { memberIds[] }        -> 201 { convId }
  GET  /v1/conversations?cursor=                 (my conversations, by last activity)
  GET  /v1/conversations/{convId}/messages?beforeSeq=&limit=50          (scroll back)
  POST /v1/media                                 -> 201 { mediaRef, uploadUrl }  (presigned upload)

clientMsgId is generated on the device before the first attempt (a UUID), so every retry of the same message carries the same ID. The server answers with sent carrying the seq; the client replaces its optimistic local copy with the confirmed one. Scrolling back uses beforeSeq, a cursor that's naturally stable because sequences never change.

AdvancedChat data model and message store

Data model

DataKey → valueStore
Messagespartition (conv_id, bucket), clustering seq → from, body / ciphertext, media ref, server_tsWide-column (Cassandra / ScyllaDB style)
Conversationsconv_id → type (1:1 / group), title, last_seq, created_atSharded SQL or wide-column
Members(conv_id, user_id) → role, delivered_seq, read_seq, mutedSame shard as the conversation
My conversationsuser_id → (conv_id, last_activity, unread) sorted by activityWide-column or Redis per user
Sessionsuser_id → [(device_id, gateway_id)] with a TTLRedis
Devices(user_id, device_id) → push token, last syncSQL

The message table is partitioned by conversation and a time bucket, clustered by sequence: a conversation's messages are stored together and in order, so "everything after 41" is one sequential read, and the bucket stops a ten-year-old group from growing into one gigantic partition. That's exactly the layout Discord described in March 2023 ("How Discord Stores Trillions of Messages"): messages partitioned by channel plus a static time-window bucket, with Snowflake IDs. Moving that store from 177 Cassandra nodes to 72 ScyllaDB nodes took its p99 for fetching historical messages from 40–125 ms to 15 ms. See Time-series, wide-column and graph stores for why this shape suits append-heavy, range-read data.

AdvancedChat architecture: one message end to end

High-level design

Alice phone Bob laptop WS gateway 1 holds sockets WS gateway 2 holds sockets Chat service seq per conversation Pub/sub a channel per gateway Message store (conv, bucket) → seq Sessions + presence user → gateway, TTL Push service offline: APNs / FCM send
Gateways only hold connections; the chat service orders, stores and routes. Gateways register each connection in the session store, so the chat service knows where to send.

Follow one message from Alice to Bob:

  1. Alice's phone holds a WebSocket to gateway 1 (an L4 load balancer spread the connections; see Scaling WebSockets). When it connected, gateway 1 wrote alice → (phone, gw1) into the session store with a TTL that heartbeats refresh.
  2. The send frame goes to the chat service instance that owns this conversation. All sends for one conversation go to one owner, so the sequence counter has a single writer.
  3. The owner assigns seq, writes the message to the message store, and only then acks sent to Alice. Storing before acking is what "durable once acknowledged" means.
  4. It looks up the members' sessions: Bob is on gateway 2, so it publishes the message to gateway 2's pub/sub channel; gateway 2 writes it down Bob's socket. Bob's device acks delivered, which flows back the same way to Alice as a receipt.
  5. A member with no session gets a push notification through APNs or FCM, and the messages themselves on their next sync. The push carries a preview at most; the sync is the source of truth.
AdvancedDeep dive: ordering and the sequencer

Why a per-conversation counter, and not timestamps or global IDs?

  • Client timestamps are wrong by seconds or minutes on real phones, and can be set to anything. Ordering by them lets a skewed clock put a reply above the question.
  • Server timestamps from different chat servers disagree by clock skew too (see Time and ordering: Lamport and vector clocks), and two messages in the same millisecond still need a tie-break.
  • Snowflake IDs (Discord's choice) are roughly time-ordered and unique without coordination, which is great for storage keys. But they have gaps, so a client can't tell "I'm missing a message" from "nothing was sent".
  • A per-conversation sequence is dense: 1, 2, 3… So a device that has 5 and receives 7 knows 6 is missing and asks for it. That gap detection is what makes push-plus-sync reliable.

The price is a single writer per conversation. That's fine: even a busy group gets a few messages a second, nowhere near one machine's limit. Route by conv_id (consistent hashing, see Consistent hashing) to the owning chat-service instance, and keep the counter where the messages live — last_seq on the conversation row, incremented with a conditional write — so failover to a new owner continues from the stored value instead of a lost in-memory counter.

Dense numbering has one sharp edge. If the last_seq increment succeeds and the message write then fails (a timeout, a crash between the two writes), sequence 6 is used up but no message 6 exists: a permanent hole. A device that has 5 and receives 7 asks for 6, and if the server answers with an empty list, a device that trusts gap detection waits for 6 forever. Two ways out:

  • Make the sync response authoritative. The server answers "everything after 5" with what exists and the range it covers: "6 doesn't exist, here is 7, you're complete up to 7". The device moves its bookmark over the hole instead of waiting.
  • Write the sequence and the message as one conditional write, so a number is never used without its message: insert the message at seq = last + 1 only if that row doesn't exist yet, and retry with the next number on a clash. In a Cassandra-style store that conditional insert is a lightweight transaction (INSERT … IF NOT EXISTS), which runs Paxos among the replicas: four round trips in Cassandra's original implementation (DataStax's documentation), and still much costlier than a plain write with the faster Paxos v2 that Cassandra 4.1 added. At a few messages a second per conversation that can be affordable; it's the price of never having holes.

On the device: show the message immediately with a "sending" clock icon under its clientMsgId (optimistic UI), insert it at its seq position when sent arrives, and buffer out-of-order arrivals: if 7 arrives before 6, hold 7, request since: 5, and render both in order. See Design a chat app UI for the client side.

AdvancedDeep dive: delivery guarantees and offline sync

Delivery is at-least-once, displayed exactly once:

  • Sender to server: the device retries send until it gets sent; the server deduplicates by (conv_id, clientMsgId) (kept for a few days), as the model's byClientId does.
  • Server to recipient: the live push is best-effort. If the socket was half-dead, the message is still in the store, and the next sync with the device's bookmark fetches it. The device ignores any seq it already has.
  • Multiple devices: each device has its own bookmark and its own session; a message is pushed to every connected device of every member, including the sender's other devices. A "read" ack from the phone moves the user's read watermark, and the server forwards it to the laptop so its unread badge clears too.
  • Long offline periods: a device back after a month might be 50,000 messages behind across its chats. Sync the conversation list first (latest seq and unread count per conversation), then fetch recent pages for the conversations on screen, and backfill the rest lazily as the user scrolls.

Group receipts come straight from watermarks: "read by 812 of 1,023" for message 9,000 is a count of members with read_seq ≥ 9000. For big groups that count is computed on demand when someone opens message info, not pushed on every ack.

AdvancedDeep dive: presence with heartbeats and TTLs

Deep dive: presence

Presence ("online", "last seen 5 min ago") is derived from heartbeats with a TTL: the app sends a heartbeat every 30 seconds; the presence store keeps presence:{user} with an expiry; no heartbeat within the TTL means offline. The design question is how long to wait, because every status change is pushed to everyone watching. The model replays one afternoon of a phone that loses signal in a tunnel and is later closed, with two TTLs, on a fake clock:

presence.mjs
// Presence from heartbeats with a TTL, on a fake clock. A grace period stops short drops flipping the status.
const HEARTBEAT = 30;   // seconds between heartbeats from the app
const WATCHERS = 40;    // contacts who have a chat with dana open right now

// dana's phone beats every 30 s, loses signal in a tunnel (no beat at t=120), and the app closes after t=300.
const beats = [0, 30, 60, 90, 150, 180, 210, 240, 270, 300];

function simulate(ttl) {
  let lastBeat = -Infinity, shown = "offline", pushes = 0;
  const log = [];
  for (let now = 0; now <= 450; now += 10) {          // a sweeper every 10 s; real stores use key expiry
    if (beats.includes(now)) lastBeat = now;
    const status = now - lastBeat <= ttl ? "online" : "offline";
    if (status !== shown) {
      shown = status;
      pushes += WATCHERS;                              // every watcher gets the change
      log.push(`${status}@${now}s`);
    }
  }
  return `TTL ${String(ttl).padStart(2)} s: ${log.join(" -> ")}  (${pushes} pushes)`;
}

console.log(simulate(HEARTBEAT));      // offline the moment one heartbeat is late
console.log(simulate(HEARTBEAT * 3));  // tolerate two missed heartbeats
Result
TTL 30 s: online@0s -> offline@130s -> online@150s -> offline@340s  (160 pushes)
TTL 90 s: online@0s -> offline@400s  (80 pushes)
What’s happening
  1. Dana's phone beats at 0, 30, 60, 90; the beat at 120 is lost in a tunnel; beats resume at 150 and stop after 300 when the app closes. Forty contacts currently have a chat with her open, so every visible change costs 40 pushes.
  2. With TTL = 30 (offline the moment one heartbeat is late), the sweep at 130 s sees 130 − 90 = 40 > 30 and flips her offline; at 150 she flips back. Then at 340 she goes offline for real: four changes in all, 4 × 40 = 160 pushes, and two of them (the tunnel's offline and online) were noise.
  3. With TTL = 90 (two missed heartbeats tolerated), the tunnel never shows: at 130 s the gap is 40, within 90. She shows online until 400 s, 100 s after she really left, for 80 pushes — half the traffic.
  4. The cost of the grace period is staleness: "online" lingers for up to the TTL after the app closes. Fix the common case explicitly: a clean app close or logout sends an "offline" frame, and only crashes and lost signal wait for the TTL.
  5. What it demonstrates: presence is a trade between freshness and fan-out. A TTL of about three heartbeat intervals (two missed beats tolerated) hides flapping connections, which matter a lot on mobile networks.

At scale, presence fan-out is the expensive part, not the heartbeats: a user with 500 contacts flipping status would notify 500 people. So subscribe lazily — a client subscribes to the presence of the people on its screen (the open chat, the visible part of the chat list) and unsubscribes when they scroll away. In big groups, presence is shown only on demand. Heartbeats themselves can ride on the WebSocket ping, and the store is a Redis key with EXPIRE, so the stored value expires without a sweeper.

That covers the value, not the push. To tell watchers "dana went offline" at the moment her key expires, something has to notice the expiry, and Redis's way of announcing it, keyspace notifications (notify-keyspace-events Ex), has two catches that its documentation spells out. They are Pub/Sub, which is fire-and-forget: a subscriber that is disconnected or restarting misses the events sent meanwhile. And the expired event is sent when Redis actually deletes the key, either when something reads it or when the background expiry cycle finds it, not when the TTL reaches zero, so with many keys it can arrive noticeably late. (In a Redis Cluster each node also sends events only for its own keys, so the listener must subscribe to every node.) The usual mitigation is to treat notifications as a hint at most and own the timing yourself: keep last-heartbeat times in a sorted set (ZADD presence <time> dana) and have the presence service sweep it every few seconds with ZRANGEBYSCORE presence -inf <now − TTL>, so a missed event is caught on the next sweep. Readers can also compute "online" from the last-heartbeat time instead of trusting a pushed flag.

AdvancedDeep dive: large groups and channels

A 1,024-member group is fan-out on write to connections: 1,023 deliveries per message, most to offline members who just get it on sync. That's fine. A broadcast channel with 500,000 followers is the news-feed celebrity problem again: pushing every message to every follower's connection is 500,000 deliveries, and their read receipts would be a storm. Treat large channels like celebrities in Design a news feed: store once, push only to members who have the channel open right now (they subscribed when they opened it), send at most a badge-update to the rest, and let them sync when they look. Disable per-message read receipts above a size; show a view count instead.

AdvancedChat trade-offs table

Trade-offs and what to say

DecisionChoiceAlternativeWhy
TransportWebSocket per deviceLong polling / SSE + POSTBidirectional, one connection, low overhead per message; fall back to long polling behind hostile proxies
OrderingPer-conversation seqTimestamps, Snowflake IDsDense, so gaps are detectable; single writer per conversation is cheap
DeliveryAt-least-once + clientMsgId dedup + sync by bookmarkExactly-once protocolExactly-once over a flaky network isn't achievable; idempotence gives the same visible result
ReceiptsWatermarks per memberRow per message per member200× fewer writes in the estimate; two numbers per member
PresenceHeartbeat + TTL, lazy subscriptionsPush every change to every contactHalves noise in the model; fan-out only to watchers
Big channelsPush to open sessions onlyPush to every memberSame reasoning as the feed's celebrities
AdvancedGotcha: acking before storing
Carol's phone reconnects with the bookmark g1: 41. The conversation is at seq 45. Before her sync response arrives, a live push of seq 46 lands. What should her app do?
Show answer

Hold 46, wait for the sync to return 42–45, then show 42, 43, 44, 45, 46 in order and move the bookmark to 46.

What’s happening
  1. The bookmark says the device has everything up to 41, so on connect it sends sync with since: { g1: 41 }, and the server will answer with 42–45.
  2. Meanwhile seq 46 is sent by someone else and pushed live to Carol's new socket. It can arrive first because the push and the sync response travel separately.
  3. The app sees 46 while its highest contiguous seq is 41: there's a gap (42–45), so 46 goes into a buffer instead of on screen. Showing it now would put the newest message above four older ones.
  4. The sync response delivers 42–45. Now 42 through 46 are contiguous, so the app renders all five in order and sets the bookmark to 46.
  5. If the sync had failed, the gap would still be detectable on the next connect, because the bookmark only advances over contiguous sequences. That's the property a dense per-conversation counter buys.

#Design search autocomplete

Added

Search autocomplete (typeahead) suggests the most likely completions of what you've typed so far, updating on each keystroke: type ca and see "calculator", "canva", "car insurance". It has to answer in a few tens of milliseconds, because a suggestion that arrives after the next keystroke is useless, and it gets several requests per search. Interviewers like it because the obvious design (search the data on every keystroke) is too slow, and the good design moves almost all the work out of the request path.

The mental model: a phone book where every prefix already has its page. On the page for ca, someone has already written the ten best completions in order. Typing is flipping to the page — one lookup, no searching. The cost moves to an offline pipeline that counts what people actually search and rewrites the book periodically, and to memory: there's a page for every prefix of every popular query. Freshness then becomes a question of how often you reprint, and how you let a sudden surge ("canada day" on July 1) jump the queue.

"c" calculator 900 canva 700 car insurance 650 "ca" calculator 900 canva 700 car insurance 650 "can" canva 700 canada day 120 "car" car insurance 650 carbonara recipe 400 cardio workout 90 a n r
Every node keeps its own top-K, so answering a prefix is one lookup. The lists are copies: "car insurance" is stored at c, ca, car and every longer prefix of itself.

The main model builds exactly that index from a day of aggregated logs, answers prefixes with one lookup, then applies the next window of logs as a batch: old counts decay, new counts are added, and the whole index is rebuilt and swapped in at once.

autocomplete.mjs
// Top-k suggestions per prefix, built from a query log and refreshed in batches with decay.
const K = 3, MAX_PREFIX = 20, DECAY = 0.5;

function buildIndex(counts) { // counts: Map query -> score. Returns Map prefix -> top-K queries.
  const index = new Map();
  for (const [query, score] of counts) {
    for (let len = 1; len <= Math.min(query.length, MAX_PREFIX); len++) {
      const prefix = query.slice(0, len);
      const top = index.get(prefix) ?? [];
      top.push({ query, score });
      top.sort((a, b) => b.score - a.score || a.query.localeCompare(b.query));
      if (top.length > K) top.pop(); // keep only K per prefix: memory is bounded by prefixes x K
      index.set(prefix, top);
    }
  }
  return index;
}
const suggest = (index, typed) => (index.get(typed.toLowerCase()) ?? []).map((s) => `${s.query} (${s.score})`);

// Batch 1: a day of aggregated logs (query -> times searched).
let counts = new Map([
  ["calculator", 900], ["canva", 700], ["car insurance", 650], ["carbonara recipe", 400],
  ["cat videos", 380], ["canada day", 120], ["cardio workout", 90], ["carrot cake", 60],
]);
let index = buildIndex(counts);
console.log(`batch 1: ${index.size} prefixes stored`);
for (const typed of ["c", "ca", "car", "cat"]) console.log(`  "${typed}" ->`, suggest(index, typed).join(", "));

// Batch 2: the next window's logs. Old scores decay so a burst can overtake a steady query.
const nextWindow = new Map([["canada day", 2400], ["calculator", 850], ["car insurance", 500], ["canva", 300]]);
const merged = new Map();
for (const [q, s] of counts) merged.set(q, Math.round(s * DECAY));
for (const [q, s] of nextWindow) merged.set(q, (merged.get(q) ?? 0) + s);
counts = merged;
const next = buildIndex(counts);
const names = (top) => (top ?? []).map((s) => s.query).join("|");
let changed = 0;
for (const [prefix, top] of next) if (names(top) !== names(index.get(prefix))) changed++;
index = next; // swap the whole snapshot at once: readers never see half an update
console.log(`batch 2: ${changed} of ${index.size} prefixes show different suggestions`);
for (const typed of ["c", "ca", "can", "car"]) console.log(`  "${typed}" ->`, suggest(index, typed).join(", "));
Result
batch 1: 71 prefixes stored
  "c" -> calculator (900), canva (700), car insurance (650)
  "ca" -> calculator (900), canva (700), car insurance (650)
  "car" -> car insurance (650), carbonara recipe (400), cardio workout (90)
  "cat" -> cat videos (380)
batch 2: 3 of 71 prefixes show different suggestions
  "c" -> canada day (2460), calculator (1300), car insurance (825)
  "ca" -> canada day (2460), calculator (1300), car insurance (825)
  "can" -> canada day (2460), canva (650)
  "car" -> car insurance (825), carbonara recipe (200), cardio workout (45)
What’s happening
  1. The log arrives already aggregated: query → times searched for the window, not raw events. buildIndex walks every prefix of every query (up to 20 characters: nobody needs suggestions for the 40th character) and inserts the query into that prefix's list, keeping only the top K = 3 by score (ties broken alphabetically so the output is stable).
  2. Batch 1 stores 71 prefixes for 8 queries — every distinct prefix gets a page. "c" and "ca" both show calculator (900), canva (700), car insurance (650); "cat" has only one candidate. suggest is a single Map.get: the request path does no searching and no sorting.
  3. Batch 2 is the next window. Old scores are multiplied by DECAY = 0.5, then new counts are added: canada day 120 × 0.5 + 2400 = 2460, calculator 900 × 0.5 + 850 = 1300, canva 700 × 0.5 + 300 = 650. Queries absent from the new window (cat videos, carrot cake) just decay.
  4. Canada day jumps from not being in "c"'s top 3 to first. Here the burst would win even without decay (raw totals 2,520 against calculator's 1,750), because the model has only one day of history. Decay matters at real scale, where a steady query has months of totals behind it that a one-hour surge can't overtake, and it also lets the trend fade once the surge stops (the quiz below).
  5. Only 3 of 71 prefixes show different suggestions (c, ca, can), even though every score changed. That's the case for incremental updates in a big system — rebuild only the prefixes of queries whose counts moved — but here the whole index is rebuilt and swapped with index = next in one assignment, so a reader sees the old index or the new one, never a half-updated mix.
  6. What it demonstrates: autocomplete is a precomputed read model. The write side (logs → counts → index) runs in batches off the request path; the read side is a lookup. Freshness is the batch interval plus the decay factor.
AdvancedAutocomplete requirements and scope

Requirements

Functional

  • Return up to 10 completions for a prefix, as the user types.
  • Rank by popularity with a freshness bias: trending queries rise within minutes to an hour.
  • Suggestions depend on language and region (en-CA sees "canada day" earlier than en-GB).
  • Never suggest offensive or dangerous completions, and never surface a query only one person ever typed. Google's October 2020 explainer describes the same kinds of policies: automated systems block violent, hateful, sexually explicit or dangerous predictions, and reported ones are removed by enforcement teams.
  • Personalisation (my recent searches) is a stretch goal, merged on the client or at the edge.

Non-functional

  • Latency: p99 under 50 ms at the server so the round trip fits between keystrokes (a target we set; the CHI 2018 typing study's 52 words-a-minute average, used in the frontend autocomplete estimates, is about one keystroke every 230 ms, and fast typists are well under that).
  • Scale: 100 M DAU, 10 searches each a day (the estimate's assumptions, the same as the trie topic's).
  • Availability over freshness: serving yesterday's suggestions beats serving none.
  • Eventual consistency is fine: a new popular query can take an hour to appear.
AdvancedAutocomplete estimates: requests and index size

Estimates

Assumptions: 100 M DAU, 10 searches each per day (as in the trie topic in chapter 08), 6 suggest requests per search (the keystrokes left after a ~150 ms debounce in the client), a 2× peak; 50 M distinct queries kept after dropping rare ones; trie shape measured on the synthetic log in the first deep dive (5.1 nodes and 7.1 stored suggestions per query); 40 bytes per trie node and 12 bytes per stored suggestion as rough costs.

suggest-estimates.mjs
// Autocomplete: request rate, index size, and log volume.
const DAU = 100e6;
const searchesPerUserPerDay = 10;
const requestsPerSearch = 6;          // keystrokes that survive a ~150 ms debounce
const peakFactor = 2;
const keptQueries = 50e6;             // distinct queries above the minimum-count cut-off
const nodesPerQuery = 5.1;            // measured on the synthetic log (prefix-cost.mjs): 512,356 / 100,000
const suggestionsPerQuery = 7.1;      // measured there too, with K = 10: 705,533 / 100,000
const nodeBytes = 40;                 // child pointers + bookkeeping per trie node (rule of thumb)
const suggestionBytes = 12;           // 8-byte query ID + 4-byte score
const queryBytes = 24;                // each query string stored once
const S = 86_400;

const req = DAU * searchesPerUserPerDay * requestsPerSearch;
console.log(`suggest requests: ${(req / 1e9).toFixed(1)} B/day = ${(req / S / 1e3).toFixed(0)} k/s avg, ${(req / S * peakFactor / 1e3).toFixed(0)} k/s peak`);
const parts = {
  nodes: keptQueries * nodesPerQuery * nodeBytes,
  suggestions: keptQueries * suggestionsPerQuery * suggestionBytes,
  strings: keptQueries * queryBytes,
};
const total = Object.values(parts).reduce((a, b) => a + b, 0);
console.log(`index: ${Object.entries(parts).map(([k, v]) => `${k} ${(v / 1e9).toFixed(1)} GB`).join(" + ")} = ${(total / 1e9).toFixed(1)} GB`);
console.log(`search log: ${(DAU * searchesPerUserPerDay * 100 / 1e9).toFixed(0)} GB/day to aggregate (~100 bytes per search)`);
const perServer = 50_000;             // planning figure for an in-memory lookup service
console.log(`servers at ${perServer / 1e3}k req/s each: ${Math.ceil(req / S * peakFactor / perServer)} + spares, each holding a full copy`);
Result
suggest requests: 6.0 B/day = 69 k/s avg, 139 k/s peak
index: nodes 10.2 GB + suggestions 4.3 GB + strings 1.2 GB = 15.7 GB
search log: 100 GB/day to aggregate (~100 bytes per search)
servers at 50k req/s each: 3 + spares, each holding a full copy
What’s happening
  1. Requests: 100 M × 10 × 6 = 6.0 B a day, 69 k/s average (the 69,444 lookups a second of the trie topic), 139 k/s at the 2× peak assumed here (the trie topic assumes a 3× peak, about 208 k/s; with 3× this design would need 5 servers instead of 3 before spares). For scale, Google said in March 2025 that it sees more than 5 trillion searches a year — about 158 k searches a second (Search Engine Land's conversion), each with several keystrokes.
  2. The index is 15.7 GB: 10.2 GB of trie nodes, 4.3 GB of suggestion lists, 1.2 GB of query strings. Storing suggestions as IDs into a string table rather than strings is what keeps the lists small: each query string is stored once even though it appears in about seven lists. One caveat on the 5.1 nodes per query: it was measured on the synthetic log, whose queries are 4 to 21 characters long (11.6 on average) and built from only 20 syllables, so they share prefixes heavily. Real queries are longer (an older Chitika study of US search engines measured about 4.3 words per Google query) and draw on a far bigger vocabulary, so they share fewer prefixes and the real node count per query is probably several times higher. At 3 to 5 times the node figure the index is about 36 to 57 GB, which still fits in one server's RAM, so replicating the whole index still works; measure it on your real log.
  3. That fits in RAM on one ordinary server, which changes the design: replicate the whole index to every suggest server instead of sharding it. Any server can answer any prefix; adding capacity is adding copies.
  4. At a planning figure of 50 k lookups/s per server, peak needs 3 servers plus spares for failures and deploys, more realistically 6 to 10 across regions. The search log to aggregate is about 100 GB a day — a modest batch job.
AdvancedSuggest API and caching headers

API

Output
GET /v1/suggest?q=can&locale=en-CA&limit=10
200  Cache-Control: public, max-age=300
     { "q": "can", "suggestions": [ { "text": "canada day", "kind": "query" },
                                    { "text": "canva",      "kind": "query" } ] }

The response depends only on q, locale and limit, so it is cacheable by a CDN and the browser (see Where to cache). Personal suggestions must not go into that cached response; fetch them separately or merge them on the client. The client debounces keystrokes, cancels stale requests and ignores out-of-order responses — covered in Design an autocomplete component.

AdvancedAutocomplete data model and snapshots

Data model

DataShapeWhere
Raw search events(ts, user_or_session, locale, query)Log (Kafka), then cold storage
Window counts(window, locale, normalised_query) → count, distinct_usersBatch output tables
Scores(locale, query) → decayed scoreBuilder state, carried between windows
Index snapshotper locale: prefix → top-K (query_id, score), plus a string tableA versioned file in object storage, loaded into RAM
Blocklistpatterns and exact queries never to suggestSmall table, applied at build time and at read time

The index snapshot is the product: a compact, immutable file (suggest-en-CA-v4211) that servers load and swap in atomically. Normalisation — lower-casing, trimming whitespace, Unicode normalisation — happens before counting, so "Canada Day " and "canada day" count together.

AdvancedAutocomplete architecture: read path and pipeline

High-level design

read path, per keystroke Client debounce · cache CDN short prefixes hit Suggest servers full index in RAM replicas, any prefix write path, in batches Search log events (Kafka) Aggregator counts per window Index builder decay · top-K · filters Snapshots object storage searches load v+1, swap the two paths meet only at the snapshot
The read path is a CDN and an in-memory lookup. Everything that's slow — counting, ranking, filtering — happens in the batch pipeline.
  1. Read path. The client debounces, keeps its own small cache (backspacing to ca again needs no request), and calls /v1/suggest. The CDN answers most short prefixes; the rest reach any suggest server, which looks the prefix up in its in-memory index and filters against the live blocklist.
  2. Logging. Executed searches (not keystrokes) are logged with locale and a user or session ID, onto a log such as Kafka.
  3. Aggregation. A job counts queries per window, for example hourly, with distinct-user counts. See Batch vs stream processing for windows and late data.
  4. Build. The builder applies decay, drops queries below the minimum distinct-user count, applies the blocklist, computes top-K for every prefix, and writes a new versioned snapshot.
  5. Swap. Servers download the new version alongside the old one, then switch a pointer — the model's index = next. A bad build is rolled back by switching the pointer back.
AdvancedDeep dive: precomputed top-K vs searching at query time

The alternative to storing top-K at every node is a plain trie (or a sorted list of queries) where a request walks to the prefix's node and then searches its whole subtree for the best K. How bad is that? This model builds a seeded synthetic log of 100,000 distinct queries made of random syllables with Zipf-like popularity, then replays the traffic one keystroke at a time and measures how many queries a query-time scan would look at.

prefix-cost.mjs
// Query-time scan vs precomputed top-k, on a seeded synthetic log of 100,000 distinct queries.
function mulberry32(a) {
  return () => {
    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);
const syllables = "ka ro mi ne su ta li po da ve chi lo ma ri sa no te ba gu fi".split(" ");
const word = () => Array.from({ length: 2 + Math.floor(rand() * 3) }, () => syllables[Math.floor(rand() * syllables.length)]).join("");
const queries = new Set();
while (queries.size < 100_000) queries.add(rand() < 0.5 ? word() : `${word()} ${word()}`);
const log = [...queries].map((q, i) => [q, Math.ceil(1e6 / (i + 1))]); // Zipf-like popularity

// Subtree size per prefix = how many queries a query-time scan must look at for that prefix.
const subtree = new Map();
for (const [q] of log) for (let len = 1; len <= Math.min(q.length, 20); len++) {
  const p = q.slice(0, len);
  subtree.set(p, (subtree.get(p) ?? 0) + 1);
}
// Replay the traffic: each search types its query's prefixes one keystroke at a time.
let keystrokes = 0, scanned = 0;
const byLength = new Map();
for (const [q, count] of log) for (let len = 1; len <= Math.min(q.length, 20); len++) {
  const size = subtree.get(q.slice(0, len));
  keystrokes += count;
  scanned += count * size;
  const b = byLength.get(len) ?? { n: 0, s: 0 };
  b.n += count; b.s += count * size;
  byLength.set(len, b);
}
console.log(`distinct queries ${log.length}, distinct prefixes (trie nodes) ${subtree.size}`);
console.log(`stored suggestions with K=10: ${[...subtree.values()].reduce((a, n) => a + Math.min(10, n), 0)}`);
for (const len of [1, 2, 3, 4, 6]) {
  const b = byLength.get(len);
  console.log(`  typed ${len} char${len > 1 ? "s" : " "}: a query-time scan looks at ${Math.round(b.s / b.n).toString().padStart(6)} queries on average; precomputed: 1 lookup`);
}
console.log(`traffic-weighted average: ${Math.round(scanned / keystrokes)} queries scanned per keystroke`);
Result
distinct queries 100000, distinct prefixes (trie nodes) 512356
stored suggestions with K=10: 705533
  typed 1 char : a query-time scan looks at   7623 queries on average; precomputed: 1 lookup
  typed 2 chars: a query-time scan looks at   5005 queries on average; precomputed: 1 lookup
  typed 3 chars: a query-time scan looks at    605 queries on average; precomputed: 1 lookup
  typed 4 chars: a query-time scan looks at    258 queries on average; precomputed: 1 lookup
  typed 6 chars: a query-time scan looks at     12 queries on average; precomputed: 1 lookup
traffic-weighted average: 1360 queries scanned per keystroke
What’s happening
  1. 100,000 distinct queries produce 512,356 distinct prefixes (trie nodes) — about 5 per query, because popular-looking syllable runs are shared. That 5.1 is the figure the estimate used.
  2. Storing top-10 at every node costs 705,533 stored suggestions, about 7 per query: most deep nodes have fewer than 10 queries below them, so their lists are short. That's the 7.1 in the estimate — far less than "10 per prefix".
  3. A query-time scan after one character looks at 7,623 queries on average, after two 5,005, after three 605. Short prefixes are exactly where users start, so the expensive case is the common one.
  4. Weighted by real traffic (each search types every prefix of its query, and popular queries are searched far more), the average keystroke would scan 1,360 queries and sort them. Precomputed, every keystroke is one lookup.
  5. What it demonstrates: precomputing moves a cost of thousands of comparisons per request into a few extra list entries per query in memory. It's the Tries for prefix search structure with the top-K cached at each node, which is the variant every autocomplete answer should name.

Other shapes worth naming: a flat hash map prefix → top-K is simplest and fastest to look up, but stores every prefix string in full (more memory than a trie's shared paths); a search engine's completion suggester or edge n-gram index (see Search with an inverted index) adds fuzzy matching and filters, at the cost of running a search cluster on the hot path. Typo tolerance ("cnada" → "canada") is a second lookup with edits, or a separate spelling-correction model, not something the prefix index does.

AdvancedDeep dive: keeping counts fresh
  • Batch windows (hourly) are simple, cheap, and easy to re-run after a bug. Each build is a pure function of the window counts and the previous scores, which is the model's batch 2.
  • Exponential decay gives recent windows more weight without storing history: score = score × decay + new_count. With hourly windows, a decay of 0.5 per window means a burst halves in importance every hour; 0.95 halves it about every 13.5 hours (0.95^13.5 ≈ 0.5), roughly a day of memory. The steady state for a query searched c times a window is c / (1 − decay).
  • Trending overlay: for minutes-level freshness, run a small streaming job that counts the last 5–10 minutes and flags queries growing much faster than their baseline. The suggest server merges a tiny trending list into the snapshot's results at read time. The snapshot stays hourly and stable.
  • Minimum distinct users (say 50 different people) before a query can be suggested. It removes typos and the long tail, and it's a privacy rule: a query typed by one person (their name and address, say) must never be suggested to others.
  • Incremental updates: when only a few thousand queries change between windows, update only their prefixes. With immutable snapshots that's a delta file applied to a copy; it pays off when the index is too big to rebuild every hour.
AdvancedDeep dive: caching and the shape of the traffic

Every search passes through its first and second character, so the shortest prefixes get the most traffic, and there are very few of them: 26 one-letter and 676 two-letter prefixes for unaccented Latin letters. Those responses are the perfect CDN entries: tiny, few, and shared by everyone in a locale, so a max-age of a few minutes takes a large share of all requests off the servers (an assumption to measure, not a quoted figure). Deep prefixes are rare and spread out; they go to the servers. If the index ever outgrew one machine (many locales, many more queries), shard by locale first, then by prefix range, and give the hottest short prefixes extra replicas — never shard by first letter alone, because s and c carry far more queries than x.

AdvancedAutocomplete trade-offs table

Trade-offs and what to say

DecisionChoiceAlternativeWhy
Read pathPrecomputed top-K per prefixSearch the subtree per request1 lookup vs ~1,360 queries scanned per keystroke in the model
StructureTrie with top-K, IDs into a string tableFlat prefix map; search engineSmallest memory with shared prefixes; simplest correct thing
DistributionReplicate the whole indexShard by prefix15.7 GB fits in RAM; any server answers any prefix
FreshnessHourly batch + decay + trending overlayReal-time counters in the indexStable, re-runnable builds; minutes-level trends where they matter
SafetyMinimum distinct users + blocklist at build and readFilter only at readRemoves private and offensive suggestions before they exist
CachingCDN + browser for short prefixesNo cachingThe hottest keys are the fewest
Using the model's numbers after batch 2 (canada day 2460, calculator 1300) with decay 0.5: if canada day gets no searches in the next window and calculator gets its usual 850, which one is first for "c" after batch 3?
Show answer

Calculator: 1500 against canada day's 1230.

What’s happening
  1. Batch 3 multiplies every existing score by 0.5, then adds the window's counts.
  2. Canada day: 2460 × 0.5 + 0 = 1230. With no new searches it only decays.
  3. Calculator: 1300 × 0.5 + 850 = 1500. Its steady traffic tops it up every window.
  4. 1500 > 1230, so calculator is first again — one quiet hour after the holiday. Calculator's long-run score settles at 850 / (1 − 0.5) = 1700, which is why a burst of 2,400 could beat it, but only briefly.
  5. If batch 3 used decay 0.9 instead, canada day would keep 2460 × 0.9 = 2214 against calculator's 1300 × 0.9 + 850 = 2020 and stay on top for another window or more. The decay factor is how long a trend is remembered.

#Design a comments and likes system

Added

Likes and comments look like the simplest feature in a social app: a button that adds one to a number, and a list of replies. At scale they hide two separate problems. Correctness: one like per user, unlike works, retries don't double-count, and the number shown matches reality eventually. Contention: when a post goes viral, thousands of people press the same button in the same second, and if they all update the same database row, they queue behind that row's lock no matter how big the database is.

Two mental models cover it. First, the likes table is the truth and the count is a cache of it: who liked what is stored once, with a primary key that makes a second like impossible, and the number is derived — so it can be rebuilt, batched or slightly late, and never needs COUNT(*) on the read path. Second, one turnstile versus many: a single counter row is a single turnstile that everyone must pass through one at a time; a sharded counter is a row of turnstiles, each counting its own people, with someone adding up all the turnstiles every second.

5,000 likes/s posts.like_count one row lock: ~2,000 updates/s the rest wait in line 5,000 likes/s shard 0 shard 1 shard 2 … shard 15 Aggregator sum every second like_count cached for reads
A single row serialises every like; shards spread the lock, and one summed number serves the reads, up to a second behind.

The main model is a seeded simulation of that contention. One viral post gets 5,000 likes a second for two seconds (Poisson arrivals). Each like is an UPDATE … SET n = n + 1 that holds the row's lock for 0.5 ms including the commit, so one row can absorb at most 2,000 a second — roughly what one client managed against PostgreSQL 18 on the Mac this site was written on (measurements in the deep dive). It runs with 1, 2, 4 and 16 counter rows and reports how long likes wait for the lock.

counters.mjs
// One hot counter row vs a sharded counter: a seeded simulation of row-lock contention.
function mulberry32(a) {
  return () => {
    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 RATE = 5_000;    // likes per second on one viral post
const SECONDS = 2;
const HOLD_MS = 0.5;   // how long one UPDATE holds the row lock, commit included: 2,000 updates/s per row

function run(shards, seed) {
  const rand = mulberry32(seed);
  const freeAt = new Array(shards).fill(0); // when each row's lock is next free
  const count = new Array(shards).fill(0);
  const waits = [];
  let t = 0, finished = 0;
  while (true) {
    t += (-Math.log(1 - rand()) / RATE) * 1000; // Poisson arrivals, in ms
    if (t > SECONDS * 1000) break;
    const s = Math.floor(rand() * shards);       // each like picks a random shard row
    const start = Math.max(t, freeAt[s]);        // queue behind whoever holds the lock
    freeAt[s] = start + HOLD_MS;
    count[s]++;
    waits.push(start - t);
    finished = Math.max(finished, freeAt[s]);
  }
  waits.sort((a, b) => a - b);
  const pct = (p) => waits[Math.floor(p * (waits.length - 1))].toFixed(1).padStart(7);
  const total = count.reduce((a, b) => a + b, 0);
  console.log(`${String(shards).padStart(2)} row${shards > 1 ? "s" : " "}: ${total} likes, lock wait p50 ${pct(0.5)} ms  p99 ${pct(0.99)} ms  max ${pct(1)} ms, last write at ${(finished / 1000).toFixed(2)} s, a read sums ${shards} row${shards > 1 ? "s" : ""}`);
  return count;
}
const results = new Map([1, 2, 4, 16].map((shards) => [shards, run(shards, 1)]));

// Periodic aggregation: a job sums the shard rows into posts.like_count once a second,
// so the feed reads one number instead of 16, at the cost of being up to a second behind.
const shardRows = results.get(16);
console.log(`aggregated like_count = ${shardRows.reduce((a, b) => a + b, 0)}, shard rows: ${shardRows.join(" ")}`);
Result
 1 row : 10005 likes, lock wait p50  1510.9 ms  p99  2972.7 ms  max  3002.4 ms, last write at 5.00 s, a read sums 1 row
 2 rows: 10005 likes, lock wait p50   259.2 ms  p99   520.8 ms  max   527.3 ms, last write at 2.53 s, a read sums 2 rows
 4 rows: 10005 likes, lock wait p50     0.2 ms  p99     2.3 ms  max     4.6 ms, last write at 2.00 s, a read sums 4 rows
16 rows: 10005 likes, lock wait p50     0.0 ms  p99     0.6 ms  max     1.4 ms, last write at 2.00 s, a read sums 16 rows
aggregated like_count = 10005, shard rows: 636 671 631 588 629 617 660 622 633 605 649 560 652 629 632 591
What’s happening
  1. Arrivals are a Poisson process: the gap to the next like is -ln(1 − u) / RATE, with u from the seeded PRNG, so the run is reproducible and 10,005 likes arrive in the 2 seconds (close to the expected 10,000).
  2. Each like picks a row (always row 0 when there's one row), starts at max(arrival, freeAt[row]) — it queues behind whoever holds the lock — and holds it for 0.5 ms. freeAt is the whole lock model: one number per row.
  3. One row: capacity is 2,000/s against 5,000/s arriving, so the queue grows by 3,000 every second. The median like waits 1.5 s, the worst 3.0 s, and the last write lands at 5.00 s — three seconds after the burst ended. In a real database those waits are held connections, so the connection pool runs dry and other requests start failing too.
  4. Two rows: 4,000/s of capacity is still below 5,000/s, so it still queues — p50 259 ms, p99 521 ms. Sharding helps only once shards × per-row rate exceeds the arrival rate.
  5. Four rows: 8,000/s of capacity, and the wait collapses to 0.2 ms p50, 2.3 ms p99. Sixteen rows: 0.6 ms p99. Past the point where capacity exceeds demand, more shards only buy headroom for a bigger burst.
  6. The price is on the read side: the true count is the sum of all the shard rows, here 16 reads. The last line shows the periodic aggregation: a job adds the 16 rows (636 + 671 + … = 10,005) once a second and stores the total, so post reads fetch one cached number that is at most a second old.
  7. What it demonstrates: contention is about one key, not about the database's total capacity. Spreading one logical counter over several physical rows turns a queue into parallel work, at the cost of a scatter read or a slightly stale aggregate.
AdvancedLikes and comments requirements

Requirements

Functional

  • Like and unlike a post (and a comment); at most one like per user per item.
  • Show the like count and whether I liked it, on every post in a feed page.
  • Comment on a post, reply to a comment (two levels shown, deeper threads collapsed), edit and delete my comments.
  • List comments by "top" or "newest", paginated; show comment counts.
  • See who liked a post (a paginated list), lower priority.

Non-functional

  • Scale: 500 M DAU, 20 likes and 1 comment per user per day, 200 post views each (the estimate's assumptions).
  • My own like shows immediately (read-your-writes for me); counts others see may lag by a few seconds (eventual consistency).
  • Counts never drift permanently: no double-counting from retries, and periodic reconciliation fixes anything that slips.
  • Survive a viral post: one post getting thousands of likes a second must not slow down anything else.
AdvancedLikes estimates: write rates and the hot post

Estimates

Assumptions: 500 M DAU, 20 likes, 1 comment and 200 post views per user per day, a 3× peak, 48 bytes per like row and 400 bytes per comment; one celebrity post collecting 2 M likes, 60% of them in its first 10 minutes; a per-key write ceiling of 1,000 a second, which is DynamoDB's documented per-partition-key limit (AWS Database Blog, 2017, reviewed 2022: above 3,000 RCU and 1,000 WCU for one partition key value, requests may be throttled).

likes-estimates.mjs
// Likes and comments: write rates, storage, and the one post that breaks a single row.
const DAU = 500e6;
const likesPerUserPerDay = 20;
const commentsPerUserPerDay = 1;
const viewsPerUserPerDay = 200;     // post impressions, each needs a like count and "did I like it?"
const peakFactor = 3;
const likeRowBytes = 48;            // post_id + user_id + created_at + index overhead (rough)
const commentBytes = 400;           // text + IDs + metadata
const S = 86_400;

const likes = DAU * likesPerUserPerDay, comments = DAU * commentsPerUserPerDay, views = DAU * viewsPerUserPerDay;
console.log(`likes:      ${(likes / 1e9).toFixed(0)} B/day = ${(likes / S / 1e3).toFixed(0)} k/s avg, ${(likes / S * peakFactor / 1e3).toFixed(0)} k/s peak`);
console.log(`comments:   ${(comments / 1e6).toFixed(0)} M/day = ${(comments / S / 1e3).toFixed(1)} k/s avg`);
console.log(`count reads: ${(views / 1e9).toFixed(0)} B/day = ${(views / S / 1e6).toFixed(1)} M/s avg -> served from cache, never COUNT(*)`);
console.log(`storage:    likes ${(likes * likeRowBytes / 1e12).toFixed(2)} TB/day, comments ${(comments * commentBytes / 1e9).toFixed(0)} GB/day`);

// A celebrity post: 2 M likes, 60% of them in the first 10 minutes.
const burst = (2e6 * 0.6) / 600;
const rowLimit = 1000;              // e.g. DynamoDB's 1,000 WCU per partition key per second (AWS, 2017/2022)
console.log(`hot post:   ${burst.toFixed(0)} likes/s for 10 min -> needs >= ${Math.ceil(burst / rowLimit)} counter shards at ${rowLimit}/s each (x2 headroom: ${Math.ceil(burst / rowLimit) * 2})`);
Result
likes:      10 B/day = 116 k/s avg, 347 k/s peak
comments:   500 M/day = 5.8 k/s avg
count reads: 100 B/day = 1.2 M/s avg -> served from cache, never COUNT(*)
storage:    likes 0.48 TB/day, comments 200 GB/day
hot post:   2000 likes/s for 10 min -> needs >= 2 counter shards at 1000/s each (x2 headroom: 4)
What’s happening
  1. Likes: 500 M × 20 = 10 B a day, 116 k/s average, 347 k/s at peak. Spread over many posts, that's an easy write load for a sharded store; the problem is never the total.
  2. Comments: 500 M a day, 5.8 k/s. Twenty times fewer than likes, and each one is read many times, so comments are a read-heavy feed of their own rather than a contention problem.
  3. Count reads: 100 B post views a day is 1.2 M count lookups a second on average. That number alone rules out COUNT(*) — counts must be precomputed and served from cache, and "did I like it" must be a batched point lookup.
  4. Storage: likes add 0.48 TB a day, comments 200 GB a day. Likes are small but numerous; partition them so no single post's likes land on one hot partition.
  5. The hot post: 1.2 M likes in 10 minutes is 2,000 likes/s on one counter, double the 1,000/s per-key ceiling, so at least 2 counter shards and 4 with headroom. The simulated burst above (5,000/s) needed 4 rows at 2,000/s each to stop queueing — the same reasoning, different ceiling.
AdvancedLikes and comments API

API

Output
PUT    /v1/posts/{postId}/like                     -> 200 { liked: true,  likeCount }   (repeat-safe)
DELETE /v1/posts/{postId}/like                     -> 200 { liked: false, likeCount }
GET    /v1/posts/{postId}/likes?cursor=            -> 200 { users[], nextCursor }       (who liked)
POST   /v1/posts/{postId}/comments                 { body, parentId? }  -> 201 { commentId, createdAt }
       Idempotency-Key: <uuid>
GET    /v1/posts/{postId}/comments?sort=top|new&cursor=&limit=20
                                                   -> 200 { comments[ {id, author, body, likeCount, replyCount, topReplies[]} ], nextCursor }
PUT    /v1/comments/{commentId}/like               -> 200
PATCH  /v1/comments/{commentId}  /  DELETE /v1/comments/{commentId}

A like is a PUT on "my like of this post", not a POST to a likes collection, so pressing twice or retrying a timed-out request leaves exactly one like — the request is idempotent by its shape. The response returns the client's best guess at the count (the cached total, adjusted by this change) so the button can update optimistically. A comment is a new resource each time, so it needs an idempotency key (see Idempotency keys for APIs).

AdvancedLikes and comments data model

Data model

DataKey → valuePartition byWhy
Likes(user_id, post_id) → created_atuser_idThe primary key forbids a second like; "did I like these 20 posts?" is one query on one partition; a viral post's likes spread over every user's partition
Likers by post(post_id, created_at, user_id)post_id"Who liked this", written asynchronously from the like events
Counter shards(post_id, shard) → npost_idOnly for hot posts; 1 shard for everything else
Post statspost_id → like_count, comment_count, updated_atpost_idThe aggregate every read uses; cached
Comments(post_id, comment_id) → author, parent_id, body, created_at, like_count, deletedpost_idA post's comments together, newest-first by Snowflake ID
Top comments(post_id, score, comment_id)post_idMaintained asynchronously as scores change

The key decision is partitioning likes by user, not by post. Partitioned by post, a viral post's likes would all insert into one partition — the same contention moved from the counter to the table. Partitioned by user, the insert that enforces "one like per user" touches the liker's partition, and the per-post work (counting, the likers list) is done asynchronously from events. This is Modelling data for access patterns again, with the hot-partition lesson from Resharding and hot partitions.

AdvancedLikes architecture: truth, events, counts

High-level design

Clients API gateway auth · limits Like service insert if absent Likes PK (user, post) Event log liked · commented Aggregator sum per post per 1 s Counts cached Comment service threads · ranking Comments by post_id
The likes table and the comments store are the truth; every count is derived from their events by the aggregator and read from cache.
  1. Like. The like service inserts (user_id, post_id) with "insert if absent" semantics. If the row already existed, nothing changed and nothing is emitted, which is what makes retries and double-taps harmless. If it was new, it emits Liked(post_id, user_id, +1) to the event log, through an outbox row written in the same transaction (or the table's change stream), so a crash between the insert and the emit can't lose the event. Unlike deletes the row and emits −1 only if a row was deleted.
  2. Count. The aggregator consumes the log partitioned by post_id, so all events for one post reach one consumer. It sums deltas in memory and flushes once a second: one write per post per second, however many likes arrived. Hot posts get counter shards so even that write isn't concentrated.
  3. Read. Feed hydration asks for counts of 20 posts in one multi-get from the counts cache, and "did I like them" in one query on the viewer's likes partition. The viewer's own recent likes are overlaid on the client so their tap shows instantly.
  4. Comment. The comment service writes to the post's comment partition and emits Commented(post_id, +1) to the same log, so comment counts use the same machinery. "Top" ordering is maintained asynchronously from comment likes.
AdvancedDeep dive: counter strategies, measured

The model's 0.5 ms lock-hold time came from measuring. On the Mac this site was written on (Apple M5 Pro, PostgreSQL 18.6 in Docker, default settings with synchronous commit, pgbench, 10-second runs, October 2026), an UPDATE counters SET n = n + 1 WHERE id = … gave:

SetupThroughputAverage latency
1 client, 1 row1,965 updates/s0.51 ms
32 clients, the same 1 row1,600–1,730 updates/s (876 on a cold first run)18–20 ms
32 clients, 16 rows chosen at random10,000–11,600 updates/s2.8–3.2 ms

Adding clients to one row doesn't add throughput, it adds waiting: 32 clients got no more than one client (slightly less in these runs), at nearly 40× the latency, because each update waits for the previous transaction's commit to release the lock. Sixteen rows gave 5 to 6× the throughput. A re-run during review on the same machine and image gave the same shape: 1,559 updates/s for one client, 1,716 for 32 clients on one row (18.7 ms), 11,782 for 32 clients on 16 rows. These are laptop numbers for illustration — a production server with faster disks will be faster in absolute terms — but the shape is the point. Your options:

  • Single counter row. Simple, exact, fine for the vast majority of posts. Breaks at around a thousand or two updates a second on one key.
  • Sharded counter rows. N rows per hot post, each write picks one at random, reads sum all N (or read a periodically summed total). AWS's DynamoDB guide recommends the same thing as "write sharding": add a random suffix to the partition key, then query every suffix and merge on read. Shard only hot posts: detect a post crossing a rate threshold and switch it to N shards, so the other 99.9% of posts still cost one read.
  • In-memory counter (Redis INCR). Each increment is microseconds, so one key takes far more than a database row, and INCR is atomic. But it's a cache: if it's lost or a write is missed, it drifts, so it's rebuilt from the likes table and periodically reconciled.
  • Event log + aggregator (the design above). Coalesces any number of likes into one write per post per interval, and the log gives replay. It's the most moving parts, and counts lag by the flush interval.

The next model shows how much the aggregator saves, and the subtle part of it: replaying the log after a crash without double-counting.

aggregator.mjs
// Coalescing like events into one write per post per second, and replaying safely after a crash.
function mulberry32(a) {
  return () => {
    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);
const POSTS = 2_000, EVENTS = 100_000, SECONDS = 10;
// The event log (think Kafka partition): offset = index. 30% of traffic hits post 0, the rest spreads out.
const log = Array.from({ length: EVENTS }, (_, i) => ({
  second: Math.floor((i / EVENTS) * SECONDS),
  post: rand() < 0.3 ? 0 : 1 + Math.floor(Math.pow(rand(), 2) * (POSTS - 1)),
  delta: rand() < 0.9 ? +1 : -1, // 10% are unlikes
}));

const truth = new Map();
for (const e of log) truth.set(e.post, (truth.get(e.post) ?? 0) + e.delta);
console.log(`naive: ${EVENTS} UPDATEs, post 0 alone gets ${Math.round(log.filter((e) => e.post === 0).length / SECONDS)} a second`);

// The aggregator: sum deltas in memory, flush once per second. The DB row stores the count AND the
// last offset applied, updated in one transaction, so a replay can skip what was already applied.
function aggregate(db, from, to, { crashAfterSecond = -1, trackOffsets = true } = {}) {
  let writes = 0, hot = 0, buffer = new Map(), second = log[from].second;
  const flush = (lastOffset) => {
    for (const [post, delta] of buffer) {
      const row = db.get(post) ?? { count: 0, applied: -1 };
      if (!trackOffsets || lastOffset > row.applied) db.set(post, { count: row.count + delta, applied: lastOffset });
      writes++;
      if (post === 0) hot++;
    }
    buffer = new Map();
  };
  for (let i = from; i < to; i++) {
    if (log[i].second !== second) {
      flush(i - 1);
      if (second === crashAfterSecond) return { writes, hot, crashedAt: i }; // flushed, but the consumer offset wasn't committed
      second = log[i].second;
    }
    buffer.set(log[i].post, (buffer.get(log[i].post) ?? 0) + log[i].delta);
  }
  flush(to - 1);
  return { writes, hot };
}
for (const trackOffsets of [false, true]) {
  const db = new Map();
  const first = aggregate(db, 0, EVENTS, { crashAfterSecond: 4, trackOffsets });
  const committed = Math.floor(first.crashedAt / 2); // the last offset commit happened earlier, mid-way
  const second = aggregate(db, committed, EVENTS, { trackOffsets });
  const wrong = [...truth].filter(([post, n]) => (db.get(post)?.count ?? 0) !== n).length;
  console.log(`${trackOffsets ? "offset-checked" : "blind replay  "}: ${first.writes + second.writes} writes (${first.hot + second.hot} to post 0), post 0 = ${db.get(0).count} (truth ${truth.get(0)}), ${wrong} posts wrong`);
}
Result
naive: 100000 UPDATEs, post 0 alone gets 2967 a second
blind replay  : 23459 writes (13 to post 0), post 0 = 29412 (truth 23468), 1960 posts wrong
offset-checked: 23459 writes (13 to post 0), post 0 = 23468 (truth 23468), 0 posts wrong
What’s happening
  1. The seeded log has 100,000 like events over 10 seconds across 2,000 posts; 30% hit post 0, and 10% of events are unlikes (−1). Writing each event as an UPDATE would be 100,000 writes, with post 0 alone taking 2,967 a second — over the measured single-row ceiling.
  2. aggregate buffers deltas per post in a Map and flushes once per second. Over the run that's 23,459 row writes instead of 100,000, and post 0 gets one write per second (13 in total, explained below) instead of 2,967.
  3. Then a crash: the aggregator flushes second 4's counts to the database but dies before committing its consumer offset to the log. The last committed offset was earlier (here, mid-way through the work), so on restart it replays from there. That's normal at-least-once consumption (see Delivery guarantees and ordering).
  4. Blind replay re-adds the second half of second 2 and all of seconds 3 and 4, which were already applied: post 0 ends at 29,412 instead of the true 23,468, and 1,960 posts are wrong. The three extra writes to post 0 (10 + 3 = 13) are those replayed seconds.
  5. Offset-checked replay stores, in the same row and the same write, the last log offset it applied (applied). On replay, a batch whose last offset is not beyond applied is skipped. Result: post 0 = 23,468, matching the truth, and 0 posts wrong. It works here because replayed batches end on the same offsets as the originals (whole seconds); stream processors such as Kafka Streams or Flink get the same effect by committing state and offsets together.
  6. What it demonstrates: batching turns a hot key's thousands of writes into one per interval, and idempotent application (count and offset updated atomically) is what makes at-least-once replay safe. See Idempotency and exactly-once.

Even with all that, run a reconciliation job: recount likes from the likes table for posts whose counts changed recently (or a sample of all posts), compare with the stored count, and fix differences. The likes table is the truth; the counter is a fast approximation of it that you check.

AdvancedDeep dive: "did I like it?" on every post

Each feed page shows 20 posts, and each needs likedByMe. With likes partitioned by user_id, it's one query: SELECT post_id FROM likes WHERE user_id = ? AND post_id IN (…20 ids…) — one partition, 20 point lookups on its primary key. At the estimate's 1.2 M post views a second that's 60 k such queries a second, so add a per-user cache of recent likes (most likes a user sees as "liked" are recent ones they just made). A Bloom filter per user can answer "definitely not liked" without touching the store at all, which is the common answer: you've liked only a small share of what you scroll past.

AdvancedDeep dive: comments on a hot post

Comment writes are not the contention problem: even a viral post getting 100 comments a second is one partition doing 100 inserts a second, which any store handles. The pressure is on reads: millions of people open the same post and load the same first page of top comments. Treat that first page as a hot key — cache it with a short TTL and coalesce concurrent misses so the store sees one rebuild, not thousands (see Hot keys and cache stampedes).

Structure and ordering:

  • Threads: store parent_id (an adjacency list). Load top-level comments for a page, then the top 2–3 replies of each in one batched query, with a replyCount and a "view more replies" cursor. Deep nesting is rare; collapse beyond two levels.
  • Newest pages by comment_id (a Snowflake: time-ordered) with a cursor — stable and cheap.
  • Top orders by a score that changes as comment likes arrive, so paginate over a snapshot (the same trick as the ranked feed), or accept small duplicates between pages and dedupe on the client.
  • Deletes are soft (deleted = true, body removed) so replies keep their parent and counts stay explainable.
  • Counts use the same event-and-aggregator path as likes; the comment count on the post card can lag by a second.
AdvancedCounter approaches compared

Trade-offs and what to say

ApproachWrite cost on a hot postRead costAccuracyUse when
COUNT(*) on readnonescans every likeexactnever on a feed
One counter rowevery like queues on one lock (~2,000/s measured)1 readexactmost posts
Sharded rowsspread over N locksN reads, or 1 aggregatedexact (aggregate lags)detected hot posts
Redis INCRmicroseconds1 readdrifts without reconciliationvery hot, display-only counts
Event log + aggregator1 write per post per second1 readlags by the interval; exact with idempotent applythe default at scale
AdvancedGotcha: partitioning likes by post
A post has 16 counter shards. Each shard row handles 1,000 writes a second, and the post is receiving 20,000 likes a second. What happens, and what are your two fixes?
Show answer

Capacity is 16,000/s, so a queue grows by 4,000 likes every second until the burst ends. Either add shards (at least 20, so 32 with headroom), or stop writing per like and let the aggregator write once per interval.

What’s happening
  1. Total counter capacity is 16 × 1,000 = 16,000 writes a second.
  2. Demand is 20,000 a second, so 4,000 a second can't be served. As in the model's 2-row case, waits grow for as long as the burst lasts and drain only after it.
  3. Fix 1, more shards: 20,000 / 1,000 = 20 is the minimum; with headroom, 32 (the next power of two, 60% spare) or 40 (double). Reads now sum more rows, which is why the aggregated total matters.
  4. Fix 2, batching: if likes go through the event log, the aggregator applies one summed delta per post (or per shard) per second. 20,000 likes become 1 write a second, and the shard count stops mattering for writes.
  5. The like itself is still recorded immediately in the likes table, partitioned by user, so no like is lost while the count catches up.

#Design a leaderboard

Added

A leaderboard ranks players by score and answers three questions: who is in the top N, what is my rank, and who is just above and below me — while scores change every few seconds. The first is easy for any database with an index on score. The second is the trap: "my rank" is "how many players have a higher score than me", and in a plain table that's a COUNT(*) over every row above you. For the player in 40-millionth place, that's a scan of 40 million index entries on every profile view.

The mental model is a queue at a ticket office that keeps itself sorted, where every person also knows how many people are ahead of them without counting. That's what a sorted set gives you: a hash table from player to score (for "what's my score"), plus a structure ordered by score that stores counts along its links (for "what's my rank"). Redis's sorted set is exactly that — its documentation describes it as a skip list plus a hash table, with ZADD, ZRANK and range reads at O(log N) — so the design is mostly about using it well: one key per board, a database as the source of truth behind it, and what to do when one board outgrows one machine.

Hash member → score ana 120 ben 125 chen 150 fay 95 gus 60 head gus60 fay95 ana120 ben125 chen150 span 4 span 2 span 2 rank of ben = spans on the path = 4 (ascending; 5 − 4 = 1 player above him)
Score lookups use the hash; rank and range queries walk the ordered levels, adding up how many members each link skips.

The main model is a sorted-set style leaderboard in plain JavaScript: a Map from player to score, plus an array kept in rank order with binary search, offering the same operations as Redis's ZINCRBY, ZREVRANK and ZREVRANGE, and a tie rule — on equal scores, whoever got there first ranks higher.

leaderboard.mjs
// A sorted-set style leaderboard: member -> score, kept in rank order, with rank and range queries.
class Leaderboard {
  #byMember = new Map(); // member -> { member, score, at }
  #ranked = [];          // best first
  // Higher score first; on a tie, whoever reached that score earlier ranks higher.
  #better = (a, b) => b.score - a.score || a.at - b.at;
  #position(entry) {     // binary search: first index whose entry is not better than this one
    let lo = 0, hi = this.#ranked.length;
    while (lo < hi) { const mid = (lo + hi) >> 1; if (this.#better(this.#ranked[mid], entry) < 0) lo = mid + 1; else hi = mid; }
    return lo;
  }
  incrBy(member, points, at) { // like ZINCRBY: remove from the old position, re-insert at the new one
    const old = this.#byMember.get(member);
    if (old) this.#ranked.splice(this.#position(old), 1);
    const entry = { member, score: (old?.score ?? 0) + points, at };
    this.#byMember.set(member, entry);
    this.#ranked.splice(this.#position(entry), 0, entry);
    return entry.score;
  }
  rank(member) { // like ZREVRANK, 0-based; the page shows rank + 1
    const e = this.#byMember.get(member);
    return e ? this.#position(e) : null;
  }
  range(start, stop) { // like ZREVRANGE start stop WITHSCORES
    return this.#ranked.slice(start, stop + 1).map((e, i) => `${start + i + 1}. ${e.member} ${e.score}`);
  }
  around(member, k) {
    const r = this.rank(member);
    return this.range(Math.max(0, r - k), r + k);
  }
}

const board = new Leaderboard();
const matches = [ // [player, points, time]
  ["ana", 120, 1], ["ben", 80, 2], ["chen", 150, 3], ["dev", 120, 4], ["eli", 60, 5],
  ["fay", 95, 6], ["gus", 40, 7], ["ben", 45, 8], ["eli", 70, 9], ["gus", 20, 10],
];
for (const [player, points, at] of matches) board.incrBy(player, points, at);
console.log("top 3:     ", board.range(0, 2).join(" | "));
console.log("ana rank:  ", board.rank("ana") + 1, "(tied with dev on 120, but got there first)");
console.log("around fay:", board.around("fay", 1).join(" | "));
board.incrBy("dev", 31, 11);
console.log("dev +31:   ", board.range(0, 2).join(" | "));
console.log("ana rank:  ", board.rank("ana") + 1);
Result
top 3:      1. chen 150 | 2. eli 130 | 3. ben 125
ana rank:   4 (tied with dev on 120, but got there first)
around fay: 5. dev 120 | 6. fay 95 | 7. gus 60
dev +31:    1. dev 151 | 2. chen 150 | 3. eli 130
ana rank:   5
What’s happening
  1. incrBy is ZINCRBY: it finds the player's old entry, removes it from its position, computes the new score, and inserts it at the position a binary search finds. Ben plays twice (80, then 45), so he ends on 125; Eli plays twice (60 + 70 = 130); Gus twice (40 + 20 = 60).
  2. #better defines the order: higher score first, and on a tie, the smaller at (earlier) first. That gives a total order — no two entries compare equal — which is what makes ranks stable and pagination repeatable.
  3. Top 3 is range(0, 2): chen 150, eli 130, ben 125. It's a slice of the ordered array — the equivalent of ZREVRANGE board 0 2 WITHSCORES.
  4. Ana and Dev both have 120. Ana reached it at time 1, Dev at time 4, so Ana is rank 4 and Dev rank 5. rank is the binary-search position, 0-based like ZREVRANK, so the page adds 1.
  5. around("fay", 1) returns one player either side: dev 120, fay 95, gus 60 at ranks 5–7 — "players near me" is just a range around my rank.
  6. Dev scores 31 more at time 11: his entry moves from rank 5 to rank 1 (151 beats chen's 150), and Ana moves from 4 to 5 without anyone touching her entry. Ranks are positions, not stored values, which is why updates are cheap: nothing else is rewritten.
  7. The array makes splice O(N) per update, which is fine for a model but not for 100 M players. Redis replaces the array with a skip list whose links carry spans, so the same operations are O(log N) — the first deep dive builds one.
AdvancedLeaderboard requirements and scope

Requirements

Functional

  • Update a player's score when a match ends (server-reported, never trusted from the client).
  • Show the top 100 of a board; show any player's rank and score; show the 5 players above and below them.
  • Boards: all-time global, daily and weekly, and per region; a friends board (my friends ranked).
  • Ties: whoever reached the score first ranks higher.

Non-functional

  • Scale: 100 M players on the all-time board, 30 M daily active (the estimate's assumptions).
  • Freshness: a new score shows in ranks within about a second.
  • Latency: top-N and my-rank under 50 ms p99 (a target we set).
  • Exact ranks for everyone, if the numbers allow it; "top 12%" is an acceptable fallback for players far down a sharded board.
  • Durable: scores are never lost, even if the ranking store is.
AdvancedLeaderboard estimates: memory, ops, rebuild

Estimates

Assumptions: 100 M players on the all-time board, 30 M DAU, 8 matches (score updates) and 5 leaderboard views per player per day, each view costing 3 commands (top 10, my rank, around me), a 4× peak. Memory per member and node throughput are from a measurement: on this site's Mac (Apple M5 Pro, Redis 8.10.2 in Docker, redis-benchmark with 50 clients and no pipelining, October 2026), a sorted set of 1,000,000 members named like player:123456 was reported by MEMORY USAGE as about 78 bytes per member — an overestimate, since the server's whole used_memory was 65.9 MB, so the real cost is under 66 bytes (a re-run during review: MEMORY USAGE 77.7 MB, used_memory up by 67.5 MB) — and ran about 172 k ZINCRBY, 204 k ZREVRANK and 209 k ZREVRANGE 0 9 per second. The estimate pads those to 100 bytes and a 50 k ops/s planning budget.

leaderboard-estimates.mjs
// Leaderboard: does it even need sharding? Assumptions in, memory and ops/s out.
const players = 100e6;              // players on the all-time board
const DAU = 30e6;
const matchesPerPlayerPerDay = 8;   // each match ends with one score update
const viewsPerPlayerPerDay = 5;     // each view: top 10 + "my rank" + 5 players around me
const peakFactor = 4;               // evening peaks and events
const bytesPerMember = 100;         // measured ~66-78 B with 13-byte names in Redis 8.10; padded for longer IDs
const opsPerNode = 50_000;          // planning figure: measured ~170k ZINCRBY/s on a laptop, leave headroom
const S = 86_400;

const writes = DAU * matchesPerPlayerPerDay / S;
const reads = DAU * viewsPerPlayerPerDay * 3 / S; // three commands per view
console.log(`score updates: ${(writes / 1e3).toFixed(1)} k/s avg, ${(writes * peakFactor / 1e3).toFixed(1)} k/s peak`);
console.log(`board reads:   ${(reads / 1e3).toFixed(1)} k/s avg, ${(reads * peakFactor / 1e3).toFixed(1)} k/s peak (commands)`);
console.log(`memory:        ${(players * bytesPerMember / 1e9).toFixed(0)} GB for one all-time sorted set`);
const peakOps = (writes + reads) * peakFactor;
console.log(`one primary:   ${(peakOps / opsPerNode).toFixed(2)}x of a node's planning budget at peak`);
// Every update also lands on a daily, a weekly and a regional board.
const boards = 4;
console.log(`with ${boards} boards per update: ${(writes * boards * peakFactor / 1e3).toFixed(0)} k writes/s peak -> ${Math.ceil(((writes * boards + reads) * peakFactor) / opsPerNode)} primaries`);
// The sorted set is rebuilt from the database if Redis loses it.
const rebuildRate = 170_000;        // ZADDs per second, one client on the same host, no pipelining (measured, laptop)
console.log(`rebuild:       ${players / 1e6} M ZADDs at ${rebuildRate / 1e3}k/s = ${(players / rebuildRate / 60).toFixed(0)} min`);
Result
score updates: 2.8 k/s avg, 11.1 k/s peak
board reads:   5.2 k/s avg, 20.8 k/s peak (commands)
memory:        10 GB for one all-time sorted set
one primary:   0.64x of a node's planning budget at peak
with 4 boards per update: 44 k writes/s peak -> 2 primaries
rebuild:       100 M ZADDs at 170k/s = 10 min
What’s happening
  1. Score updates: 30 M × 8 = 240 M a day, 2.8 k/s average, 11.1 k/s at peak. Board reads: 30 M × 5 views × 3 commands = 450 M a day, 20.8 k/s at peak.
  2. Memory: 100 M members × 100 bytes = 10 GB for the all-time board — one sorted set fits on one ordinary Redis node.
  3. Throughput: peak updates plus reads are 0.64 of one node's 50 k planning budget, which is itself well under the ~170–200 k/s measured. A single primary handles the all-time board, with replicas for reads and failover.
  4. The real multiplier is the number of boards: each match updates all-time, daily, weekly and regional boards, so 4 writes per update — 44 k writes/s at peak, 2 primaries. Spreading different boards across nodes is easy; each is its own key.
  5. Rebuild: if the ranking store is lost, replaying 100 M scores from the database at the measured 170 k/s takes about 10 minutes. That rate was one client on the same host, with no network round trip; a loader across a network, sending one command per round trip, would be far slower (at a 0.2 ms round trip, at most 5,000 a second: over 5 hours), so the loader must pipeline (a review re-run with 100 commands per pipeline reached about 745 k ZADDs/s) or run several in parallel to hit 10 minutes. That's the recovery time to quote, and why the database is the source of truth.
  6. What it shows: at this scale you don't need to shard one board. The sharding deep dive is for when one board gets too big or too hot for one node — say a billion players, or a live event with a million updates a second.
AdvancedLeaderboard API

API

Output
POST /v1/matches/{matchId}/results        (game servers only, mTLS)
     { results: [ { playerId, points } ], endedAt }        -> 202
GET  /v1/boards/{boardId}/top?limit=100                     -> 200 { entries: [ { rank, playerId, name, score } ] }
GET  /v1/boards/{boardId}/players/{playerId}?around=5       -> 200 { rank, score, above[], below[] }
GET  /v1/boards/{boardId}/friends/{playerId}                -> 200 { entries[] }
     boardId: all | daily-2026-10-24 | weekly-2026-W43 | region-eu-all

Scores come from game servers, not clients: a client can claim any score, so the match result is reported server-to-server and validated (plausible points for the match length, anti-cheat signals) before it touches a board. matchId in the path makes the report idempotent: a retried report for the same match is ignored, so a timeout can't award points twice.

AdvancedLeaderboard data model and keys

Data model

DataKey → valueStoreRole
Match resultsmatch_id → players, points, ended_atSQLIdempotency and audit; the source of truth
Player scores(board_id, player_id) → score, reached_atSQL, sharded by player_idTruth for every board; used to rebuild
Boardslb:{board_id} → sorted set of player_id by encoded scoreRedisRanking, top-N, around-me
Top-N cachelb:{board_id}:top100 → rendered JSON, 1 s TTLRedis / CDNEveryone reads the same top 100
Player profilesplayer_id → name, avatarCache in front of SQLHydrating board entries

Daily and weekly boards are separate keys named by their window (lb:daily-2026-10-24) with an EXPIRE a few days after the window closes, so "resetting" a board means starting to write to a new key — no mass delete at midnight.

AdvancedLeaderboard architecture: report, rank, read

High-level design

Game servers match results Score service validate · idempotent Event log ScoreChanged Board updater one ZADD per board Scores DB source of truth Sorted sets (Redis) all · daily · weekly · region primary + replicas Clients Leaderboard API top-N cache · hydrate rebuild ZREVRANGE · ZREVRANK
The database records scores; the sorted sets rank them. Losing Redis costs a rebuild, never a score.
  1. Report. A game server posts the match result. The score service checks it, inserts the match by match_id (a duplicate is a no-op), updates the player's score rows in the database in the same transaction, and emits a ScoreChanged event through an outbox so the event and the database agree.
  2. Rank. The board updater consumes events and applies one ZADD per affected board, with the encoded score (score plus tie-breaker, see the last deep dive). Using ZADD with the absolute new score rather than ZINCRBY with the delta makes replays harmless: setting a score twice is the same as setting it once. One gap remains: an older event delivered late (out of order across partitions, or replayed after a newer one) would set the score back. On boards where scores only go up, add GT (Redis 6.2+): ZADD lb:all GT <score> <player> only updates an existing member if the new score is greater, and still adds new members, so a stale or replayed event can never lower a score. Where scores can fall (penalties, seasonal decay), compare a per-player version in a small Lua script instead.
  3. Read. The API serves the top 100 from a 1-second cache (everyone asks for the same thing), reads ZREVRANK and ZREVRANGE around a player for "my rank", and hydrates names from the profile cache.
  4. Recover. If the Redis primary dies, a replica takes over; if the data is lost entirely, a pipelined loader replays the scores table (about 10 minutes for 100 M, from the estimate).
AdvancedDeep dive: inside a sorted set

How does Redis answer "rank of this member" in O(log N) when a plain sorted array needs a binary search and O(N) inserts, and a plain balanced tree doesn't know positions (unless you add subtree counts to every node, the tree version of the same trick)? A skip list with spans: a linked list with extra "express lanes" on higher levels, where every link records how many members it jumps over. Searching from the top level down, you add up the spans of the links you follow, and the sum is the rank. Redis builds its levels with probability 1/4 per extra level and a maximum of 32 levels (ZSKIPLIST_P and ZSKIPLIST_MAXLEVEL in server.h), and stores small sets — up to 128 members by default, zset-max-listpack-entries in redis.conf — as a compact listpack instead. This model implements the skip list's insert and rank with the same constants and measures the work.

skiplist.mjs
// A skip list with spans, the structure behind Redis sorted sets: rank in O(log N) steps.
function mulberry32(a) {
  return () => {
    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 MAX = 32;
class SkipList {
  constructor(rand) {
    this.rand = rand;
    this.head = { score: -Infinity, next: new Array(MAX).fill(null), span: new Array(MAX).fill(0) };
    this.level = 1;
    this.length = 0;
  }
  insert(score) { // ascending by score, like ZADD
    const update = [], rankAt = [];
    let x = this.head;
    for (let i = this.level - 1; i >= 0; i--) {
      rankAt[i] = i === this.level - 1 ? 0 : rankAt[i + 1];
      while (x.next[i] && x.next[i].score < score) { rankAt[i] += x.span[i]; x = x.next[i]; }
      update[i] = x;
    }
    let lvl = 1;
    while (lvl < MAX && this.rand() < 0.25) lvl++; // Redis promotes with p = 1/4
    for (let i = this.level; i < lvl; i++) { rankAt[i] = 0; update[i] = this.head; this.head.span[i] = this.length; }
    this.level = Math.max(this.level, lvl);
    const node = { score, next: new Array(lvl).fill(null), span: new Array(lvl).fill(0) };
    for (let i = 0; i < lvl; i++) {
      node.next[i] = update[i].next[i];
      update[i].next[i] = node;
      node.span[i] = update[i].span[i] - (rankAt[0] - rankAt[i]); // how many nodes this link jumps over
      update[i].span[i] = rankAt[0] - rankAt[i] + 1;
    }
    for (let i = lvl; i < this.level; i++) update[i].span[i]++;
    this.length++;
  }
  rank(score) { // 1-based rank and the number of links followed to find it
    let x = this.head, rank = 0, steps = 0;
    for (let i = this.level - 1; i >= 0; i--) {
      while (x.next[i] && x.next[i].score <= score) { rank += x.span[i]; x = x.next[i]; steps++; }
      if (x.score === score) return { rank, steps };
    }
    return null;
  }
}
for (const n of [1_000, 100_000, 1_000_000]) {
  const rand = mulberry32(5), list = new SkipList(rand);
  const scores = Array.from({ length: n }, (_, i) => i * 2 + 1); // distinct, inserted in random order
  for (let i = n - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [scores[i], scores[j]] = [scores[j], scores[i]]; }
  for (const s of scores) list.insert(s);
  let steps = 0, correct = 0;
  for (let q = 0; q < 1000; q++) {
    const k = Math.floor(rand() * n), r = list.rank(k * 2 + 1);
    steps += r.steps;
    if (r.rank === k + 1) correct++;
  }
  console.log(`n=${String(n).padStart(9)}: levels ${list.level}, ranks correct ${correct}/1000, avg ${(steps / 1000).toFixed(1)} links followed (log2 n = ${Math.log2(n).toFixed(1)}; an array scan would walk ~${n / 2})`);
}
Result
n=     1000: levels 6, ranks correct 1000/1000, avg 10.6 links followed (log2 n = 10.0; an array scan would walk ~500)
n=   100000: levels 11, ranks correct 1000/1000, avg 19.7 links followed (log2 n = 16.6; an array scan would walk ~50000)
n=  1000000: levels 12, ranks correct 1000/1000, avg 25.5 links followed (log2 n = 19.9; an array scan would walk ~500000)
What’s happening
  1. insert walks down from the top level. At each level it moves right while the next score is smaller, adding each link's span to rankAt[i] — the rank of the node where it stopped on that level — and remembers that node in update[i].
  2. The new node's height is random: level 1 always, each extra level with probability 1/4, so about a quarter of the nodes reach level 2, a sixteenth level 3, and so on. That's what keeps the structure balanced on average without any rebalancing.
  3. Splicing the node in, the spans are repaired: the link into the new node spans rankAt[0] − rankAt[i] + 1 members; the new node's own link gets what's left of the old span. Levels above the new node just grow their span by 1. That bookkeeping is the whole trick that makes ranks cheap.
  4. rank repeats the walk, summing spans, and returns as soon as it lands on the score. For 1,000 random queries at each size, every rank was correct (1000/1000), checked against the known answer.
  5. Links followed grow like log N: about 10.6 for 1,000 members, 19.7 for 100,000 and 25.5 for 1,000,000 — slightly more than log₂ N, because with p = 1/4 each level is four times sparser but you take a few steps per level. An array scan for the same answer would walk ~500,000 entries.
  6. What it demonstrates: rank, insert and range queries are all O(log N) because the ordered structure carries counts. That's the property that makes a leaderboard "just a sorted set", and why ZREVRANK on a 100 M-member board is a few dozen pointer hops rather than a scan of millions of entries.
AdvancedDeep dive: when one board doesn't fit one node

In Redis Cluster, a key lives in exactly one of 16,384 hash slots, so one sorted set is always on one node — you can't spread a single board by adding nodes. If one board outgrows a node (billions of members, or millions of updates a second during a live event), you split the board yourself. Two ways:

  • Shard by score range (0–999 on shard A, 1,000–9,999 on B…): rank is "everyone on higher shards" plus your local rank, and top-N is one shard. But players move between shards as scores change, the top shard takes the hottest traffic, and the ranges need rebalancing as the score distribution drifts.
  • Shard by player (hash(player_id) % S): writes spread evenly and a player never moves. Top-N and global rank become scatter-gather queries. This model measures them on 200,000 seeded players over 8 shards, plus a cheap approximation for rank.
sharded-top.mjs
// A leaderboard split over 8 shards by player ID: exact top-N by merging, exact rank by scatter-gather,
// and an approximate rank from a score histogram.
function mulberry32(a) {
  return () => {
    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);
const PLAYERS = 200_000, SHARDS = 8, N = 10;
const players = Array.from({ length: PLAYERS }, (_, id) => ({ id, score: Math.floor(1_000_000 * Math.pow(rand(), 3)) }));
const better = (a, b) => b.score - a.score || a.id - b.id;     // ties: lower ID first, so order is total
const shards = Array.from({ length: SHARDS }, () => []);
for (const p of players) shards[p.id % SHARDS].push(p);         // shard by player, not by score
for (const s of shards) s.sort(better);                          // each shard is its own sorted set

// Top N: each shard returns its own top N (a ZREVRANGE 0 N-1), the service merges SHARDS x N rows.
const merged = shards.flatMap((s) => s.slice(0, N)).sort(better).slice(0, N);
const truth = [...players].sort(better).slice(0, N);
console.log(`top ${N} from ${SHARDS} x ${N} rows: ${merged.map((p) => p.score).join(" ")}`);
console.log(`same as a global sort: ${merged.every((p, i) => p.id === truth[i].id)}; came from shards ${merged.map((p) => p.id % SHARDS).join(",")}`);

// Exact rank: ask every shard "how many are better than this?" (a ZCOUNT) and add up.
const betterThan = (s, p) => { let lo = 0, hi = s.length; while (lo < hi) { const m = (lo + hi) >> 1; if (better(s[m], p) < 0) lo = m + 1; else hi = m; } return lo; };
const globalSorted = [...players].sort(better);
const rankOf = new Map(globalSorted.map((p, i) => [p.id, i + 1]));
// Approximate rank: a histogram of scores in 10,000-point buckets (100 counters), kept up to date by the
// writers. Count everyone in higher buckets, and assume scores are spread evenly inside the player's bucket.
const W = 10_000, buckets = new Array(100).fill(0);
for (const p of players) buckets[Math.floor(p.score / W)]++;
const approx = (p) => {
  const b = Math.floor(p.score / W);
  const above = buckets.slice(b + 1).reduce((x, y) => x + y, 0);
  return above + Math.round(buckets[b] * (1 - (p.score % W) / W)) + 1;
};
for (const id of [3, 777, 150_001]) {
  const p = players[id];
  const exact = shards.reduce((sum, s) => sum + betterThan(s, p), 0) + 1;
  const est = approx(p);
  console.log(`player ${String(id).padStart(6)} score ${String(p.score).padStart(4)}: scatter-gather rank ${String(exact).padStart(6)} (true ${rankOf.get(id)}, ${SHARDS} calls), histogram ~${est} (off by ${Math.abs(est - exact)}, 1 call), top ${((exact / PLAYERS) * 100).toFixed(1)}%`);
}
Result
top 10 from 8 x 10 rows: 999995 999977 999953 999951 999913 999898 999896 999869 999868 999769
same as a global sort: true; came from shards 7,7,1,2,0,7,1,1,5,3
player      3 score 205543: scatter-gather rank  82023 (true 82023, 8 calls), histogram ~82049 (off by 26, 1 call), top 41.0%
player    777 score 128980: scatter-gather rank  98876 (true 98876, 8 calls), histogram ~98906 (off by 30, 1 call), top 49.4%
player 150001 score 685467: scatter-gather rank  23854 (true 23854, 8 calls), histogram ~23844 (off by 10, 1 call), top 11.9%
What’s happening
  1. 200,000 players get seeded scores skewed toward the low end (rand()³ × 1,000,000), and each goes to shard id % 8. Each shard is sorted independently, like 8 separate sorted sets.
  2. Top N: each shard returns its own top 10 (a ZREVRANGE 0 9), and the service merges the 80 rows and keeps the best 10. The result equals a global sort (true). Each shard must return the full N, not N/8: the top 10 came from shards 7,7,1,2,0,7,1,1,5,3, so shard 7 alone held 3 of them.
  3. Exact rank for a player: ask every shard how many players beat this score (a binary search per shard, the equivalent of ZCOUNT with an exclusive bound), and add them up. For players 3, 777 and 150,001 the sums (82,023, 98,876, 23,854) match the true global ranks exactly — at a cost of 8 calls per rank query.
  4. Approximate rank: a histogram of 100 buckets of 10,000 points, kept up to date as scores change. Rank ≈ everyone in higher buckets, plus a share of the player's own bucket assuming scores are spread evenly within it. It's off by 26, 30 and 10 positions — under 0.1% — for one call to a tiny structure.
  5. That suggests the product split: exact ranks where people look closely (the top 1,000, merged from the shards and cached), and "rank ~98,906, top 49.4%" further down, where nobody can tell the difference between 98,876 and 98,906.
  6. What it demonstrates: sharding by player keeps writes cheap and spreads load, at the cost of fan-out reads; precomputing a merged top-N and a histogram turns most of those fan-out reads back into single lookups.

The friends board is a different shape: a player has at most a few hundred friends, so fetch their scores in one call (ZMSCORE lb:all friend1 friend2 …) and sort them in the API. No separate sorted set per player is needed.

AdvancedDeep dive: ties, time windows and the score encoding

Redis orders members with equal scores lexicographically by member name, which is not a fair tie-break. To make "first to reach it wins" part of the ordering, pack the tie-breaker into the score: points in the high part, and (latest possible time − time reached) in the low part, so earlier achievers get a slightly larger number. The catch is that sorted-set scores are double-precision floats, exact for integers only up to 2^53, so the parts must fit together.

tie-encoding.mjs
// Ties: "whoever got there first ranks higher", packed into one sorted-set score.
// Redis scores are IEEE 754 doubles: integers are exact only up to 2^53.
const MAX_SCORE = 10_000_000;              // game points
const scoreBits = Math.ceil(Math.log2(MAX_SCORE + 1));
const timeBits = 53 - scoreBits;
const EPOCH = Date.UTC(2026, 0, 1) / 1000; // seconds; the board's own epoch keeps the numbers small
const TIME_SPAN = 2 ** timeBits;           // seconds we can represent
console.log(`score needs ${scoreBits} bits, leaving ${timeBits} bits = ${(TIME_SPAN / 86400 / 365.25).toFixed(1)} years of seconds`);

const encode = (points, reachedAtSec) => points * TIME_SPAN + (TIME_SPAN - 1 - (reachedAtSec - EPOCH));
const decode = (packed) => Math.floor(packed / TIME_SPAN);

const ana = encode(120, EPOCH + 3600);     // reached 120 points one hour in
const dev = encode(120, EPOCH + 7200);     // reached 120 points two hours in
console.log(`ana ${ana} > dev ${dev}: ${ana > dev}`);
console.log(`decoded: ${decode(ana)}, ${decode(dev)}; largest packed value is safe: ${Number.isSafeInteger(encode(MAX_SCORE, EPOCH))}`);
console.log(`one bit too many: ${Number.isSafeInteger(2 * MAX_SCORE * TIME_SPAN)}`);
Result
score needs 24 bits, leaving 29 bits = 17.0 years of seconds
ana 64961376751 > dev 64961373151: true
decoded: 120, 120; largest packed value is safe: true
one bit too many: false
What’s happening
  1. Scores up to 10,000,000 need 24 bits, which leaves 53 − 24 = 29 bits for the time part: 2^29 seconds, 17.0 years. Counting time from the board's own epoch (1 January 2026 here), rather than 1970, is what makes 29 bits enough.
  2. encode multiplies points by TIME_SPAN (shifting them above the time bits) and adds TIME_SPAN − 1 − elapsed, so a smaller elapsed time gives a larger packed value.
  3. Ana and Dev both have 120 points; Ana got there an hour in, Dev two hours in. Ana's packed score (64,961,376,751) is larger than Dev's (64,961,373,151), so ZREVRANK puts her first — the same tie rule as the main model, now inside one number.
  4. decode divides and floors to recover the points: 120 and 120. The largest possible packed value is still a safe integer, so no two different (points, time) pairs collide.
  5. Doubling the maximum value — one bit too many — is not a safe integer: precision would silently drop low bits and ties would break wrongly. If the game needs more points or a longer board life, use coarser time (minutes instead of seconds) or a different tie rule.

Windows are separate keys: a match updates lb:all, lb:daily-2026-10-24 and lb:weekly-2026-W43, and the daily key expires a few days after its day ends. A season reset is a new key name; old seasons can be archived from the database rather than kept in memory.

AdvancedLeaderboard trade-offs table

Trade-offs and what to say

DecisionChoiceAlternativeWhy
Ranking structureRedis sorted set per boardORDER BY score + COUNT(*) in SQLO(log N) rank vs scanning every row above you
Source of truthScores DB, Redis rebuildableRedis only~10-minute rebuild for 100 M; scores survive any cache loss
UpdatesZADD GT absolute score, idempotent by match_idZINCRBY deltaReplays and retries can't double-count; late events can't lower a score
Scale-outOne board per node; shard by player only when neededShard from day one10 GB and ~0.6 of a node at peak in the estimate
Ranks on a sharded boardExact top-N merged and cached; histogram estimate deeperScatter-gather for every rank1 call instead of 8, under 0.1% off in the model
TiesPacked score with timeRedis's lexicographic defaultFair, deterministic order within 2^53
AdvancedGotcha: ZRANK counts from the lowest
The board is sharded over 8 shards by player. To show the global top 10, a colleague asks each shard for its top 2 (8 × 2 = 16 rows) and merges them. Why can that be wrong, and what's the fix?
Show answer

One shard can hold several of the global top 10 — in the model, shard 7 held three — so taking only 2 per shard can drop a top-10 player. Each shard must return its top 10.

What’s happening
  1. Players are spread by hash(player_id), not by score, so nothing stops several top players from hashing to the same shard.
  2. In the model's run, the global top 10 came from shards 7,7,1,2,0,7,1,1,5,3: shard 7 had 3 of them, shard 1 had 3.
  3. Asking for 2 per shard returns only 2 of shard 7's three and 2 of shard 1's three. The merged list then misses two real top-10 players and fills their places with lower scores.
  4. The worst case is all 10 on one shard, so each shard must return N = 10. The merge reads 8 × 10 = 80 rows — trivial — and the result equals a global sort, as the model checked.
  5. To avoid doing even that on every request, the merged top 100 is cached for a second; everyone asks for the same list.

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.