When the Estimate Changes the Design
Cases where the arithmetic rules an approach out before you draw it: fanout, read-time joins, partition size, batch windows.
writes_per_second = events_per_second * average_fanout ; verdict = writes_per_second vs sustained_write_capacityAn estimate earns its place when it deletes an option. The useful outcome of back-of-the-envelope work is not a capacity plan; it is "that approach is off by a factor of a hundred, so we are not drawing it".
A fanout that does not fit the write budget
Designing Data-Intensive Applications uses Twitter's November 2012 figures: 4,600 tweets per second on average, 12,000 at peak, and 300,000 home-timeline reads per second. Fanning out on write at an average of 75 followers per account is 345,000 timeline writes per second, which is affordable. One account with over 30 million followers posting once is over 30 million writes for a single event, which is not. The arithmetic does not rule out fan-out-on-write; it rules out fan-out-on-write for everybody, which is why the shipped design is a hybrid.
A join that cannot run at that volume
Those same 300,000 reads per second, each joining a follow list against a tweets table and sorting the result, is not a query you index your way out of. Pre-computation is not an optimisation here; it is the only option.
A partition that grows without bound
Discord keys messages by (channel_id, bucket), where the bucket is a fixed 10-day window, because channel alone would let one partition grow forever. Even so, hot partitions pushed p99 historical reads to 40-125 ms across 177 Cassandra nodes; after migrating to ScyllaDB, 72 nodes of 9 TB each served a p99 of 15 ms.
Ruling something out needs a factor of ten. At a factor of two, your inputs are the uncertainty.
Example
// The check that decides whether a timeline is built on write or on read.
function fanoutBudget({ eventsPerSecond, averageFanout, maxFanout, writeCapacityPerSecond }) {
const averageWrites = eventsPerSecond * averageFanout;
const worstCaseBurst = maxFanout; // one event from the largest account
return {
averageWrites,
worstCaseBurst,
// A factor of two is noise. A factor of ten is a decision.
averageVerdict: averageWrites > writeCapacityPerSecond * 10 ? 'ruled out'
: averageWrites > writeCapacityPerSecond ? 'needs work'
: 'fits',
burstVerdict: worstCaseBurst > writeCapacityPerSecond ? 'ruled out' : 'fits',
};
}
// Twitter's figures as given in Designing Data-Intensive Applications (2012),
// against a cache tier that can absorb ~1M writes/second.
fanoutBudget({
eventsPerSecond: 4_600, // tweets posted per second, average
averageFanout: 75, // followers per account, average
maxFanout: 30e6, // the largest accounts exceed 30 million
writeCapacityPerSecond: 1e6,
});
// averageWrites 345_000 -> averageVerdict 'fits'
// worstCaseBurst 30e6 -> burstVerdict 'ruled out'
// Read the two verdicts together. The average fits comfortably, so write-time
// fanout is right for almost every account. The burst does not fit by a factor
// of 30, so it is wrong for a handful of them. That disagreement IS the design:
// fan out on write for normal accounts, fetch-and-merge at read time for the
// large ones. You do not arrive at a hybrid by taste; you arrive at it because
// two numbers from the same estimate point in opposite directions.When to use it
- Justifying a hybrid timeline design with two numbers instead of appealing to how Twitter does it.
- Rejecting a read-time join early, before half the interview is spent indexing it.
- Choosing a partition key by checking how large one partition becomes after a year.
- Showing that a nightly batch job cannot finish inside its window, so the pipeline has to be incremental.
More examples
The read-time join that cannot be indexed away
Shows how to kill a read-path proposal with one multiplication rather than a debate about indexes. Reach for it the moment someone suggests assembling a high-QPS feed from a join across a fan-in relationship.
// Proposal: build each home timeline at read time.
// SELECT t.* FROM tweets t
// JOIN follows f ON f.followee_id = t.author_id
// WHERE f.follower_id = ? ORDER BY t.created_at DESC LIMIT 50
const timelineReadsPerSecond = 300_000; // DDIA, Twitter 2012
const avgFollowsPerUser = 200; // rows on the left of the join
// Even with a perfect index, each read touches at least one row per followed
// account, then merges and sorts:
const rowsTouchedPerSecond = timelineReadsPerSecond * avgFollowsPerUser;
rowsTouchedPerSecond; // 60,000,000 index entries per second
// Compare against a single node: a well-indexed server might sustain low
// hundreds of thousands of index lookups per second.
const perNode = 200_000;
Math.ceil(rowsTouchedPerSecond / perNode); // ~300 nodes, doing only this
// Three hundred nodes to serve reads is not a tuning problem, and the shards
// make it worse: the follow set spans every shard, so each read fans out to
// all of them and its latency becomes the slowest one.
//
// Verdict: ruled out by two orders of magnitude. Pre-compute the timeline.Partition size decides the partition key
Demonstrates that a composite partition key is an arithmetic result, not a style preference, and marks the limit of what it buys you. Use it whenever a time-series or message table is keyed on a single entity id.
// Wide-column stores want partitions small - large ones concentrate reads,
// slow compaction, and cannot be split after the fact.
// Discord's key: (channel_id, bucket), bucket = a fixed 10-day window. They
// picked 10 days by checking their largest channels: at that width a bucket
// stayed comfortably under 100 MB, which was the target.
const busyChannel = { messagesPerDay: 500_000, bytesPerMessage: 1_000 };
// Keyed on channel_id alone, one partition grows forever:
const gbPerYear = (busyChannel.messagesPerDay * busyChannel.bytesPerMessage * 365) / 1e9;
gbPerYear; // 182 GB in ONE partition - unmanageable
// Keyed on (channel_id, bucket) with a 10-day bucket:
const BUCKET_DAYS = 10;
const gbPerBucket =
(busyChannel.messagesPerDay * busyChannel.bytesPerMessage * BUCKET_DAYS) / 1e9;
gbPerBucket; // 5 GB - bounded, but ~50x the 100 MB target, so still too big
// So the bucket width is an output of the arithmetic, not a constant to copy.
// For a channel this busy, holding 100 MB means a bucket of hours:
const bucketDaysFor = (targetMB) =>
(targetMB * 1e6) / (busyChannel.messagesPerDay * busyChannel.bytesPerMessage);
bucketDaysFor(100); // 0.2 days, about 5 hours
// The cost of bucketing: a query spanning a quiet period must walk back
// through several buckets to collect 50 messages, so the read is no longer
// always a single partition hit - and the narrower the bucket, the more walks.
//
// And note what bucketing does NOT fix. Discord still hit hot partitions,
// because traffic across channels is wildly uneven: p99 historical reads of
// 40-125 ms on 177 Cassandra nodes, improving to 15 ms on 72 ScyllaDB nodes
// of 9 TB each. Bounding partition SIZE does not bound partition TRAFFIC.A batch window that does not close
Separates the IO estimate from the CPU estimate, which is where batch-window proposals usually fail. Reach for this before committing to a nightly job, since the deadline, not the total work, is what eventually pages someone.
// Proposal: recompute all recommendations nightly, in a 6-hour window.
const users = 80e6;
const bytesPerUserHistory = 50e3;
const windowHours = 6;
const totalTB = (users * bytesPerUserHistory) / 1e12; // 4 TB to read
const requiredThroughputGBs = (totalTB * 1e12) / (windowHours * 3600) / 1e9;
requiredThroughputGBs; // ~0.185 GB/s = 185 MB/s sustained, just to READ
// 185 MB/s of sequential reads is unremarkable for one NVMe device, so the IO
// is not the problem. The compute is:
const cpuMsPerUser = 40;
const coreSeconds = (users * cpuMsPerUser) / 1000; // 3.2e6 core-seconds
const coresNeeded = coreSeconds / (windowHours * 3600); // ~148 cores
// 148 cores for 6 hours every night is real money and a hard deadline: lose
// two hours to a failure and the window is gone, with no room to retry.
// Worse, it does not scale with the business - double the users and either
// the window or the fleet doubles.
//
// The estimate does not say "impossible". It says the nightly batch has a
// cliff in it, and that an incremental design - recompute on activity, in a
// stream - has no window to miss. That is the decision the arithmetic forced.
Discussion