Skip to content
academia.sh

Lesson 02 / 14

News Feed

Building the same stream with two distribution models: measuring the work volume of fan-out on write against fan-out on read, the tail of the follower distribution misleading an estimate done with the average, deriving the hybrid model's threshold as a number from the distribution capacity and the visibility window, and computing the moment the window is exceeded when the distribution consumer slows down.

Contents

In the previous case, the read’s response was a single record: give the key, get back the target. In this case the read ratio stays similar but the response changes — the news feed is an ordered list compiled from many sources. The question therefore stops being “where do I cache the record” and becomes: when is the list compiled? There are two options — fan-out on write, which writes the post into every follower’s box the moment it is published; fan-out on read, which merges the followed accounts’ posts the moment the feed is requested. This lesson measures both options’ work volume and gives the hybrid model’s threshold as a number.

Constraints

Functional requirements. F1: a user publishes a post. F2: a user requests the posts of the accounts they follow, ordered by time. F3: a user follows and unfollows. F4: a deleted post does not appear in the feed.

Scope reduction. Ranking by relevance score, media files, notifications, and search are outside this design; a ranking model changes the feed’s content, not the distribution decision.

Code Threshold Threshold’s source
G1 the feed’s first page (20 posts) returns in 200 ms the first view expected when the feed opens
G2 a published post appears in a follower’s feed within 60 seconds for the feed to count as live
G3 the feed box store does not exceed 20 GB for the box to stay in memory

Assumptions

Code Assumption Value Rationale
VH1 daily active users 1,000,000 users who open the feed at least once a day
VH2 daily posts per user 0.2 most users read, few write
VH3 daily feed requests per user 8 the feed is refreshed several times a day
VH4 peak factor 3 the ratio of the peak-hour rate to the daily average
VH5 follower distribution base 33, exponent 1.16 followers concentrate in a small number of accounts; the exponent is the tail’s thickness
VH6 entries kept in the box 400 twenty pages of history are enough, the rest comes from the post store
VH7 box entry 32 bytes post ID and timestamp

VH5 is not a number but a distribution; its average follows from the model and was not chosen as an assumption. An account’s follower count cannot exceed the user count, and the model applies this cap.

Back-of-the-Envelope Estimate and the Two Models’ Work Volume

// feed/estimate.mjs — the estimate from VH1-VH7 and the work volume of the two distribution
// models. The follower distribution is generated with a hand-written generator (fixed seed),
// the cap is the user count. IT IS A MODEL.
const VH = { users: 1_000_000, postsPerUser: 0.2, feedRequests: 8, peakFactor: 3, base: 33, exponent: 1.16, boxEntries: 400, entryBytes: 32 };
const DAY = 86_400;
const postPeak = (VH.users * VH.postsPerUser / DAY) * VH.peakFactor;
const feedPeak = (VH.users * VH.feedRequests / DAY) * VH.peakFactor;

let s = 20260730 % 2147483647;
const rand = () => (s = (s * 48271) % 2147483647) / 2147483647;
const followers = new Float64Array(VH.users);
let atCap = 0;
for (let i = 0; i < VH.users; i += 1) {
  const v = Math.round(VH.base * Math.pow(rand(), -1 / VH.exponent));
  if (v > VH.users - 1) atCap += 1;
  followers[i] = Math.min(VH.users - 1, v);
}
const totalFollows = followers.reduce((a, b) => a + b, 0), average = totalFollows / VH.users;
const sorted = Float64Array.from(followers).sort();
const pct = (p) => sorted[Math.floor(p * VH.users)];
const topShare = sorted.slice(VH.users - VH.users / 1000).reduce((a, b) => a + b, 0) / totalFollows;

const b = (x, n = 2) => x.toFixed(n);
console.log(`peak posts/s = ${b(postPeak)}   peak feed requests/s = ${b(feedPeak)}   read/write ratio = ${b(feedPeak / postPeak)}`);
console.log(`box store = ${b(VH.users * VH.boxEntries * VH.entryBytes / 1e9)} GB (${VH.boxEntries} entries per user)`);
console.log(`\nfollower distribution: average ${b(average)}  p50 ${pct(0.5)}  p90 ${pct(0.9)}  p99 ${pct(0.99)}  ` +
  `p99.9 ${pct(0.999)}  largest ${sorted[VH.users - 1]}  accounts at cap ${atCap}`);
console.log(`the top one-thousandth slice holds ${b(topShare, 4)} of follows`);

const writeWork = postPeak * average, readWork = feedPeak * average;
console.log(`\n${"model".padEnd(20)}${"work per write".padStart(16)}${"work per read".padStart(16)}${"work/s at peak".padStart(15)}`);
console.log(`${"fan-out on write".padEnd(20)}${b(average).padStart(16)}${"1.00".padStart(16)}${b(writeWork + feedPeak).padStart(15)}`);
console.log(`${"fan-out on read".padEnd(20)}${"1.00".padStart(16)}${b(average).padStart(16)}${b(readWork + postPeak).padStart(15)}`);
console.log(`ratio = ${b((readWork + postPeak) / (writeWork + feedPeak))}`);

const doubled = ((VH.users * VH.feedRequests * 2 / DAY) * VH.peakFactor) * average;
console.log(`\nVH3 sensitivity: daily feed requests 8 -> 16 makes fan-out on read ${b(readWork)} -> ${b(doubled)} work/s, ` +
  `fan-out on write stays at ${b(writeWork)} work/s`);
peak posts/s = 6.94   peak feed requests/s = 277.78   read/write ratio = 40.00
box store = 12.80 GB (400 entries per user)

follower distribution: average 203.63  p50 60  p90 241  p99 1763  p99.9 12474  largest 999999  accounts at cap 7
the top one-thousandth slice holds 0.2753 of follows

model                 work per write   work per read work/s at peak
fan-out on write              203.63            1.00        1691.87
fan-out on read                 1.00          203.63       56570.71
ratio = 33.44

VH3 sensitivity: daily feed requests 8 -> 16 makes fan-out on read 56563.76 -> 113127.53 work/s, fan-out on write stays at 1414.09 work/s

Three numbers carry the decision. The first is the ratio: the same stream wants 1691.87 work/s at peak with fan-out on write, and 56,570.71 work/s with fan-out on read — 33.44-fold. The difference is the read/write ratio itself; work gets multiplied by whichever side’s rate it is replicated on, and the read side runs forty times as often. The second is the box store: 12.80 GB, below G3’s 20 GB threshold. The third is sensitivity: when feed requests double, fan-out on read climbs to 113,127.53 while fan-out on write stays at 1414.09. Fan-out on write is independent of read volume; this is its defining property.

The distribution row carries a warning. The average is 203.63, but the median is 60, the 99.9th percentile is 12,474, and the largest account is at the cap. The top one-thousandth slice holds 0.2753 of follows. The estimate done with the average gives the steady rate correctly, but it says nothing about what a single post demands.

The Hybrid Model’s Threshold

// feed/hybrid.mjs — the hybrid model's threshold: accounts above the threshold are gathered
// at read time. Same distribution generator and same seed; follow probability is assumed
// proportional to popularity. IT IS A MODEL.
const VH = { users: 1_000_000, postsPerUser: 0.2, feedRequests: 8, peakFactor: 3, base: 33, exponent: 1.16 };
const DAY = 86_400, WINDOW = 60;                       // WINDOW: G2 threshold
const postPeak = (VH.users * VH.postsPerUser / DAY) * VH.peakFactor;
const feedPeak = (VH.users * VH.feedRequests / DAY) * VH.peakFactor;

let s = 20260730 % 2147483647;
const rand = () => (s = (s * 48271) % 2147483647) / 2147483647;
const followers = new Float64Array(VH.users);
for (let i = 0; i < VH.users; i += 1)
  followers[i] = Math.min(VH.users - 1, Math.round(VH.base * Math.pow(rand(), -1 / VH.exponent)));
const sorted = Float64Array.from(followers).sort();
const average = sorted.reduce((a, b) => a + b, 0) / VH.users;

// Connections below the threshold are fanned out on write, those above are gathered on read.
function work(threshold) {
  let below = 0, above = 0, largestBelow = 0, aboveAccounts = 0;
  for (const f of sorted) {
    if (f <= threshold) { below += f; largestBelow = f; } else { above += f; aboveAccounts += 1; }
  }
  return { write: postPeak * (below / VH.users), read: feedPeak * (above / VH.users), largestBelow, aboveAccounts };
}

const b = (x, n = 2) => x.toFixed(n);
const fullWrite = work(VH.users), CAPACITY = 3 * fullWrite.write;    // capacity is set to three times the steady rate
const THRESHOLD = Math.round(WINDOW * (CAPACITY - fullWrite.write));      // the share left for one post is the window's share
console.log(`steady write rate = ${b(fullWrite.write)} box writes/s, capacity ${b(CAPACITY)}/s, ` +
  `left for a single post ${b(CAPACITY - fullWrite.write)}/s -> threshold in a ${WINDOW} s window = ${THRESHOLD.toLocaleString("en-US")} followers`);
console.log(`\n${"threshold".padStart(11)}${"accounts above".padStart(16)}${"write work/s".padStart(15)}${"read work/s".padStart(14)}` +
  `${"total work/s".padStart(14)}${"largest burst/s".padStart(18)}`);
for (const e of [1000, 10_000, 84_846, THRESHOLD, VH.users]) {
  const r = work(e);
  console.log(`${e.toLocaleString("en-US").padStart(11)}${String(r.aboveAccounts).padStart(16)}${b(r.write).padStart(15)}` +
    `${b(r.read).padStart(14)}${b(r.write + r.read).padStart(14)}${b(r.largestBelow / WINDOW).padStart(18)}`);
}

const chosen = work(THRESHOLD);
console.log(`\nat the chosen threshold: ${chosen.aboveAccounts} accounts above are gathered at read time, ` +
  `${b(chosen.read / feedPeak, 3)} extra resources per feed request, total work ${b(chosen.write + chosen.read)}/s`);
console.log(`in the thresholdless design the largest single post wants ${b(sorted[VH.users - 1] / WINDOW)} box writes/s = ` +
  `${b(sorted[VH.users - 1] / WINDOW / fullWrite.write)} times the steady rate`);

// If the distribution consumer slows down: backlog / capacity, the moment it exceeds the G2 window.
console.log(`\n${"consumer capacity".padEnd(21)}${"backlog/s".padStart(11)}${"G2 exceeded at".padStart(16)}`);
for (const k of [1200, 1000, 700]) {
  const backlog = chosen.write - k;
  console.log(`${`${k} box writes/s`.padEnd(21)}${b(backlog).padStart(11)}${`${b(WINDOW * k / backlog)} s`.padStart(16)}`);
}
console.log(`average followers ${b(average)}; the connection share above the threshold ${b(chosen.read / feedPeak / average, 4)}`);
steady write rate = 1414.09 box writes/s, capacity 4242.28/s, left for a single post 2828.19/s -> threshold in a 60 s window = 169,691 followers

  threshold  accounts above   write work/s   read work/s  total work/s   largest burst/s
      1,000           19203         700.05      28561.87      29261.92             16.67
     10,000            1308        1000.84      16530.11      17530.95            166.52
     84,846             115        1193.70       8815.60      10009.30           1393.05
    169,691              52        1247.71       6655.38       7903.09           2820.88
  1,000,000               0        1414.09          0.00       1414.09          16666.65

at the chosen threshold: 52 accounts above are gathered at read time, 23.959 extra resources per feed request, total work 7903.09/s
in the thresholdless design the largest single post wants 16666.65 box writes/s = 11.79 times the steady rate

consumer capacity      backlog/s  G2 exceeded at
1200 box writes/s          47.71       1509.13 s
1000 box writes/s         247.71        242.22 s
700 box writes/s          547.71         76.68 s
average followers 203.63; the connection share above the threshold 0.1177

The table’s last row seems to settle the argument at first glance: unthresholded fan-out on write minimizes total work at 1414.09/s, and every threshold makes this number grow. Pulling the threshold down to 1000 raises total work to 29,261.92, close to twenty times as much. The reason does not change — every connection moved above the threshold gets recounted on the side that runs forty times as often.

What sets the threshold is not total work but the last column. In the thresholdless design, the largest account’s single post wants 16,666.65 box writes per second within the 60-second G2 window: 11.79 times the steady rate. This demand is invisible to an estimate done with the average, because the average is 203.63 and says nothing about the size of a single record.

The threshold is computed from here. If the distribution capacity is set to three times the steady rate (4242.28/s), 2828.19 writes/s are left for a single post; over a 60-second window this comes to 169,691 followers. That is the threshold, and it has two inputs: the window and the capacity. At the chosen threshold, 52 accounts move to the read side, 23.959 extra resources are read per feed request, and total work becomes 7903.09/s — 5.59 times the thresholdless design. This is the price paid to keep a single post from consuming the entire distribution capacity.

The 52 accounts above the threshold hold only 0.1177 of connections. This is why the hybrid model works: the fifty-two accounts at the tail of the queue carry only this share of connections, yet produce the entire burst.

Design

  • Materialized view (Scaling the Data Layer, Materialized Views). The feed box is a projection; the parameter is 400 entries per box and 32 bytes per entry, 12.80 GB in total.
  • Command and query responsibility separation (Scaling the Data Layer, Command and Query Separation). The write path looks at the post store, the read path at the box; the parameter is that the box holds the post’s ID, not its text.
  • Message queue and competing consumers (Application Layer, Message Queues; Competing Consumers). Distribution jobs land on a queue; the parameter is capacity: three times the steady 1414.09/s, 4242.28 box writes/s.
  • Sharding (Data Distribution, Sharding). The box store is sharded by user ID; the parameter is that a feed request touches a single node — scatter–gather occurs only for the 52 accounts above the threshold.
  • Backpressure (Resilience and Reliability, Backpressure). When the queue backs up, a signal goes to the publish path; the parameter is G2’s 60-second window.

Two deliberately unused patterns. Edge caching (Traffic Layer, Content Delivery Networks) is not used: the feed is personal, no two users get the same response, and a shared cache produces no hits. Sequential convoy (Application Layer, Sequential Convoy) is not used: the order of distribution jobs does not matter, because the feed is sorted by timestamp inside the box.

Eliminated Alternative: Pure Fan-Out on Read

The alternative design never builds a box; the latest posts of followed accounts are merged the moment the feed is requested. Two things it wins were measured: the box store drops from 12.80 GB to zero, and the G2 window disappears — a post is visible the moment it is published, because visibility does not require moving it. Unfollowing also takes effect instantly.

The number that eliminates it is in the work volume: 56,570.71 work/s at peak, 33.44 times fan-out on write. What is more, this number depends on read volume; when VH3 doubles it climbs to 113,127.53 while fan-out on write stays at 1414.09. The alternative wins if a different constraint changes: if the read/write ratio moved from 40 toward 1, the two models’ work would equalize and there would be no reason left to keep a box. This means the decision is set not by followers per post, but by the ratio of feed requests to posts.

Failure Behavior and What Is Given Up

If the distribution consumer slows down (Resilience and Reliability, Failure Modes), the queue backs up and visibility latency grows. The table gives the moment it is exceeded: if capacity drops to 1200 box writes/s, the backlog is 47.71/s and G2’s 60-second window is exceeded in 1509.13 seconds; at 1000 it is 242.22 seconds, at 700 it is 76.68 seconds. A partial slowdown does not stop the feed, it delays it; the symptom of this failure is therefore not an error but a shift in freshness.

If a shard of the box store drops, the feed for users in that shard returns empty. Graceful degradation (Resilience and Reliability, Graceful Degradation) here means switching to fan-out on read: the path already built for the 52 accounts above the threshold opens temporarily, for the users in the dropped shard, to everyone they follow — expensive, but better than an empty feed.

What is given up is write amplification. Each post is written to an average of 203.63 boxes, and this work happens even if no read ever arrives. The design multiplied writes by close to two hundred to cheapen reads; the entire decision rests on the read/write ratio being 40.

Summary

  • The same stream wants 1691.87 work/s at peak with fan-out on write and 56,570.71 work/s with fan-out on read: 33.44-fold. The difference is the read/write ratio itself.
  • Fan-out on write is independent of read volume: when feed requests double, fan-out on read climbs to 113,127.53 while fan-out on write stays at 1414.09.
  • The follower distribution’s average is 203.63, but its median is 60 and its p99.9 is 12,474; the top one-thousandth slice holds 0.2753 of follows.
  • Total work does not set the threshold — the thresholdless design is cheapest at 1414.09/s. What sets the threshold is the 16,666.65 writes/s the largest account’s single post wants within the 60-second window, 11.79 times the steady rate.
  • The threshold is computed from the window and the capacity: at three times the steady rate, the threshold is 169,691 followers, 52 accounts move to the read side, and total work becomes 7903.09/s.
  • When consumer capacity drops to 1200/1000/700, the G2 window is exceeded in 1509.13 / 242.22 / 76.68 seconds; what is given up is 203.63 box writes per post.

Next Step

In this case, the cost of reading was compiling, and compiling was cheapened by shifting it to write time. One property made the shift possible: for whom the response would be prepared was known at write time, because the follower list is known the moment it is written. In the next case this property disappears. A user starts typing text and requests a suggestion list on every keystroke; what the query will be is not known in advance, and the number of possible queries is not finite the way a list of users is. If the response cannot be prepared at write time, it has to be prepared at read time — and this time the read threshold is not 200 milliseconds, but small enough to fit between keystrokes. The question turns to which data structure can hold that threshold, and how much freshness must be given up.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close