4 — Capacity estimation
An estimate decides the shape of a design before anything is built. It shows whether a system is one server or a hundred, whether the data fits in memory, and which part runs out first. Chapter 3 introduced the idea with one short example. This chapter covers each quantity in turn, and then works two examples from start to finish.
An estimate only needs to be right to within a factor of two or so. What matters more is that every input is written down, so that each one can be checked and replaced with a measurement later. In this chapter, anything that isn’t measured or sourced is labeled as an assumption.
The chain of quantities
Most estimates follow the same chain, where each step feeds the next.
users and their actions
-> requests a day -> average rate -> peak rate
-> reads and writes
-> storage growth (with replication, indexes, backups)
-> bandwidth in and out
-> working set -> cache size -> load left for the database
-> servers needed (with headroom)
-> concurrency (Little's law) -> threads, connections, pools
Requests per second
The starting point is usually daily active users (DAU) and how many actions each one takes:
There are 86,400 seconds in a day. Rounding to 100,000 makes mental arithmetic easy and understates the rate by about 14%, which is fine for a first pass.
| Requests a day | Average a second |
|---|---|
| 1 million | 11.6 |
| 10 million | 116 |
| 100 million | 1,157 |
| 1 billion | 11,574 |
The peak factor
Traffic isn’t spread evenly. It follows the users’ day, and events cause sharper spikes. A system has to handle its peak, not its average, so the estimate needs a peak factor:
The peak factor is an assumption until there’s traffic to measure, and it varies a great deal by product. A better way to state it is the share of daily traffic in the busiest hour, which can be read straight from logs once they exist. If the busiest hour carries a share of the day’s requests:
| Busiest hour’s share of the day | Peak factor |
|---|---|
| 4.2% (perfectly flat) | 1.0 |
| 8% | 1.9 |
| 10% | 2.4 |
| 15% | 3.6 |
The busiest hour still hides spikes within it, such as the top of the hour or a push notification sent to every user at once. A design that expects such spikes should either size for them or shed load gracefully (chapter 16).
Read and write ratio
Reads and writes scale in different ways, so they should be estimated separately. Reads can be served from caches and replicas. Writes all go to a primary, unless the data is partitioned (chapter 11).
A product where each user views 30 pages a day and posts twice has a read-to-write ratio of 15 to 1. A logging or metrics pipeline is the opposite: nearly all writes, read by a few dashboards. The ratio decides where the design effort goes. A read-heavy system needs caching and replicas, and a write-heavy one needs batching, partitioning and cheap appends.
Storage growth
Storage is the rate of new records multiplied by their size and by how long they’re kept. The size of a record is where estimates usually go wrong, because the stored size is larger than the data.
Record size
In PostgreSQL, every table row has a fixed header of 23 bytes on most machines, and each row on a page also needs a 4-byte item identifier pointing to it. Tables and indexes are stored in pages of 8 kB, and columns are aligned, which adds padding. So a row with 300 bytes of text and four 8-byte columns takes about:
before padding and free space. For small rows, the overhead is a large share: a row with 40 bytes of data takes 67 bytes, so 40% of what’s stored is overhead. Other stores have their own overheads, and the reliable way to know is to load a sample of realistic data and measure it, in PostgreSQL with pg_total_relation_size.
The multipliers
| Multiplier | Typical effect | How to pin it down |
|---|---|---|
| Indexes | Each index stores its keys again, plus pointers. Several indexes can approach the table’s own size | Build the indexes on sample data and measure |
| Replication | Every replica holds a full copy: a primary and two replicas mean 3× | Count the copies in the design |
| Backups | Full backups plus retained history, often several times the live size | Multiply by the retention policy |
| Free space and bloat | Updates and deletes leave space that’s reused later, not returned at once | Measure on a running system |
| Compression | Divides the size by a ratio that depends entirely on the data | Compress a real sample |
The general form is:
Backups are usually kept on separate, cheaper storage, so they’re best estimated separately.
Bandwidth
Bandwidth is the request rate multiplied by the bytes per request, in each direction:
Network links are rated in bits a second, and storage and memory in bytes, so a factor of 8 appears when moving between them. 167 MB/s of responses is 1.33 Gbit/s. Protocol headers, TLS records and retransmissions add to the payload, so a link shouldn’t be planned near its rated speed. A CDN removes cached responses from the origin’s egress altogether (chapter 5).
Cache size
A cache only has to hold the working set, the data that’s actually being requested, not all of the data. How much of the working set it needs depends on how uneven the requests are.
Request popularity in many systems is close to a Zipf distribution: the item of rank is requested with probability proportional to . The exponent sets how skewed it is. A larger means the top items get a bigger share.
The often-quoted 80/20 rule, that caching 20% of items serves 80% of requests, is one point on one such curve. Whether it holds depends on and on the number of items. Simulating the cache is more reliable than assuming it.
Simulating it
This program measures two things for one million items. The ideal hit ratio is what a cache gets if it always holds the most popular items. The LRU hit ratio is what a real least-recently-used cache achieves, after a warm-up long enough to fill it.
import random
from collections import OrderedDict
from itertools import accumulate
N = 1_000_000 # distinct items
SHARES = [0.001, 0.01, 0.05, 0.10, 0.20]
def ideal_hit_ratio(cum, k):
"""Cache permanently holds the k most popular items."""
return cum[k - 1] / cum[-1]
def lru_hit_ratio(cum, k, requests, warmup, rng):
cache, hits = OrderedDict(), 0
keys = rng.choices(range(N), cum_weights=cum, k=warmup + requests)
for i, key in enumerate(keys):
if key in cache:
cache.move_to_end(key)
if i >= warmup:
hits += 1
else:
cache[key] = True
if len(cache) > k:
cache.popitem(last=False)
return hits / requests
for s in (0.8, 1.0, 1.2):
cum = list(accumulate(1 / r**s for r in range(1, N + 1)))
rng = random.Random(42)
for share in SHARES:
k = int(N * share)
print(f"s={s} cache {share:>6.1%} ideal {ideal_hit_ratio(cum, k):6.1%} "
f"LRU {lru_hit_ratio(cum, k, 2_000_000, 2_000_000, rng):6.1%}")
| Share of items cached | s = 0.8, ideal | s = 0.8, LRU | s = 1.0, ideal | s = 1.0, LRU | s = 1.2, ideal | s = 1.2, LRU |
|---|---|---|---|---|---|---|
| 0.1% | 20.7% | 10.0% | 52.0% | 40.4% | 82.2% | 76.0% |
| 1% | 36.2% | 23.2% | 68.0% | 58.6% | 91.0% | 87.5% |
| 5% | 52.3% | 39.1% | 79.2% | 71.9% | 95.1% | 93.0% |
| 10% | 60.9% | 48.7% | 84.0% | 77.9% | 96.5% | 95.0% |
| 20% | 70.8% | 60.6% | 88.8% | 84.2% | 97.7% | 96.3% |
The results show three things.
- The exponent matters more than anything else. Caching 1% of items served 23% of requests at and 88% at . The same cache size can be nearly useless or nearly perfect.
- The 80/20 rule is one case among many. Caching 20% of items gave between 61% and 98% of requests here.
- LRU falls short of ideal, by about 13 points at the most in this run, because it sometimes evicts a popular item to make room for one that’s requested once. Policies that track frequency close part of the gap (chapter 7).
Sizing for a target
With , the ideal hit ratio has a simple form. Caching the top of items gives , where , and for large . The approximation agrees with the exact sum for one million items to six decimal places. Solving for gives the number of items needed for a target:
import math
G = 0.5772156649 # Euler's constant
def harmonic(n):
return math.log(n) + G + 1 / (2 * n)
for n in (1_000_000, 10_000_000, 100_000_000):
for target in (0.80, 0.90, 0.95):
k = math.exp(target * harmonic(n) - G) # solve H(k) = target * H(n)
print(n, target, round(k), f"{k / n:.2%}")
| Items | For 80% | For 90% | For 95% |
|---|---|---|---|
| 1 million | 56,217 (5.6%) | 237,100 (23.7%) | 486,930 (48.7%) |
| 10 million | 354,702 (3.5%) | 1,883,354 (18.8%) | 4,339,762 (43.4%) |
| 100 million | 2,238,019 (2.2%) | 14,960,009 (15.0%) | 38,678,170 (38.7%) |
Each step up in hit ratio costs much more than the last. For 100 million items, going from 80% to 90% needs almost seven times the cache, and 95% more than twice that again. These are ideal numbers, so a real LRU cache needs somewhat more.
Finding the exponent
Once there are request logs, can be measured instead of assumed. Count requests per item, sort the counts in descending order, and plot the logarithm of the count against the logarithm of the rank. A Zipf workload gives roughly a straight line, and its slope is . Real workloads often bend at the ends, so the fit should cover the range of ranks that the cache will actually hold.
What the cache leaves for the database
The hit ratio sets the load that reaches the database:
So a cache sized for 90% instead of 95% doubles the database’s read load. The database has to be sized for the misses, and also for the cache being empty after a restart, when the miss rate is briefly 100%.
Servers
The number of application servers follows from the peak rate and what one server can handle at an acceptable latency:
Capacity per server should come from a load test of the real service, not from a guess. Chapter 2 measured a minimal Python server at 135,854 trivial requests a second on one core, falling to 3,836 when each request did 290 µs of CPU work. A production service with a database call, TLS and logging will be lower again, and the load test is the only way to know by how much.
Target utilization is kept well below 100%, because latency rises steeply as a server approaches full load (chapter 10). Somewhere around 50% to 70% at peak is a common choice, and the right value depends on how fast latency degrades for the particular service.
Headroom for failure is a separate requirement. If servers run in three zones and the design must survive losing one, the remaining two zones have to carry the full peak. Each zone is then sized for half the peak, not a third, which means 1.5 times the servers that normal operation would need.
Concurrency and Little’s law
Little’s law (chapter 10) links the arrival rate , the time each request spends in the system , and the average number in the system :
For estimation, it turns rates and latencies into concurrency, and concurrency is what limits threads, connections and pool sizes. At 2,000 requests a second and 50 ms each, 100 requests are in progress on average.
The law holds whatever the distribution of request times, but it gives the average. Pools have to be sized for the peaks around that average. A simulation with random arrivals at 2,000 a second and skewed request durations shows the gap:
import random, statistics
rng = random.Random(7)
RATE, T = 2_000, 200.0 # requests per second, seconds simulated
events, t, durations = [], 0.0, []
while t < T:
t += rng.expovariate(RATE)
d = rng.lognormvariate(-3.4, 0.8) # median about 33 ms, long right tail
durations.append(d)
events += [(t, +1), (t + d, -1)]
events.sort()
area, inflight, last, peak = 0.0, 0, 0.0, 0
for when, step in events:
if when > T:
break
area += inflight * (when - last)
inflight += step; last = when; peak = max(peak, inflight)
print(RATE * statistics.fmean(durations), area / T, peak)
| Quantity | Result |
|---|---|
| Mean request duration | 45.8 ms |
| p99 request duration | 212 ms |
| 91.7 | |
| Measured average in flight | 91.5 |
| Highest number in flight at any moment | 137 |
The average matched the law. The peak was 50% higher, so a pool of 92 would have been too small at the busiest moments of those 200 seconds. Latency matters as much as rate here: if a slow dependency doubles , the number in flight doubles at the same request rate, and a pool sized for normal latency runs out.
Little’s law applies at every layer. For a database behind a cache, the rate is the miss rate and the time is the query time, which usually gives a much smaller number than the application servers need.
Numbers worth knowing
These are the numbers used in this track’s estimates. Measured ones come from the programs in chapter 1 and chapter 2, run on one Apple M3 Pro laptop, and will differ on other hardware. Sourced ones come from the named specification or documentation.
| Quantity | Value | Kind |
|---|---|---|
| Read from level-1 cache | About 1 ns | Measured |
| Random read from main memory | About 110 ns | Measured |
| Sequential memory read, one core | 75 GB/s | Measured |
| SHA-256 of 1 KB, from Python | 0.6 µs | Measured |
| Serialize a small object to JSON, Python | 1.5 µs | Measured |
| Loopback TCP round trip | 16 µs | Measured |
| New loopback TCP connection, open and close | 124 µs | Measured |
| Random 4 KB SSD read, one at a time | 77 µs median | Measured |
| Random 4 KB SSD reads, 64 in parallel | 130,000 a second | Measured |
| Sequential SSD read | 2.6 GB/s | Measured |
Durable flush (F_FULLFSYNC) | 2.9 ms median | Measured |
| Internet round trip, nearby and far server | About 9 ms and 160 ms, depending on location | Measured |
| Trivial HTTP requests on one core, connections reused | About 136,000 a second | Measured |
| Seconds in a day | 86,400 | Arithmetic |
| Distance light travels in a vacuum in 1 ms | About 300 km, slower in fiber | Sourced (SI definition of the speed of light) |
| TCP initial congestion window | 10 segments, at most 14,600 bytes | Sourced (RFC 6928) |
| PostgreSQL page size | 8 kB, by default | Sourced (PostgreSQL docs) |
| PostgreSQL row header and item identifier | 23 bytes and 4 bytes | Sourced (PostgreSQL docs) |
PostgreSQL default max_connections | Typically 100 | Sourced (PostgreSQL docs) |
| UUID | 128 bits, 16 bytes | Sourced (RFC 9562) |
| DynamoDB item size limit | 400 KB | Sourced (AWS docs) |
| DynamoDB transaction | Up to 100 actions, 4 MB in total | Sourced (AWS docs) |
Worked example 1: comments on a content site
A site lets users read and post comments under articles.
Inputs
| Input | Value | Kind |
|---|---|---|
| Daily active users | 20 million | Assumption |
| Pages with comments viewed per user per day | 30 | Assumption |
| Comments posted per user per day | 2 | Assumption |
| Busiest hour’s share of daily traffic | 12.5%, a peak factor of 3 | Assumption |
| Comment text | 300 bytes on average | Assumption |
| Response for one page of comments | 8 KB | Assumption |
| Articles with comment threads | 50 million, Zipf popularity with | Assumption, to be checked against logs |
| Indexes | Add 50% to the table size | Assumption, to be measured |
| Copies | A primary and two replicas | Design choice |
Rates
| Quantity | Calculation | Result |
|---|---|---|
| Reads a day | 20 million × 30 | 600 million |
| Writes a day | 20 million × 2 | 40 million |
| Read-to-write ratio | 600 ÷ 40 | 15 to 1 |
| Average reads a second | 600 million ÷ 86,400 | 6,944 |
| Average writes a second | 40 million ÷ 86,400 | 463 |
| Peak reads a second | 6,944 × 3 | 20,833 |
| Peak writes a second | 463 × 3 | 1,389 |
Storage
A comment row holds the text and four 8-byte columns: an ID, the article ID, the user ID and a timestamp. With PostgreSQL’s row header and item identifier, that’s about 359 bytes.
| Quantity | Calculation | Result |
|---|---|---|
| Table growth a day | 40 million × 359 bytes | 14.4 GB |
| Table growth a year | 14.4 GB × 365 | 5.2 TB |
| With indexes | 5.2 TB × 1.5 | 7.9 TB a year |
| All three copies | 7.9 TB × 3 | 23.6 TB a year |
A year of comments fits on one large PostgreSQL primary, with its replicas. Growth is the issue: after two or three years, the table is large enough that partitioning by time, archiving old threads or sharding by article (chapter 11) needs planning. Nothing has to be built for that on day one, but the choice of keys should leave it possible.
Bandwidth
Peak egress is 20,833 × 8 KB = 167 MB/s, or 1.33 Gbit/s, before protocol overhead. That’s spread across the application servers and doesn’t constrain the design.
Cache
Comments for one article are read together, so the cache holds one entry per article: a rendered page of comments, 8 KB. With 50 million threads and , the sizing formula gives:
| Target hit ratio | Threads cached | Cache memory | Database reads at peak |
|---|---|---|---|
| 80% | 1.3 million (2.6%) | 10 GB | 4,167 a second |
| 90% | 8.0 million (16.0%) | 64 GB | 2,083 a second |
| 95% | 20.0 million (40.0%) | 160 GB | 1,042 a second |
A cache of around 64 GB, on one large node or spread across a few, takes the database from 20,833 reads a second to about 2,000. That figure is for an ideal cache, and the simulation above showed LRU doing several points worse at the same size, so the plan should allow either more memory or more database reads. The exponent is the biggest uncertainty. If real traffic turns out to have , the same cache serves far less, so measuring from the first weeks of logs is worth doing early.
Servers
The capacity per server is an assumption here, and stands in for a load test: 2,000 requests a second per server at acceptable latency.
For normal operation at 60% utilization:
To survive the loss of one of three zones at peak, with the remaining servers at no more than 80%, the two surviving zones must carry 20,833 requests a second between them, or 10,417 each:
That’s 21 servers in all. On a normal day they run at about 20,833 ÷ (21 × 2,000), or 50% utilization, which is the cost of surviving a zone failure.
Concurrency
With 40 ms average request time (an assumption), the application tier has requests in flight at peak, or about 40 per server. That’s comfortable for a thread pool or an event loop, with room for the peaks above average.
At the database, cache misses at 90% arrive at about 2,083 a second. At 5 ms per query (an assumption), that’s about 10 queries in flight on average. A connection pool of a few dozen across the whole fleet is enough, far below PostgreSQL’s default limit of typically 100 connections. The danger is the cold cache: at a 0% hit ratio, the database would see 20,833 reads a second, ten times the planned load, so a restarted cache needs warming or a gradual return of traffic (chapter 7).
Writes peak at 1,389 a second. On the laptop in chapter 2, a durable flush took 2.9 ms, about 345 flushes a second. At 1,389 commits a second, group commit would need about four commits to share each flush. The server’s own flush time should be measured with pg_test_fsync, and the write rate confirmed with pgbench.
Result
A modest system: one PostgreSQL primary with two replicas, a cache of about 64 GB, and around 21 application servers across three zones. The numbers that most need checking are the server capacity, the Zipf exponent and the index share.
Worked example 2: device telemetry
A fleet of devices sends a reading every few seconds, and the readings are kept for a month for dashboards and alerts.
Inputs
| Input | Value | Kind |
|---|---|---|
| Devices | 2 million | Assumption |
| Interval between readings | 10 seconds | Assumption |
| Size of one reading as stored | 200 bytes | Assumption |
| Retention | 30 days | Requirement |
| Copies | 3 | Design choice |
| Burst after a network outage, as devices reconnect and send buffered data | 2× the steady rate | Assumption |
Rates
The traffic here doesn’t follow a daily cycle, because devices report all day. The peak comes from bursts instead.
| Quantity | Calculation | Result |
|---|---|---|
| Writes a second | 2 million ÷ 10 | 200,000 |
| Writes a day | 200,000 × 86,400 | 17.3 billion |
| Burst writes a second | 200,000 × 2 | 400,000 |
| Reads | Dashboards and alert queries | Small next to writes |
This is a write-heavy system, the opposite of the first example.
Bandwidth
Ingest is 200,000 × 200 bytes = 40 MB/s, or 320 Mbit/s, and 640 Mbit/s in a burst. That’s well within a single 10 Gbit/s link, before protocol overhead.
Storage
| Quantity | Calculation | Result |
|---|---|---|
| Raw data a day | 40 MB/s × 86,400 | 3.46 TB |
| 30 days | 3.46 TB × 30 | 104 TB |
| Three copies | 104 TB × 3 | 311 TB |
This doesn’t fit on one machine. There are three levers, and an estimate shows which ones are needed:
- Compression. Readings from one device change slowly, so time-series data usually compresses well. The ratio has to be measured on real readings. At an assumed 10×, three copies take 31 TB.
- Downsampling. Keeping full resolution for a few days and per-minute averages after that cuts the older data by a factor of 6, at one reading per 10 seconds.
- Partitioning. Splitting the data by device across several machines (chapter 11), each holding a share.
Row overhead matters for small records. Stored as one PostgreSQL row per reading, each 200-byte reading would carry 27 bytes of header and item identifier, adding 13.5%. Storing readings in batches, one row per device per minute, spreads that overhead across six readings. A time-series store or a columnar format avoids most of it.
Writes and flushes
At one commit per reading, 200,000 commits a second would need 200,000 flushes a second, or extremely good group commit. Batching changes the picture completely:
| Readings per commit | Commits a second |
|---|---|
| 1 | 200,000 |
| 100 | 2,000 |
| 1,000 | 200 |
| 5,000 | 40 |
At 1,000 readings per commit, 200 commits a second is within even the slow 345 flushes a second measured on the laptop in chapter 2. So the ingest path should collect readings into batches, usually in a queue or log in front of the store (chapter 8). Batching adds latency: a reading waits for its batch, so the batch size is bounded by how fresh the dashboards must be.
Concurrency
If each device sends its reading over a short request that takes 20 ms at the gateway (an assumption), Little’s law gives requests in flight on average, and 8,000 in a burst. If devices keep a connection open instead, the gateways hold 2 million connections, whatever the request rate. Either way, this calls for event-loop servers, which handle many idle connections cheaply, rather than a thread per connection (chapter 2).
Result
Network bandwidth is no concern. The two hard parts are the write rate, which needs batching, and the storage volume, which needs compression, downsampling or partitioning, probably all three. The design should start from measured compression ratios and a decision on how long full resolution is really needed.
Common errors
| Error | Effect | Check |
|---|---|---|
| Sizing for the average | Overload at every daily peak | State a peak factor |
| Mixing bits and bytes | A factor of 8 wrong | Links in bits, storage in bytes |
| Forgetting copies and indexes | Storage several times too small | Apply the multipliers |
| Assuming the 80/20 rule | Cache far too small or far too large | Simulate, or measure |
| Sizing pools from Little’s law averages | Waits at the peaks | Add margin, and measure the peak in flight |
| Ignoring failure headroom | An outage when one zone fails | Size for the loss of the largest unit |
| Treating assumptions as facts | Confident numbers nobody checks | Label every input, and replace it with a measurement |
The takeaway
An estimate is a chain from users to rates, storage, bandwidth, cache, servers and concurrency, and every link is a number that can be written down and checked. Peak factors and Zipf exponents are assumptions until they’re measured, and the cache size a workload needs depends on its exponent far more than on any rule of thumb. Little’s law turns rates into concurrency, but it gives an average, and the limits are hit at the peaks.