---
title: 'Back-of-the-Envelope Estimation'
source: 'https://academia.sh/en/courses/introduction-to-system-design/back-of-the-envelope-estimation'
course: 'Introduction to System Design'
language: en
updated: '2026-08-23T07:01:25+00:00'
license: 'CC BY-SA 4.0'
---

# Back-of-the-Envelope Estimation

Gathering twelve assumptions into a single table, and deriving request rate, data growth, and bandwidth need from the table by arithmetic: calculating peak load, finding the load balance behind the cache, a sparse batch job dominating bandwidth, and measuring which result each assumption multiplies by how much.

The previous two lessons left two gaps with no number. The quality scenario's environment part
read "at peak load," and how many requests peak load is stayed unclear; whether the thresholds
were achievable depended on that same uncertainty. In the second lesson, six unanswered questions
had turned into six assumptions, but the assumptions had not been written down anywhere.

This lesson fills that gap. A **back-of-the-envelope estimate** is the work of deriving a
design's resource need from a small number of assumptions by arithmetic. Its purpose is not to
find the correct number but to find the correct **order of magnitude** and to make visible which
assumption a design decision rests on.

## The Assumption Table

Every calculation has a single source. The thirteen assumptions below are also used through the
rest of the course; no lesson chooses a new volume number on its own.

| Code | Assumption | Value | Rationale |
|---|---|---|---|
| V1 | daily active users | 2,000,000 | recipients and sellers who open the tracking page at least once a day |
| V2 | tracking queries per user per day | 6 | a shipment is asked about several times before delivery |
| V3 | new shipments created per day | 400,000 | the sum of daily shipment counts per seller |
| V4 | state events per shipment | 7 | pickup, transfer, out for delivery, delivered, and intermediate states |
| V5 | tracking response body | 480 bytes | state, zone, update time, and the last three route steps |
| V6 | a state event's record | 220 bytes | tracking number, state, route step, timestamp |
| V7 | a shipment's record | 900 bytes | weight, volume, zone, contract, and tariff fields |
| V8 | peak factor | 3 | the ratio of the peak-hour rate to the daily average |
| V9 | tracking query cache hit ratio | 0.90 | the same tracking number is asked again within a short interval |
| V10 | end-of-day job's window | 4 hours | the seller wants the report in the morning |
| V11 | days the pricing job scans | 30 | pricing is reconciled over a monthly period |
| V12 | how long a record is retained | 730 days | contract disputes can reach back two years |
| V13 | items collected in one invoice line | 100 | one seller's one day is a single invoice line; how period records are partitioned depends on this ratio |

Every row in the table is an assumption: its accuracy cannot be shown from inside the design. The
bandwidth concept was established in the Computer Networks curriculum and is not re-explained
here; the calculation counts only body bytes, and header and protocol overhead would be a
separate assumption.

```js
// design/assumption.mjs — the course's single assumption table; every calculation comes out of this table
export const V = {
  dailyUsers: 2_000_000,        // V1
  queriesPerUser: 6,            // V2
  dailyShipments: 400_000,      // V3
  eventsPerShipment: 7,         // V4
  trackingResponseBytes: 480,   // V5
  eventRecordBytes: 220,        // V6
  shipmentRecordBytes: 900,     // V7
  peakFactor: 3,                // V8
  cacheHitRate: 0.9,            // V9
  batchWindowHours: 4,          // V10
  batchSpanDays: 30,            // V11
  retentionDays: 730,           // V12
  itemsPerInvoiceLine: 100,     // V13
};

export const DAY_SECONDS = 86_400;

export function calculate(v) {
  const dailyQueries = v.dailyUsers * v.queriesPerUser;
  const dailyEvents = v.dailyShipments * v.eventsPerShipment;
  const readAvg = dailyQueries / DAY_SECONDS;
  const writeAvg = dailyEvents / DAY_SECONDS;
  const readPeak = readAvg * v.peakFactor;
  const writePeak = writeAvg * v.peakFactor;
  const storeReads = readPeak * (1 - v.cacheHitRate);
  const batchBytes = v.batchSpanDays * v.dailyShipments * v.shipmentRecordBytes;
  const dailyBytes = v.dailyShipments * v.shipmentRecordBytes + dailyEvents * v.eventRecordBytes;
  return {
    "daily tracking queries": dailyQueries,
    "daily state events": dailyEvents,
    "average read requests/s": readAvg,
    "peak read requests/s": readPeak,
    "peak write requests/s": writePeak,
    "peak edge requests/s": readPeak + writePeak,
    "reads behind cache/s": storeReads,
    "requests reaching store/s": storeReads + writePeak,
    "write/read ratio at store": writePeak / storeReads,
    "read egress Mbit/s": (readPeak * v.trackingResponseBytes * 8) / 1e6,
    "write ingress Mbit/s": (writePeak * v.eventRecordBytes * 8) / 1e6,
    "batch job read GB": batchBytes / 1e9,
    "batch job scan records/s": (v.batchSpanDays * v.dailyShipments) / (v.batchWindowHours * 3600),
    "batch job Mbit/s": (batchBytes * 8) / (v.batchWindowHours * 3600 * 1e6),
    "daily data growth MB": dailyBytes / 1e6,
    "stored data GB": (dailyBytes * v.retentionDays) / 1e9,
    "daily invoice lines": v.dailyShipments / v.itemsPerInvoiceLine,
    "period seller-days": (v.batchSpanDays * v.dailyShipments) / v.itemsPerInvoiceLine,
  };
}
```

Units are counted in decimal: MB is $10^6$ bytes, GB is $10^9$ bytes, Mbit/s is $10^6$ bit/s. To
avoid confusion, only bytes and seconds are used inside the calculation, and the conversion is
done in the last step.

## The Calculations

```js
// design/scale.mjs — every calculation that comes out of the assumption table
import { V, calculate } from "./assumption.mjs";

const r = calculate(V);
const format = (x) => (Number.isInteger(x) ? String(x) : x.toFixed(2));
for (const [name, value] of Object.entries(r)) console.log(`${name.padEnd(27)}${format(value).padStart(11)}`);

const msPerShipment = 1000 / r["batch job scan records/s"];
console.log(`\nbatch job time per shipment = ${msPerShipment.toFixed(2)} ms (assuming a single worker)`);
console.log(`fee call threshold 1 ms -> remaining margin = ${(msPerShipment - 1).toFixed(2)} ms`);
console.log(`batch job bandwidth / peak read egress = ${(r["batch job Mbit/s"] / r["read egress Mbit/s"]).toFixed(2)}`);
```

```
daily tracking queries        12000000
daily state events             2800000
average read requests/s         138.89
peak read requests/s            416.67
peak write requests/s            97.22
peak edge requests/s            513.89
reads behind cache/s             41.67
requests reaching store/s       138.89
write/read ratio at store         2.33
read egress Mbit/s                1.60
write ingress Mbit/s              0.17
batch job read GB                10.80
batch job scan records/s        833.33
batch job Mbit/s                     6
daily data growth MB               976
stored data GB                  712.48
daily invoice lines               4000
period seller-days              120000

batch job time per shipment = 1.20 ms (assuming a single worker)
fee call threshold 1 ms -> remaining margin = 0.20 ms
batch job bandwidth / peak read egress = 3.75
```

All twenty-one of these numbers belong to the **computed value** class: each comes out of the
assumption table by arithmetic, and none is taken from outside. Five of them affect the design
directly.

**Peak load is not where it seems to be.** The peak request rate at the edge is 513.89 per
second. Of this, 416.67 is read, 97.22 is write. The read flow looks dominant — but behind the
cache the table flips: reads reaching the store are 41.67, writes are 97.22, and the write/read
ratio at the store is 2.33. The system described as read-heavy is write-heavy where the store
sees it. The cache appears here not as a strategy but only as a multiplier in the calculation;
caching was established in the Caching, Queues and Asynchronous Processing course.

**A sparse flow dominates bandwidth.** Peak read egress is 1.60 Mbit/s, the end-of-day job's read
rate is 6 Mbit/s: a ratio of 3.75. A job that runs once a day demands three and a half times the
bandwidth of the read flow that runs every second of the day, because it squeezes 10.80 GB into a
four-hour window. A design's largest resource line item does not have to be its most frequently
running flow.

**A threshold can be verified from a calculation.** The previous lesson's fee call threshold was
1 millisecond and looked arbitrary. Given a scan rate of 833.33 records per second, there are
1.20 milliseconds per shipment; the 1-millisecond threshold leaves a margin of 0.20 milliseconds.
The threshold's source thereby becomes a computed value rather than an expectation. The number
holds under the single-worker assumption: if the work is split across parallel workers, the
margin grows, but that is a design decision, not a result of the arithmetic.

**A coincidence requires re-running the calculation.** The peak request rate reaching the store is
138.89, and the average read rate is also 138.89. The two numbers are equal because
$12 \cdot 10^6 \cdot 0.3 + 2.8 \cdot 10^6 \cdot 3 = 12 \cdot 10^6$; this is not an identity, it
is a coincidence produced by the chosen assumptions. When one of the assumptions changes, the
equality breaks, which is why the calculation is not memorized — it is re-run.

**A single division gives two different numbers.** At the V13 ratio, 400,000 shipments a day
works out to 4,000 invoice lines a day; since each line corresponds to one seller's one day, this
number is also the seller count. The pricing job, which scans thirty days, instead encounters
120,000 lines. The two numbers come out of the same division but do not count the same thing:
4,000 sellers, 120,000 seller-days. This course's later Fundamental Properties topic splits the
end-of-day job into partitions using this second number, so the two must not be used in place of
each other.

## Sensitivity

An assumption table's most important output is not the numbers but the numbers' sensitivity to
the assumptions. The script below plays the thirteen assumptions one at a time and writes how
much each of seven results multiplies by. Because the cache hit rate cannot be doubled, its play
is defined as doubling the miss rate instead; the batch window and the items collected per
invoice line are halved instead, because tightening either of them means shrinking it.

```js
// design/sensitivity.mjs — playing each assumption on its own and measuring which result multiplies by how much
import { V, calculate } from "./assumption.mjs";

const PLAY = {
  dailyUsers: (v) => v * 2,
  queriesPerUser: (v) => v * 2,
  dailyShipments: (v) => v * 2,
  eventsPerShipment: (v) => v * 2,
  trackingResponseBytes: (v) => v * 2,
  eventRecordBytes: (v) => v * 2,
  shipmentRecordBytes: (v) => v * 2,
  peakFactor: (v) => v * 2,
  cacheHitRate: () => 0.8,             // hit rate 0.90 -> 0.80: the miss rate doubles
  batchWindowHours: (v) => v / 2,      // window is halved: the same work must finish in half the time
  batchSpanDays: (v) => v * 2,
  retentionDays: (v) => v * 2,
  itemsPerInvoiceLine: (v) => v / 2,   // half as many items are collected per line
};

const RESULT = [["peak edge requests/s", "edge/s"], ["requests reaching store/s", "store/s"],
  ["read egress Mbit/s", "egress"], ["batch job Mbit/s", "batch"],
  ["daily data growth MB", "growth"], ["stored data GB", "storage"],
  ["period seller-days", "sel-day"]];
const baseline = calculate(V);
const touched = {};

console.log(`${"assumption played".padEnd(26)}${RESULT.map(([, k]) => k.padStart(8)).join("")}`);
for (const [name, play] of Object.entries(PLAY)) {
  const changed = calculate({ ...V, [name]: play(V[name]) });
  touched[name] = RESULT.filter(([s]) => Math.abs(changed[s] - baseline[s]) > 1e-9).map(([s]) => s);
  const row = RESULT.map(([s]) => (touched[name].includes(s) ? `x${(changed[s] / baseline[s]).toFixed(2)}` : "-").padStart(8));
  console.log(`${name.padEnd(26)}${row.join("")}`);
}

const widest = Object.entries(touched).sort((a, b) => b[1].length - a[1].length)[0];
console.log(`\nassumption touching the most results = ${widest[0]} (${widest[1].length}/${RESULT.length})`);
for (const [s] of RESULT) {
  const source = Object.keys(touched).filter((a) => touched[a].includes(s));
  console.log(`${s.padEnd(27)} dependent assumptions = ${source.length}`);
}
```

```
assumption played           edge/s store/s  egress   batch  growth storage sel-day
dailyUsers                   x1.81   x1.30   x2.00       -       -       -       -
queriesPerUser               x1.81   x1.30   x2.00       -       -       -       -
dailyShipments               x1.19   x1.70       -   x2.00   x2.00   x2.00   x2.00
eventsPerShipment            x1.19   x1.70       -       -   x1.63   x1.63       -
trackingResponseBytes            -       -   x2.00       -       -       -       -
eventRecordBytes                 -       -       -       -   x1.63   x1.63       -
shipmentRecordBytes              -       -       -   x2.00   x1.37   x1.37       -
peakFactor                   x2.00   x2.00   x2.00       -       -       -       -
cacheHitRate                     -   x1.30       -       -       -       -       -
batchWindowHours                 -       -       -   x2.00       -       -       -
batchSpanDays                    -       -       -   x2.00       -       -   x2.00
retentionDays                    -       -       -       -       -   x2.00       -
itemsPerInvoiceLine              -       -       -       -       -       -   x2.00

assumption touching the most results = dailyShipments (6/7)
peak edge requests/s        dependent assumptions = 5
requests reaching store/s   dependent assumptions = 6
read egress Mbit/s          dependent assumptions = 4
batch job Mbit/s            dependent assumptions = 4
daily data growth MB        dependent assumptions = 4
stored data GB              dependent assumptions = 5
period seller-days          dependent assumptions = 3
```

The matrix says three things. First, no assumption plays every result: the assumption with the
widest effect is the daily shipment count, and even that reaches only six of the seven results.
Second, the expectation that "doubling the input doubles the result" is wrong in most cells. When
daily active users double, the peak request rate at the edge rises by a factor of 1.81, because
the write flow does not depend on this assumption; the load the store sees rises by only a factor
of 1.30, because ten percent of reads reach the store. The same growth shows up at a different
size at different layers.

Third, how many assumptions a result depends on is the measure of its reliability. The request
rate reaching the store depends on six assumptions at once; if one of them is wrong, the number is
wrong. Stored data depends on five assumptions. This is why, in a design discussion, the sentence
"one hundred thirty-nine requests a second arrive at the store" cannot be defended on its own; the
defense points back to the assumption table.

## Summary

- A back-of-the-envelope estimate looks for the correct order of magnitude, not the correct
  number; its input is an assumption table gathered in a single place.
- The calculations coming out of the thirteen assumptions: 513.89 requests/s peak at the edge
  (read 416.67, write 97.22), daily data growth of 976 MB, 712.48 GB across 730 days, 4,000
  invoice lines a day, and 120,000 seller-days over the period — the last two come out of the
  same division but do not count the same thing.
- Behind the cache, the load balance flips: reads reaching the store 41.67, writes 97.22, and a
  write/read ratio of 2.33 — a read-heavy system is write-heavy at the store.
- A sparse flow can dominate bandwidth: the end-of-day job demands 6 Mbit/s because it squeezes
  10.80 GB into four hours, 3.75 times the peak read egress.
- A threshold can be verified with a calculation: since the scan runs at 833.33 records a second,
  there are 1.20 ms per shipment, and the previous lesson's 1 ms threshold leaves a margin of
  0.20 ms.
- The sensitivity matrix falsifies the "double the input, double the result" expectation: when
  daily users double, the peak at the edge rises by 1.81, the load at the store by 1.30.

## Next Step

What is in hand is a component list, a threshold set, and twenty-one computed numbers. These are
not a design; they are a design's material. A design exists once it has been explained to someone
else and defended. The next lesson takes up that explanation: how components and flows go into a
diagram, which number stands on which edge, how a trade-off is written, and how writing each
number's class next to it makes the explanation checkable.
