Module 2: Napkin Estimation (Back-of-the-Envelope)

8. Project: Enlace's capacity table

Description

This is the module's capstone: the moment you stop computing one number at a time and produce, from scratch and on your own, the complete capacity table for Enlace. Starting only from the scale —the prompt's anchor numbers— you'll derive the four capacity numbers (QPS, storage, bandwidth, memory), with their average and peak variants, with indexes and replicas where appropriate, each calculation run in Python, one sanity check per row, and a note of assumptions. The deliverable is what you'd bring to a design whiteboard or an interview: a defensible table that summarizes, in five rows, the capacity of the whole system.

There's no new topic here. Everything you need you learned in lessons 2 to 7: the tools (powers of 10, seconds per day, units), the four calculations (one per lesson), and the rounding and verification discipline of lesson 7. This project is the assembly: putting the pieces together into a single coherent artifact, with the rigor that each number is computed (not quoted), rounded with judgment, crossed with a sanity check, and comes with its assumptions stated. By the end you'll have Enlace's capacity table and —more important— the method to produce that of any system from its scale.

Connection to the module: this lesson closes the module by putting everything together. Each row of the table is a lesson: QPS (3), storage (4), bandwidth (5), memory (6); and the discipline that makes it trustworthy is lesson 7. The table you assemble here is the one Enlace carries with it for the rest of the guide: in module 3 it will justify the data model and why 7 base62 characters suffice; in module 4, the cache (the 333 MB working set and the hit ratio); in module 5, the replicas and sharding decision (the 6/27 TB and the 12,000 peak reads/s); in modules 6 and 7, the balancing and the tradeoffs. The module's boundary holds to the end: here we estimate, we don't design. The table produces the numbers the design decisions will use; it doesn't make those decisions.

The load plan before building

Before a structural engineer erects a building, they produce a load table: how much weight each column supports, how much wind force the façade withstands, how many people fit per floor according to the code. It's not the building —it doesn't have a single wall drawn yet— but it's what decides whether the building is even possible and how big its beams should be. A plan made without that table is a pretty fantasy that collapses; the load table is what turns the drawing into something that can be built and defended before the inspector.

The capacity table is exactly that for a system. It's not the design —it doesn't have boxes or arrows yet— but it's what says whether the system fits in one server or a hundred, whether the network holds up, whether a cache is worth it, whether the database has to be spread out. A design made without a capacity table is over-engineering or undersizing disguised as architecture; the table is what anchors each later decision to a number you can justify. That's why this project goes at the end of the estimation module and before the design modules: you produce the load plan first, and with it in hand, modules 3 to 7 draw the building with judgment.

And like the engineer before the inspector, the proof that your table is useful is that it withstands questions. "Why a cache?" → "because it's 4,000 reads/s and growing and the working set fits in 333 MB of RAM". "Why no sharding?" → "because 6 TB fits on one server; the QPS is resolved by the cache". Each answer is a row of the table. That's what you're going to build.

The assignment

You're given Enlace's scale and nothing else. Produce the complete capacity table.

The scale (the anchor numbers, identical throughout the guide):

  • 100 million new URLs per month.
  • read:write ratio = 100:1.
  • Retention: 5 years. Average long URL: ~500 bytes; complete record: ~1 KB.
  • Peak factor to assume: 3x (within the 2–3x range for web services).

What you must deliver:

  1. The capacity table, with these five rows, each number computed (not quoted):
    • QPS — write and read, average and peak.
    • Storage — raw at 5 years, with indexes, and provisioned with replicas.
    • Bandwidth — write and read, average and peak; monthly egress.
    • Memory — the working set (hot 20%) and its relationship with the total on disk.
    • Derived — servers for the peak and concurrency (Little's Law).
  2. A note of assumptions: each assumption that holds up the table (30-day month, 3x peak factor, +50% index, ×3 replica, 500 B cache entry, etc.).
  3. A sanity check per row: the expected mark and a reference that validates the number.

Try to do it yourself before looking at the solution. Open python3, type the calculations, round to one significant figure, and assemble the table. The complete solution —with the script run— is below.

Guide: the assembly order

The numbers chain together, so there's a natural order that avoids recomputing. Follow it:

  1. QPS first, because almost everything else depends on it. Write (100M/month ÷ seconds/month), then read (× ratio), then peak (× factor). Round here: the rounded numbers (40, 4,000) are the ones that feed the following rows.
  2. Storage, which doesn't depend on the QPS but on the prompt directly (records × retention × size), plus the index and replica factors.
  3. Bandwidth, which reuses the QPS (QPS × payload) for write and read, average and peak, plus the monthly egress.
  4. Memory, which reuses the new URLs per day (derived from the write QPS) for the working set (20% × 500 B).
  5. Derived at the end: servers (peak QPS ÷ capacity per server) and concurrency (Little's Law).

With that order, each row uses numbers you already computed, and you don't repeat work. Now, the solution.

Solution

See the complete solution (script run + table + assumptions + sanity checks)

The script that produces the whole table

A single script, from the scale to the five results. You can paste it as-is into python3:

# ENLACE'S CAPACITY TABLE — from the scale to the numbers, all computed.

# --- ANCHOR NUMBERS (the prompt, the only thing given) ---
urls_per_month   = 100_000_000     # 100M new URLs/month
read_write_ratio = 100             # read:write = 100:1
retention_years  = 5               # retention
url_bytes        = 500             # average long URL (payload and cache entry)
record_bytes     = 1_000           # complete record ~1 KB (on disk)
peak             = 3               # peak factor (2-3x range)
sec_month        = 30 * 24 * 3600  # 2,592,000 s/month
sec_day          = 86_400          # s/day

# --- ROW 1: QPS (rounded here; the following rows start from the rounded) ---
qps_write = round(urls_per_month / sec_month, -1)      # 38.58 -> 40
qps_read  = round(qps_write * read_write_ratio, -3)    # 4000
print(f"[QPS]        write ~{qps_write:.0f}/s avg, ~{qps_write*peak:.0f}/s peak | "
      f"read ~{qps_read:.0f}/s avg, ~{qps_read*peak:.0f}/s peak")

# --- ROW 2: STORAGE (records x retention x size, + indexes + replicas) ---
records  = urls_per_month * 12 * retention_years
raw      = records * record_bytes
print(f"[STORAGE]    {records/1e9:.0f}e9 records | raw {raw/1e12:.0f}TB, "
      f"+idx {raw*1.5/1e12:.0f}TB, +repl x3 {raw*1.5*3/1e12:.0f}TB")

# --- ROW 3: BANDWIDTH (QPS x payload) ---
write_bw     = qps_write * url_bytes
read_bw      = qps_read * url_bytes
egress_month = read_bw * sec_day * 30
print(f"[BANDWIDTH]  write {write_bw/1e3:.0f}KB/s avg, {write_bw*peak/1e3:.0f}KB/s peak | "
      f"read {read_bw/1e6:.0f}MB/s avg, {read_bw*peak/1e6:.0f}MB/s peak | egress {egress_month/1e12:.1f}TB/month")

# --- ROW 4: MEMORY (working set = hot 20% of the distinct URLs/day) ---
new_urls_day = urls_per_month / 30
working_set  = new_urls_day * 0.20 * url_bytes
print(f"[MEMORY]     WS = 20% x {new_urls_day/1e6:.1f}M URLs/day x {url_bytes}B = {working_set/1e6:.0f}MB "
      f"({raw/working_set:,.0f}x smaller than the disk)")

# --- DERIVED: servers (peak / capacity) and concurrency (Little's Law) ---
servers = qps_read * peak / 2000
conc    = qps_read * peak * 0.010      # 10 ms per request
nic     = 1e9 / 8 / 1e6                # 1 Gbps NIC in MB/s
print(f"[DERIVED]    servers ~{servers:.0f} (+1={servers+1:.0f}) | concurrency ~{conc:.0f} | "
      f"read peak = {read_bw*peak/1e6/nic*100:.1f}% of a 1Gbps NIC")

What to expect.

[QPS]        write ~40/s avg, ~120/s peak | read ~4000/s avg, ~12000/s peak
[STORAGE]    6e9 records | raw 6TB, +idx 9TB, +repl x3 27TB
[BANDWIDTH]  write 20KB/s avg, 60KB/s peak | read 2MB/s avg, 6MB/s peak | egress 5.2TB/month
[MEMORY]     WS = 20% x 3.3M URLs/day x 500B = 333MB (18,000x smaller than the disk)
[DERIVED]    servers ~6 (+1=7) | concurrency ~120 | read peak = 4.8% of a 1Gbps NIC

Enlace's capacity table

With those numbers, the clean table —the deliverable— looks like this:

#NumberAveragePeak (3x)With factorsDesign conclusion
1Write QPS~40/s~120/sTrivial even at peak; a single DB is plenty.
1Read QPS~4,000/s~12,000/sRead-heavy (100×); calls for cache + replicas.
2Storage6 TB raw · 9 TB with indexes · 27 TB with ×3 replicasFits on one server; doesn't force sharding for space.
3Write bandwidth~20 KB/s~60 KB/sTrivial.
3Read bandwidth~2 MB/s~6 MB/segress ~5.2 TB/month<5% of a 1 Gbps NIC; the network isn't the bottleneck.
4Memory (working set)~333 MB18,000× smaller than the diskFits comfortably in RAM; caching is cheap and profitable.
Derived~6–7 app servers · ~120 concurrent connectionsHalf a dozen machines cover the read peak.

Seven rows, each number with its conclusion. That density —number + what it means— is what distinguishes a useful capacity table from a list of loose figures. A reader who sees only the conclusions column already knows Enlace's entire character: trivial write, read-heavy, manageable disk, plenty of network, profitable cache, half a dozen servers.

The note of assumptions

The whole table rests on stated assumptions. Without them, the numbers aren't defensible: anyone can adjust an assumption and recompute. These are:

  • Traffic: 100M URLs/month constant (no growth); 100:1 read ratio; average traffic, with 3x peak factor (2–3x range for human-facing services).
  • Time: 30-day month (2.592×10⁶ s); 86,400 s day; units in base 10 (1 TB = 10¹² B).
  • Sizes: long URL ~500 B (network payload and cache value); complete record ~1 KB on disk (with DB overhead).
  • Storage: 5-year retention without expiration (worst case for space); indexes +50%; replication ×3.
  • Memory: working set = hot 20% (80/20 rule) of the new URLs per day (~3.3M, proxy for the active distinct set); cache entry ~500 B (only short_code → long_url); expected hit ratio ~90%.
  • Derived: ~2,000 req/s per app server; ~10 ms per request (redirect from cache) for Little's Law.

Each assumption is a knob: if the business grows to 200M/month, if URLs expire at 2 years, if the peak turns out to be 2x instead of 3x, you adjust the knob and recompute. Stating the assumptions is what makes the table auditable.

A sanity check per row

Before delivering, each number passes lesson 7's filter: it falls on its expected mark and a reference validates it.

  • Write QPS (~40/s, mark 10¹): a modest DB does thousands of inserts/s → 40/s is trivial. ✓
  • Read QPS (~4,000/s, mark 10³): thousands/s is real load but far from a giant (millions/s) → calls for a cache, not massive machinery. ✓
  • Storage (~6 TB, mark 10¹² B): 6×10⁹ records × 1 KB = 6 TB; cross with "an SSD is 1–4 TB" → a few disks, checks out. ✓ Two-path cross: 6e9 × 1KB and 100M × 60 months × 1KB both give 6 TB.
  • Read bandwidth (~2 MB/s, mark 10⁶ B/s): a 1 Gbps NIC is 125 MB/s → 6 MB/s peak is 4.8%, trivial. ✓ Cross: 4,000/s × 500 B and daily egress 173 GB ÷ 86,400 s both give ~2 MB/s.
  • Memory (~333 MB, mark 10⁸ B): 0.0056% of the 6 TB on disk, 2% of a 16 GB RAM → fits comfortably. ✓
  • Derived (~6 servers): 12,000/s ÷ 2,000/s per server = 6; smell test → half a dozen machines for a medium-scale shortener, credible. ✓

No row clashes with a known capacity, none falls in an absurd mark, and the internal relationships are coherent (read = 100× write; working set ≪ disk). The table holds.

The overall reading

The numbers don't just describe capacity; they tell a design story, and knowing how to read it is the module's goal:

Enlace is a medium-scale read-heavy system. The write is a non-problem (40–120/s: a single database is plenty, no sharding or queues). All the pressure is on the read (4,000–12,000/s), and the table says exactly how to resolve it: the 333 MB working set fits in RAM (hence cache, module 4), the 6 TB disk fits on one server (hence no need to shard for space, module 5), and the peak is covered with half a dozen app servers (hence balancing, module 6). The network isn't the bottleneck (2–6 MB/s, <5% of a NIC), so no effort is spent there. Each decision of the following modules is already hinted at in a row of this table. That's what it means that estimation precedes design: the numbers dictate the plan, and the design executes it.

Common mistakes

Delivering figures without conclusions (a table that doesn't communicate). What happens: someone produces the five rows of correct numbers but stops there, without the "what each one means" column, and whoever reads it has the data but not the design. Why it happens: computing feels like the work, and translating the number into a decision seems like an extra. How to detect it: if your table is a list of values without a conclusion per row, you delivered half. How to fix it: each number ends in a decision ("~4,000 reads/s → calls for cache and replicas"), never in itself. The useful table doesn't say "the read QPS is 4,000"; it says "it's 4,000, so cache". The number is the means; the conclusion is the deliverable.

Not stating the assumptions (a table that can't be audited). What happens: the person delivers "27 TB" without saying they assume +50% indexes, ×3 replica, and zero expiration, and when someone asks "and if they expire at 2 years?" they can't answer because they don't know what they assumed. Why it happens: the assumptions are taken by default and forgotten once the calculation is done. How to detect it: if you can't list from memory each knob that holds up a number, you didn't state it. How to fix it: accompany the table with an explicit note of assumptions —each factor, each rounding, each assumed regime—. A table with stated assumptions is auditable and adjustable; one without them is a magic number that falls apart at the first follow-up.

Recomputing instead of chaining (ignoring that the numbers feed each other). What happens: someone goes back to the prompt for each row —divides 100M by the seconds again, re-estimates the URLs per day from scratch— instead of reusing the already-computed numbers, and along the way introduces inconsistencies (uses 40/s for the bandwidth but 38.58/s for the memory). Why it happens: the dependency graph between the numbers isn't seen. How to detect it: if the same number appears with two different values in different rows, you didn't chain; you recomputed with different roundings. How to fix it: follow the assembly order (QPS → everything else), fix the rounded value once, and feed it to the rows that depend on it. The bandwidth and the memory start from the same rounded QPS that the QPS reports; that way the table is internally consistent.

Exercises

Exercise 1 — Enlace grows 10×. The product takes off and now 1 billion URLs/month come in (10× the original scale), with all other assumptions equal. Recompute the table and say, row by row, which design conclusions change and which don't. In particular: does it still not need sharding for space? Is the network still plenty?

See solution

With urls_per_month = 1e9, everything scales ~10× (the write QPS rounds to ~400/s):

  • QPS: write ~400/s (peak ~1,200/s); read ~40,000/s (peak ~120,000/s).
  • Storage: 60×10⁹ records → 60 TB raw, 90 TB with indexes, 270 TB with ×3 replicas.
  • Read bandwidth: 40,000/s × 500 B = 20 MB/s avg, 60 MB/s peak.
  • Memory (working set): (1e9/30) × 0.20 × 500 B ≈ 3.33 GB.
  • Derived: 120,000 / 2,000 = ~60 app servers.

What changes and what doesn't:

  • Write: still trivial. 400/s (1,200 peak) a DB does without a problem. The conclusion doesn't change.
  • Storage: the conclusion DOES change. 60 TB raw no longer fits on a single server (10–20 TB). Now sharding for space does become necessary —the number crossed the mark that flips the decision—. This is the perfect example of why you estimate: the same question ("sharding?") gives the opposite answer when the scale changes, and only the number reveals it.
  • Bandwidth: still plenty, but now watched. 60 MB/s peak is ~48% of a 1 Gbps NIC —still fits on one NIC, but no longer "ignore it"; with another 2× or a shared NIC you'd have to plan (spread among servers or a 10 Gbps NIC)—. The conclusion goes from "never a problem" to "comfortable but watch it".
  • Memory: still fits. 3.33 GB fits comfortably in 16 GB of RAM (~20%). Caching is still cheap.
  • Servers: 60 instead of 6. Half a dozen becomes several dozen; the balancing (module 6) becomes more central.

The exercise's lesson: the order of magnitude of the scale decides the design. At 100M/month Enlace is "one server with a cache"; at 1,000M/month it's "sharding + dozens of servers". The method is identical —the same multipliers— but the conclusions cross marks and change. That's why a number is computed and not quoted: it survives "and if it were 10× bigger?".

Exercise 2 — The expiration knob. The team decides that URLs expire at 2 years instead of being kept for 5. Which rows of the table change and which don't? Recompute the steady-state storage and say whether this alters the sharding decision.

See solution

With expiration at 2 years, the storage stabilizes (stops growing without a ceiling): at any moment only the records of the last 24 months live on disk, because each new URL replaces one that expires.

  • Steady storage: 100M/month × 24 months × 1 KB = 2.4 TB raw (against 6 TB at 5 years without expiration). With indexes: 3.6 TB; with ×3 replicas: ~10.8 TB.
  • QPS, bandwidth, memory: do NOT change. Expiration affects how much accumulates, not the rate (QPS), the flow (bandwidth), or the hot part (working set, which was already estimated over the day's recent URLs). Those three rows are identical.

Does sharding change? It becomes even less necessary for space: 2.4 TB steady (or 3.6 with indexes) fits with plenty of margin on a single server, forever. Expiration divides the disk by ~2.5 and eliminates the indefinite growth, which makes the capacity plan even more comfortable.

The lesson: the retention policy is a design knob with direct impact on a single row (the storage), and it doesn't touch the others. A good estimator knows which numbers each assumption moves —expiration moves the disk, not the QPS— and therefore can answer "and if they expire at 2 years?" without redoing the whole table: they only recompute the affected row. Knowing which knob moves which number is part of keeping the table "alive".

Exercise 3 — Defend the design with the table. A colleague proposes, for the original Enlace (100M/month): "we have to shard the database into 10 nodes from day one, add a message queue for the writes, and put a global CDN for the bandwidth". Using only the capacity table, argue in three or four sentences why each of those three pieces is over-engineering for Enlace's scale, citing the number that disproves it.

See solution

The three pieces solve problems Enlace, at this scale, doesn't have, and the table proves it number by number:

  • Sharding into 10 nodes from day one: unnecessary. The storage is 6 TB raw (9 with indexes), and that fits on a single server (a modern SSD/DB handles 10–20 TB). The write QPS (40–120/s) is trivial for a single primary. There's neither space pressure nor write-load pressure that justifies spreading; sharding into 10 nodes is expensive complexity solving a nonexistent problem. (Module 5 develops it.)
  • Message queue for the writes: unnecessary. Queues absorb write bursts the database can't keep up with. Enlace writes 40/s (120/s at peak), two orders of magnitude below what a modest DB inserts without sweating. There's no burst to buffer; the write goes straight to the DB.
  • Global CDN for bandwidth: unnecessary (for that reason). The read bandwidth is 2 MB/s (6 MB/s peak), less than 5% of a single 1 Gbps NIC. The network isn't the bottleneck, so a CDN for flow adds nothing. (There could be another reason for a CDN —lowering geographic latency— but not the bandwidth, and the colleague justified it by bandwidth.)

The pattern: each proposed piece falls apart when confronted with the corresponding row of the table. What Enlace does need —and the table says so too— is a cache (working set 333 MB, read 4,000/s) and maybe read replicas (module 5). The capacity table is the best vaccine against over-engineering: it turns "just in case" into "the number doesn't ask for it", which is an argument you can defend.

Summary and next step

In this capstone you produced, from scratch and from the scale alone, the complete capacity table for Enlace: QPS (~40/~4,000 average, ~120/~12,000 peak), storage (6 TB raw · 9 with indexes · 27 with replicas), bandwidth (~2 MB/s read, ~5.2 TB/month egress), memory (working set ~333 MB, 18,000× smaller than the disk) and derived (~6–7 servers, ~120 concurrent connections). Each number you computed in Python, rounded to one significant figure, crossed with a sanity check, and accompanied with a note of assumptions that makes the table auditable. And you practiced the most important thing: reading the table as a design story —trivial write, read-heavy, manageable disk, plenty of network, profitable cache— and using it to defend what to build and what not.

Before closing the module you should be able to: produce the capacity table of any system from its scale, following the assembly order (QPS → everything else); accompany it with its assumptions and a sanity check per row; recompute it when an assumption changes (10× scale, expiration) knowing which rows move; and use it to dismantle over-engineering with the number that disproves it.

With this you close module 2. You have the tools (powers of 10, units), the four capacity calculations, the rounding and verification discipline, and the table that summarizes everything. Enlace stops being a vague prompt and becomes a system with defensible numbers. Module 3 takes those numbers and starts to design: the data model of the Link record, SQL vs. NoSQL for this case, and —the heart— the generation of the short_code: hash vs. counter+base62 vs. random, the base62_encode/decode function, and why 7 base62 characters suffice (62⁷ ≈ 3.5 × 10¹² codes, plenty for the 6 billion records this table projected). The estimation is over; the design begins, and it begins with the numbers you produced here in hand.

Resources

  • The System Design Primer, "Back-of-the-envelope calculations" and the URL shortener exercise (Design Pastebin.com / Bit.ly) — github.com/donnemartin/system-design-primer. It walks through a capacity table almost identical to Enlace's, with the same QPS, storage, and bandwidth structure. The best free complement to this capstone; in English.
  • Martin Kleppmann, Designing Data-Intensive Applications, Chapter 1 ("Reliability, Scalability, and Maintainability") — dataintensive.net. The conceptual framework for describing a system's load (throughput, percentiles, ratios) that underpins the whole capacity table. The guide's bedside book; in English.
  • Alex Xu, System Design Interview – An Insider's Guide, chapter "Back-of-the-envelope estimation" and "Design a URL shortener" — summary and figures at bytebytego.com. It presents the estimation of a URL shortener step by step, with the same defensible-capacity-table spirit we assembled here. In English.