Scalability & CAP
Scalability from "one server is full" to CAP and capacity math: horizontal scaling, statelessness, replication, and CP-vs-AP — each with a runnable Python simulation.
Scalability is how a system handles growth. When one server can't keep up you either buy a bigger one (vertical) or add more (horizontal) — and adding more only works if your app is stateless. This chapter climbs from those basics through replication and the CAP theorem to back-of-envelope capacity estimation, with runnable Python simulations.
The words you'll hear (in plain terms):
| Term | What it actually means |
|---|---|
| scaling | handling more load: vertical (bigger box) or horizontal (more boxes). |
| stateless | a server keeps no per-user memory, so any server can handle any request. |
| replication | keeping copies of data on several machines for safety and read speed. |
| consistency | whether every read sees the latest write. |
| CAP | under a network partition, choose Consistency or Availability — not both. |
What you need before starting:
- The HLD chapter (SD5) sets the scene.
- Basic Python; the sims are plain Python.
- Comfort with rough arithmetic for capacity estimates.
New to the topic? Read this box, then take the chapters in order — each section is tagged essential → expert so you always know the depth you're at.
Learning objectives
- Distinguish vertical from horizontal scaling and why statelessness matters.
- Simulate primary-replica replication and eventual consistency.
- State the CAP theorem and classify systems as CP or AP.
- Estimate server/replica counts from a QPS target.
code/sd6-scalability/ — pure Python / sqlite3, runs with no setup.1 · Two ways to scale essential
Here's the moment every successful product eventually hits. Your app was fine for months. Then a post goes viral, or a big customer onboards their whole team, and suddenly the dashboards go red: CPU pinned at 100%, requests queueing, timeouts. The 3am question is brutally simple — the one server is full, now what? There are only two answers to that question, and knowing which one to reach for (and why) is the entire foundation of scalability.
The everyday analogy is a restaurant that's slammed. You have two moves. Move one: replace your one overworked chef with a faster, stronger super-chef who can cook twice as much. That's vertical scaling — same single worker, just bigger. Move two: hire a second chef, then a third, and split the orders between them. That's horizontal scaling — more workers, work divided. The first is instant and requires zero change to how the kitchen runs; the second is more work to coordinate but has no ceiling.
And that ceiling is the whole point. The super-chef eventually can't get any faster — there's no chef alive who cooks a thousand meals a minute, just as there's no single machine with unlimited CPU and RAM. Vertical scaling is capped (the biggest cloud box is only so big) and gets expensive fast — doubling a machine's power often more than doubles its price. Horizontal scaling, by contrast, uses cheap commodity boxes and grows almost without limit: Google and Netflix don't run on one titanic computer, they run on fleets of ordinary ones. That's why modern systems scale horizontally.
The common mistake is thinking these are equal options you pick by taste. They're not — they're a sequence. You reach for vertical first because it's dead simple (bump the instance size, no code changes, done by lunch), and you switch to horizontal when vertical runs out of room. The catch — the thing this whole chapter really teaches — is that horizontal scaling only works if your app servers are stateless: any box must be able to handle any request. Hire a second chef, but if only the first chef knows which orders are half-cooked, adding staff just creates chaos. Get statelessness wrong and adding machines makes things worse, not better. The diagram traces that exact progression: load grows → go vertical → then go horizontal.
Vertical = a bigger machine (simple, but capped and pricey). Horizontal = more machines (cheap, effectively unlimited — but requires stateless app servers). Modern systems scale horizontally.
This picture answers one question every growing system hits: "one server is full — now what?" It shows the two directions you can grow in, read left to right.
- The left box ("More load") is the trigger: more users, more requests — "pressure" your current server can't handle.
- The first arrow points to "Vertical: bigger box" — you keep one machine but make it more powerful (more CPU/RAM). The sub-label "simple, capped" is the catch: it's easy, but there's a limit to how big one machine can get, and big machines are expensive.
- The second arrow points to "Horizontal: more boxes" — instead of one huge machine you add many ordinary ones and split the work between them. "stateless, unbounded" means: if your servers keep no per-user memory (stateless), you can keep adding boxes almost without limit.
- The arrows are a progression, not a network diagram — they read like "load grows → first you go vertical → eventually you must go horizontal." Modern large systems live on the right-hand side.
In short: Vertical = a bigger machine (quick fix, hits a ceiling). Horizontal = more machines (cheaper, scales far) — but only works if the app is stateless. The rest of this chapter is really about making horizontal scaling safe.
2 · Statelessness, demonstrated essential
This is the bug that ruins the launch you were so proud of. A team adds a second server behind a load balancer to handle the new traffic — textbook horizontal scaling. It deploys clean. Then users start reporting they're randomly logged out mid-session, carts empty themselves, half-filled forms vanish. Nothing is broken in any single server; each one works perfectly on its own. The problem is that requests bounce between two boxes that don't share what they remember. That invisible failure has a name, and avoiding it is what "stateless" means.
Here's the plain-language version. State is just "stuff a server remembers between requests" — who's logged in, what's in your cart, how far through a wizard you are. A server is stateful when it keeps that memory inside itself, and stateless when it keeps none and instead reads and writes everything to a shared place outside itself (a cache or database). The analogy that makes it stick: a stateful server is a barista who remembers your usual by face — great until you walk into a different branch where nobody knows you. A stateless server is a coffee chain where your order lives on an app both branches can read; walk into any store, they pull up your usual instantly. The knowledge isn't in the barista's head — it's in the shared system.
Why this earns a whole section: statelessness is the precondition for everything else in this chapter. Load balancers, auto-scaling, rolling deploys, surviving a crashed box — all of them assume any server can pick up any request. The moment a server hoards state, you're forced into "sticky sessions" (pinning each user to one box), which quietly cancels most of the benefit of having many boxes: you can't balance load evenly, you can't drain a box for maintenance without dropping its users, and one crash logs out everyone it was holding.
The common mistake is the sneaky one: storing "just a little" state in memory because it's fast and easy — a cache dictionary here, a logged-in-users set there. It works flawlessly on your laptop and in a one-server test, because there's only ever one memory. It fails the instant a second box exists and a request lands on the "wrong" one. The lab below makes the failure visible in the smallest possible code: watch the same operation return False on a stateful pair of servers and True once the state moves to a shared store.
False. On the right both servers read/write one shared store, so any box answers True.
Compare the two halves left-to-right: same user, same request, only the location of the "memory" changes.
- The left (stateful) half gives each server its own
sessionsdictionary. The login lands on Server A (green), but when the load balancer sends the next request to Server B (red), B never saw it — the red arrow andcheck(ava) → Falseare the bug. - The right (stateless) half pushes the session out to a single shared store. Both servers only read and write there, so it doesn't matter which box the request hits.
- The payoff is the bottom labels:
Falseon the left versusTrueon the right — the same operation, decided entirely by where the state lives.
In short: Keep state inside a server and you must pin users to it; move state to a shared store and every box becomes interchangeable — the precondition for horizontal scaling.
stateless.py# STATEFUL: session lives in one server's memory -> must be sticky
class StatefulServer:
def __init__(self): self.sessions = {}
def login(self, user): self.sessions[user] = "token"
def check(self, user): return user in self.sessions
s1, s2 = StatefulServer(), StatefulServer()
s1.login("ava")
print("stateful, wrong server:", s2.check("ava")) # False! request hit s2
# STATELESS: session in a SHARED store -> any server works
shared = {}
class StatelessServer:
def login(self, user): shared[user] = "token"
def check(self, user): return user in shared
a, b = StatelessServer(), StatelessServer()
a.login("ava")
print("stateless, any server:", b.check("ava")) # True
stateful, wrong server: False
stateless, any server: True
This lab shows why horizontal scaling needs stateless servers. It builds two toy servers twice: first the broken "stateful" way (each server remembers logins in its own memory), then the fixed "stateless" way (logins live in a store both servers share).
- StatefulServer keeps
self.sessions = {}— a private dictionary inside that one server. Whens1.login("ava")runs, onlys1knows about Ava. - So
s2.check("ava")returns False: the request landed on the wrong server, ands2never saw the login. This is why stateful servers need "sticky" routing — every request from a user must return to the same box. - StatelessServer fixes it by reading and writing a single
shareddictionary that lives outside the servers (think of a shared cache or database). Bothaandbtalk to the samesharedstore. - Now
a.login("ava")writes toshared, andb.check("ava")reads the samesharedstore and returns True — any server can answer any request. That is exactly what lets you add more boxes freely.
What the output means: stateful, wrong server: False then stateless, any server: True — the first design loses the login when the request hits a different server; the second one doesn't, because the state is shared.
Try this: Add a third stateless server c = StatelessServer() and call c.check("ava") — it also returns True without any extra work, because the login lives in the shared store, not in any single server.
3 · Replication & eventual consistency intermediate
You've felt this one as a user, even if you didn't know its name. You change your profile photo, hit save, see the confirmation — then open the app on your phone and the old photo is still there for a few seconds before it updates. Or you post a comment, refresh, and it's briefly gone. Nothing is broken. You've just witnessed replication lag: your write landed on one machine, but the copy you happened to read from hadn't caught up yet. This section is about why that gap exists and why, most of the time, it's a feature and not a bug.
First, why keep copies at all? Two reasons, both about survival and speed. Survival: if all your data lives on one machine and that machine dies, so does your company — replication keeps standby copies so a failure is a shrug, not a catastrophe. Speed: reads usually vastly outnumber writes (think how many times a tweet is read versus written), so you let a fleet of read-only copies (replicas) answer all those reads while one machine (the primary) handles writes. One writer, many readers — that's the classic primary-replica shape.
The analogy: think of the primary as the original whiteboard in a meeting room and replicas as photos of it on people's phones. When someone updates the whiteboard, the phone photos don't change until each person re-snaps it. There's always a moment where the whiteboard says one thing and a phone still shows the old version. That moment is the lag — and the promise that every phone will eventually match the whiteboard, just not instantly, is exactly what eventual consistency means. "Eventual" is a real, honest word here: correct soon, not correct now.
The common mistake — and it's a serious one — is assuming a read right after a write always returns the fresh value. On a single machine it does; across replicas it often doesn't, and code that assumes "write then immediately read the same thing back" will flake intermittently in ways that are maddening to debug (works on your machine, fails one time in fifty in production). The engineering judgment is knowing where the lag is acceptable: a stale like-count or view-count for half a second is fine, nobody's harmed. A stale bank balance, or an inventory count that lets you oversell the last item, is not. The lab below strips this to its bones — a primary that takes a write and a replica that returns None until sync() runs — so you can see the stale-read window open and close with your own eyes.
Replication keeps copies on multiple machines: survive failures and serve reads from replicas. The catch is sync lag — a replica may briefly serve stale data (eventual consistency).
Follow the write from the left: it lands on the primary, then flows out to the replicas — but not instantly.
- The blue arrow into the Primary is a write (
x=1). The primary is the single machine that accepts changes. - The dashed grey arrows are the asynchronous catch-up to Replica 1 and Replica 2. Dashed and labelled "lags" because the copy trails the primary by a small delay.
- The two bottom labels are the payoff: a read that hits a replica before sync returns
None(stale, in red); the same read after sync returns1(green). That gap is exactly eventual consistency.
In short: Writes go to one primary, reads fan out to many replicas — cheap read scaling, but a brief window where a replica can answer with old data.
replication.pyclass Primary:
def __init__(self): self.data = {}
def write(self, k, v): self.data[k] = v
class Replica:
def __init__(self, primary): self.primary = primary; self.data = {}
def sync(self): self.data = dict(self.primary.data) # periodic catch-up
def read(self, k): return self.data.get(k)
primary = Primary(); replica = Replica(primary)
primary.write("x", 1)
print("before sync (stale):", replica.read("x")) # None
replica.sync()
print("after sync:", replica.read("x")) # 1
before sync (stale): None
after sync: 1
Replication means keeping copies of your data on more than one machine — so you survive a machine failure and can serve read requests from the copies. This lab models the simplest setup: one primary that takes writes, and one replica that copies from it. It also shows the catch: the replica can lag behind and briefly serve stale (out-of-date) data.
- Primary holds the real data in
self.dataand itswrite(k, v)method stores a value — this is the machine that accepts changes. - Replica starts empty and only catches up when
sync()runs:self.data = dict(self.primary.data)copies the primary's current data. In a real system this sync happens continuously but with a small delay. - The script writes
primary.write("x", 1), then immediately reads from the replica before syncing — soreplica.read("x")returnsNone. The replica simply hasn't received the update yet. This gap is called eventual consistency: the copy will be correct eventually, just not this instant. - After
replica.sync()the copy is up to date, soreplica.read("x")now returns1.
What the output means: before sync (stale): None then after sync: 1 — the replica served wrong (empty) data in the tiny window before it caught up, then correct data afterward.
Try this: Write a second value (primary.write("y", 2)) after the sync and read replica.read("y") before syncing again — you'll see None once more. Every new write reopens the stale-read window until the next sync.
4 · The CAP theorem advanced
Picture the exact moment things go wrong. Two of your datacenters normally chatter constantly, keeping their data in step. Then a fiber line gets cut, or a switch fails, and they can't talk to each other — but both are still up, and users are still hitting both. A customer updates their data on the east coast. A moment later they read it from the west coast, which hasn't heard the update and can't hear it because the link is down. What should the west-coast side do: answer with the old value, or refuse to answer at all? There is no third option, and that forced dilemma is the CAP theorem.
Let's define the three letters plainly. Consistency (C) = every read sees the most recent write (no stale answers). Availability (A) = every request gets a non-error response (the system always answers). Partition-tolerance (P) = the system keeps working even when the network splits and machines can't reach each other. CAP's claim is that when a partition is happening, you can have C or A but not both at once.
Here's the piece almost everyone gets wrong, so read slowly. CAP is not "pick 2 of 3, like choosing toppings." P is not optional — networks genuinely fail, cables get cut, packets drop, so any real distributed system must tolerate partitions whether you like it or not. That collapses the "choose 2 of 3" myth into the only choice that actually exists: during a partition, do you sacrifice C or sacrifice A? A system that keeps consistency by refusing to answer is called CP (e.g. traditional SQL clusters, ZooKeeper, etcd — the sort of things that guard money and configuration). A system that keeps answering, accepting possibly-stale data, is AP (e.g. Cassandra, DynamoDB, DNS — the sort of things that must never go dark). And crucially — this is the honest, precise version — the trade only bites during a partition. When the network is healthy, a well-built system happily gives you both consistency and availability; CAP is a statement about the failure moment, not about everyday operation.
The common mistake is calling a system "a CA system" or claiming you "chose CP over AP" as a permanent, global personality trait. Reality is finer-grained: the choice is made per-operation, at the instant a partition occurs, and mature systems even let you pick per-request (a "read-your-writes" query can demand consistency while a background analytics read tolerates staleness). The tidy "CP vs AP" split is the right interview-level model — clear and correct as far as it goes — but know that PACELC refines it: even with no partition (Else), you still trade Latency against Consistency, because keeping copies perfectly in sync costs round-trips. The lab below makes the core dilemma tangible: the same partition, one node that refuses to stay correct and one that answers to stay up.
CAP: when a network partition splits your machines, you must choose Consistency (reject requests to stay correct) or Availability (answer, possibly stale). Partitions happen, so P isn't optional — the real choice is CP vs AP.
Read it top-to-bottom: first the network breaks, then the same read forces one of two answers.
- The top row is two nodes that normally sync; the red dashed line with "✂ link cut" is the partition — they can no longer reach each other, yet both are still up and taking requests.
- The bottom-left red box (CP) refuses the request to avoid serving wrong data — consistency wins, availability is sacrificed. The examples below it (SQL, ZooKeeper, etcd) guard money and configuration.
- The bottom-right green box (AP) answers anyway with whatever it last had — availability wins, freshness is sacrificed. Its examples (Cassandra, DynamoDB, DNS) must never go dark.
In short: A partition is real and unavoidable (P), so the only choice is CP (refuse to stay correct) vs AP (answer to stay up) — and only during the split; a healthy network gives you both.
| Choice | During a partition | Examples |
|---|---|---|
| CP | reject to stay correct | traditional SQL, ZooKeeper, etcd |
| AP | answer, maybe stale | Cassandra, DynamoDB, DNS |
cap.pyclass Node:
def __init__(self, mode): self.mode = mode; self.value = None; self.reachable = True
def read(self):
if not self.reachable and self.mode == "CP":
return ("error", "unavailable to stay consistent") # CP: refuse
return ("ok", self.value) # AP: answer (maybe stale)
cp = Node("CP"); cp.value = "v1"; cp.reachable = False # partitioned off
ap = Node("AP"); ap.value = "v1"; ap.reachable = False
print("CP node:", cp.read()) # refuses
print("AP node:", ap.read()) # answers with possibly-stale v1
CP node: ('error', 'unavailable to stay consistent')
AP node: ('ok', 'v1')
This lab makes the CAP theorem concrete. CAP says: when the network partitions (machines can't reach each other), a system must pick one of two behaviours — stay Consistent (only ever return correct data, even if that means refusing) or stay Available (always answer, even if the data might be stale). You cannot have both during a partition. Systems that pick correctness are called CP; systems that pick answering are AP.
- A Node has a
mode("CP"or"AP"), a storedvalue, and areachableflag. Settingreachable = Falsesimulates "this node is cut off by a network partition." - In
read(), the CP node checks: if it's unreachable and its mode is CP, it refuses — returning an error rather than risk serving out-of-date data. Correctness wins over answering. - Any other case (including the AP node) falls through to
return ("ok", self.value)— it answers anyway with whatever value it last had, which might be stale. Availability wins over correctness. - The script cuts off both nodes (
reachable = False), then reads each. The CP node returns an error; the AP node returns its possibly-old"v1". Same partition, opposite choices.
What the output means: CP node: ('error', 'unavailable to stay consistent') and AP node: ('ok', 'v1') — CP would rather fail than lie; AP would rather answer than fail.
Try this: Set the CP node's reachable = True and read again — it now answers normally. That's the real lesson: CAP only forces the hard choice during a partition; when the network is healthy you get both consistency and availability.
5 · Expert — capacity estimation expert
Here's a scene that plays out in every system-design interview and every real design review. Someone asks, "so how many servers does this need?" and the room splits into two kinds of engineers. One kind freezes, because it feels like you'd need to build the thing and measure it. The other kind grabs the nearest whiteboard, writes down three numbers, multiplies and divides for ninety seconds, and says "about four app servers and two read replicas, call it six with headroom." That second engineer isn't smarter — they just know that capacity is arithmetic, not clairvoyance. This section teaches you to be that engineer.
The technique has a name — back-of-the-envelope estimation — and the whole idea is that being roughly right beats being precisely stuck. The analogy is planning a wedding: you don't email 200 guests to ask exactly how hungry they'll be. You estimate — "200 people, most eat one plate, some go back for seconds, so cater for ~250" — and you're fine. Servers are the same: you take a couple of known facts (how many users, how often they act), turn them into requests-per-second, and divide by what one machine can handle. The magic step most beginners skip is the peak multiplier: traffic is never flat, it clumps at lunch, at 8pm, on launch day, so you size for the busy hour, not the daily average, or you'll fall over precisely when everyone's watching.
The mental toolkit for this is a handful of latency numbers worth committing to memory — not to recite, but so your estimates land in the right order of magnitude. Knowing that a memory read is ~100 nanoseconds while a cross-continent round-trip is ~150 milliseconds — a gap of over a million times — instantly tells you why chatty cross-region calls kill performance and why caching in memory is transformative. You don't need these exact; you need to never be off by a factor of a thousand.
The common mistake is false precision — spending an hour deriving "7.3 servers" from shaky assumptions, when the honest answer is "single digits, let's say 8 with headroom." Interviewers and senior reviewers grade the method, not the digits: did you state your assumptions out loud, go from users → average QPS → peak QPS → divide by per-server capacity → add headroom? Say it as a narrative and your assumptions become correctable in real time. The lab below runs exactly that pipeline in code — a million users become a concrete, defensible fleet size — and the same shape of math (estimate the load, divide by per-unit capacity) will later size your databases, caches, and queues in SD7.
Design work needs rough numbers: 1 server or 1,000? Learn to estimate from a few facts. Latency figures every engineer should know, roughly:
Read it left-to-right as a pipeline — each box feeds the next, and every arrow is one arithmetic step.
- The first box holds your two known facts: daily active users and requests per user per day. Divide by the 86,400 seconds in a day to get the average QPS (~231).
- The middle amber boxes apply the step most beginners skip — a peak factor (×3 here) — because traffic clumps at busy hours, giving peak QPS (~694). Sizing for the average alone means falling over exactly when everyone is watching.
- The green box is the payoff: divide peak QPS by what one server handles (500 qps) and round up (ceiling division) to a concrete, defensible fleet — 2 app servers plus headroom.
In short: users → average QPS → ×peak → ÷ per-server capacity → round up. A million users becomes a small, defensible number of boxes, and reviewers grade that method.
| Operation | ~Time |
|---|---|
| memory read | ~100 ns |
| SSD read | ~100 µs |
| same-region round-trip | ~0.5 ms |
| indexed DB query | ~1–10 ms |
| cross-continent round-trip | ~150 ms |
capacity.pydef size_fleet(dau, req_per_user_per_day, per_server_qps, peak_factor=3):
avg_qps = dau * req_per_user_per_day / 86_400
peak_qps = avg_qps * peak_factor # traffic is bursty
servers = -(-int(peak_qps) // per_server_qps) # ceil division
return round(avg_qps), round(peak_qps), servers
avg, peak, servers = size_fleet(dau=1_000_000, req_per_user_per_day=20, per_server_qps=500)
print(f"avg {avg} qps, peak ~{peak} qps -> {servers} app servers (+ headroom)")
# read-heavy? size read replicas separately:
reads = peak * 0.9 # 90% reads
replicas = -(-int(reads) // 800) # each replica ~800 read qps
print(f"peak reads ~{round(reads)} qps -> {replicas} read replicas")
avg 231 qps, peak ~694 qps -> 2 app servers (+ headroom)
peak reads ~625 qps -> 1 read replicas
This lab is capacity estimation — the back-of-the-envelope math that answers "how many servers do I need?" from a few facts. It's the exact reasoning interviewers want to hear, done step by step: users → requests per second → peak → number of machines.
avg_qps = dau * req_per_user_per_day / 86_400turns daily activity into an average QPS (queries per second).dauis daily active users, and86_400is the number of seconds in a day — the underscores are just readability, Python ignores them.peak_qps = avg_qps * peak_factormultiplies by 3 because traffic is bursty — real usage clumps at busy hours, so you must size for the peak, not the average, or you'll fall over when it matters.servers = -(-int(peak_qps) // per_server_qps)is a trick for rounding up (ceiling division): if peak is 694 and each server handles 500, you need 2 servers, not 1. Normal//rounds down, so negating twice flips it to round up.- The second half sizes read replicas separately:
reads = peak * 0.9assumes 90% of traffic is reads, then divides by each replica's read capacity (800 qps) with the same round-up trick. Reads and writes often need different amounts of hardware.
What the output means: avg 231 qps, peak ~694 qps -> 2 app servers and peak reads ~625 qps -> 1 read replicas — from a million users you land on a small, concrete fleet size.
Try this: Change dau to 10_000_000 (10× the users) and re-run — every number scales up and you'll need roughly 10× the servers. Then drop per_server_qps to 200 and watch the server count jump: weaker machines mean more of them.
Exercise SD6.1 — Scale a read-heavy service
Context: Sizing a read-heavy service end to end is the bread-and-butter scalability exercise: estimate load, provision the app and replica tiers, choose a consistency stance, and prove the read-routing behaves under lag.
Your task: For a service with 10M daily active users, 90% reads, and a 5ms DB query, estimate peak QPS, size both app servers and read replicas, choose CP or AP with justification, and extend the replication simulation to route reads to a replica pool and writes to the primary, showing a stale read during lag.
Requirements:
- Estimate peak QPS from DAU and the read/write split
- Size app servers and read replicas from the QPS and per-node limits
- Choose CP or AP for this workload and justify the choice
- Route reads across a pool of replicas and writes to the primary
- Demonstrate a stale read occurring during replication lag
💡 Hint: Peak QPS is well above the daily average, so apply a peak multiplier; replicas carry the read fan-out while writes stay on the primary, which is where the stale-read window appears.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: There are two ways to add capacity: a bigger box (vertical) or more boxes (horizontal). Vertical is simplest until you hit the largest instance; past that, only horizontal keeps growing.
Your task: Model the maximum throughput of vertical scaling (one box, capped at some core count) versus horizontal scaling (add nodes), and show where horizontal overtakes.
Requirements:
- Model vertical throughput as cores times per-core rps, capped at a maximum core count
- Model horizontal throughput as nodes times per-node rps, uncapped
- Show vertical throughput plateauing once the biggest box is reached
- Show horizontal throughput continuing to rise as nodes are added
- Conclude that beyond the biggest single box, only horizontal scaling adds capacity — which requires statelessness
💡 Hint: The vertical function needs a min(cores, max_cores) cap; the crossover is wherever added nodes exceed what the capped single box can ever do.
Show solution
Vertical hits a ceiling; horizontal keeps going:
def vertical(cores, per_core_rps=100, max_cores=32):
return min(cores, max_cores) * per_core_rps # capped by biggest box
def horizontal(nodes, per_node_rps=800):
return nodes * per_node_rps # add boxes
print("vertical @64 cores:", vertical(64)) # 3200 (capped at 32 cores)
print("horizontal @5 nodes:", horizontal(5)) # 4000
# Beyond the biggest single box, only horizontal scaling adds capacity.
Vertical scaling is simplest until you hit the largest instance; past that, throughput only grows by adding nodes — which requires statelessness.
Context: Horizontal scaling only works if any node can serve any request. Session state kept in one node's local memory pins users to that node and breaks load balancing the moment a request lands elsewhere.
Your task: Contrast a stateful server that stores sessions in local memory (which fails when a request hits the wrong node) with a stateless one that stores sessions in a shared store (where any node works).
Requirements:
- Build a stateful node that keeps sessions in its own memory
- Show a login on one node not being visible on a second node
- Build a stateless node that reads and writes sessions to a shared store
- Show a login on one stateless node being visible from another
- Explain that moving state to a shared store is the precondition for round-robin load balancing
💡 Hint: Give the stateful nodes separate dicts and the stateless nodes one shared dict; the difference between True False and True True is the whole point.
Show solution
State in local memory pins a user to a node and breaks load balancing:
# Stateful: session lives on ONE node -> requests to another node fail
class StatefulNode:
def __init__(self): self.sessions = {}
def login(self, user): self.sessions[user] = "token"
def check(self, user): return user in self.sessions
n1, n2 = StatefulNode(), StatefulNode()
n1.login("ada")
print(n1.check("ada"), n2.check("ada")) # True False <-- broken on n2!
# Stateless: session in a SHARED store; any node works
shared = {}
class StatelessNode:
def login(self, user): shared[user] = "token"
def check(self, user): return user in shared
s1, s2 = StatelessNode(), StatelessNode()
s1.login("ada")
print(s1.check("ada"), s2.check("ada")) # True True
Moving session state to a shared store makes every node interchangeable — the precondition for round-robin load balancing to work.
Context: Read replicas scale read throughput, but asynchronous replication means a replica can lag the primary. That lag window is exactly where eventual consistency shows up.
Your task: Simulate a primary with asynchronous replicas and show a read hitting a stale replica before replication has caught up, then the correct value after it runs.
Requirements:
- Model a primary plus a set of replicas and a pending-write buffer
- On a write, update the primary and queue the change as pending (not yet on replicas)
- Provide a
replicatestep that flushes pending writes to every replica - Read a replica before replication and get a stale (missing) value
- Read again after replication and get the correct value — eventual consistency in action
💡 Hint: Keep writes in a pending list until replicate() flushes them; the gap between write and flush is the "eventual" — fine for a like count, not a bank balance.
Show solution
Async replication trades freshness for read throughput:
class Replicated:
def __init__(self, n_replicas):
self.primary = {}
self.replicas = [{} for _ in range(n_replicas)]
self.pending = []
def write(self, k, v):
self.primary[k] = v
self.pending.append((k, v)) # not yet on replicas
def replicate(self):
for k, v in self.pending:
for r in self.replicas: r[k] = v
self.pending.clear()
def read_replica(self, i, k):
return self.replicas[i].get(k)
db = Replicated(2)
db.write("x", 1)
print(db.read_replica(0, "x")) # None <-- stale, replication hasn't run
db.replicate()
print(db.read_replica(0, "x")) # 1 <-- eventually consistent
The window between write and replicate is the "eventual" in eventual consistency — fine for a like count, not for a bank balance.
Context: The CAP theorem says that under a network partition you must choose between consistency and availability — there is no "CA" once the network splits. Systems encode this choice as a policy.
Your task: Model both a CP and an AP write policy and show that under a partition the CP policy refuses the write while the AP policy accepts it, risking divergence.
Requirements:
- Take a policy and a partitioned flag as inputs
- Under a partition, have CP reject the write to stay consistent
- Under a partition, have AP accept the write to stay available (risking divergence)
- When there is no partition, accept the write normally
- Explain the domain examples: a ledger chooses CP, a shopping cart often chooses AP and reconciles later
💡 Hint: The branch is just if partitioned then split on policy; the teaching point is that you are choosing which property to sacrifice, never keeping both.
Show solution
During a partition, CP and AP make opposite calls:
def handle_write(policy, partitioned):
if partitioned:
if policy == "CP":
return "REJECT (stay consistent, sacrifice availability)"
if policy == "AP":
return "ACCEPT (stay available, risk divergence)"
return "ACCEPT (no partition)"
for p in ("CP", "AP"):
print(p, "->", handle_write(p, partitioned=True))
# CP -> REJECT (stay consistent, sacrifice availability)
# AP -> ACCEPT (stay available, risk divergence)
There's no "CA" under a real partition — you only choose which to give up. A ledger picks CP; a shopping cart often picks AP and reconciles later.
Context: Back-of-envelope storage and bandwidth estimates decide whether one database suffices or you need to shard. It's the classic interview and design-review calculation.
Your task: For a service with D daily writes of S bytes each, retained for R years, compute the total storage and the average write bandwidth.
Requirements:
- Compute per-day bytes as daily writes times bytes each
- Compute total bytes across the full retention period (days times years)
- Compute average write bandwidth as per-day bytes divided by seconds per day
- Return the figures in human-friendly units (GB per day, TB total, KB/s average)
- Use the totals to judge whether one DB suffices or sharding is needed
💡 Hint: Keep the unit conversions explicit (bytes to GB/TB, per-day to per-second); the total is what you carry into the sharding decision in the next chapter.
Show solution
The classic interview/design estimate, as a calculator:
def estimate(daily_writes, bytes_each, retention_years):
per_day_bytes = daily_writes * bytes_each
total_bytes = per_day_bytes * 365 * retention_years
avg_write_bps = per_day_bytes / 86400 # bytes per second
GB = 1024 ** 3
return {
"per_day_GB": round(per_day_bytes / GB, 2),
"total_TB": round(total_bytes / GB / 1024, 2),
"avg_write_KBps": round(avg_write_bps / 1024, 1),
}
print(estimate(daily_writes=10_000_000, bytes_each=500, retention_years=3))
# {'per_day_GB': 4.66, 'total_TB': 4.99, 'avg_write_KBps': 56.5}
10M writes/day × 500B ≈ 4.7 GB/day, ≈ 5 TB over 3 years — enough to decide whether one DB suffices or you need sharding (SD7).
Context: Scaling is staged, not a single leap. As projected load grows, the right move changes from a single box, to read replicas, to sharding — and simple thresholds can encode that escalation.
Your task: Given projected read and write rps at launch, six months, and a year, write a decision function that chooses at each stage between a single box, adding replicas, and sharding, using simple thresholds.
Requirements:
- Keep everything on a single box while total load is under its capacity
- Add read replicas (or replicas plus a read cache) when read rps exceeds one box
- Escalate to sharding writes once write rps exceeds what one box handles
- Return the list of scaling moves recommended for the given load
- Encode the key fact that replicas scale reads but never writes
💡 Hint: Split the read and write decisions: replicas answer read pressure, sharding answers write pressure — so a high write rps must trigger sharding no matter the read path.
Show solution
A decision function that maps load to the right scaling move:
def scaling_decision(read_rps, write_rps,
single_box_cap=1000, replica_read_cap=8000):
plan = []
if read_rps + write_rps <= single_box_cap:
plan.append("single box")
else:
if read_rps > single_box_cap:
plan.append("add read replicas" if read_rps <= replica_read_cap
else "add replicas + read cache")
if write_rps > single_box_cap:
plan.append("shard writes (SD7)") # replicas don't scale writes
return plan
print(scaling_decision(500, 300)) # ['single box']
print(scaling_decision(5000, 400)) # ['add read replicas']
print(scaling_decision(9000, 2000)) # ['add replicas + read cache', 'shard writes (SD7)']
Replicas scale reads but never writes — so once write rps exceeds one box, the plan correctly escalates to sharding.
✓ Checkpoint — you can move on when you can…
- Contrast vertical/horizontal scaling and explain statelessness.
- Simulate replication and eventual consistency.
- State CAP and classify systems as CP or AP.
- Estimate servers and replicas from a QPS target.
Knowledge check check yourself
The lesson contrasts vertical and horizontal scaling and says modern systems scale horizontally. What is the difference between the two, and what property must the app servers have for horizontal scaling to work?
Show answer
According to the CAP theorem as taught here, why isn't Partition-tolerance really optional, and what distinguishes a CP system from an AP system during a network partition?