Adding capacity sounds simple. The reason it isn’t is that some things can be duplicated and some things can’t — and knowing which is which is most of system design.
Prerequisites: Performance Metrics Time to read: ~20 minutes
Your service handles 1,000 QPS. You need 100,000. What do you do?
There are exactly two options — get a bigger machine, or get more machines — and a third thing people forget: make the work smaller.
The interesting part isn’t the choice. It’s the consequences. Going from one machine to many changes the nature of your system: state has to live somewhere shared, failures become partial rather than total, and problems that were impossible (two servers disagreeing) become routine.
Buy a bigger machine. 4 cores → 64 cores. 16 GB → 1 TB of RAM.
Why it’s better than beginners think:
📐 Reality check that changes people’s instincts: Stack Overflow served ~2 billion page views a year from nine web servers and one SQL Server with plenty of headroom. Most systems you’ll ever build will never outgrow a single large database.
Where it stops working:
Add more machines and spread the load.
What you gain:
What it costs you — and this is the real content of this chapter:
| Vertical | Horizontal | |
|---|---|---|
| Complexity | Low | High |
| Ceiling | Hard limit | Effectively none |
| Cost curve | Super-linear | Roughly linear |
| Fault tolerance | None | Built in |
| Code changes | Usually none | Statelessness required |
| Failure mode | Total | Partial |
| Deployment | Downtime | Rolling, zero-downtime |
| Best for | Databases, early-stage products, stateful systems | Stateless app tiers, caches, workers |
🎙️ The answer interviewers want to hear: “I’d scale vertically first because it’s simpler and we’re nowhere near the ceiling, and design the app tier to be stateless so we can scale horizontally the moment we need to. The database is the piece that will force the harder conversation.”
That sentence shows you know both, know the ordering, and aren’t over-engineering.
This is the most important idea in the chapter.
Stateless = the server keeps nothing about a client between requests. Every request carries what it needs. Any instance can serve any request.
Stateful = the server holds something (a session, a connection, a file being uploaded, a game room) that only exists there.
Stateless things scale horizontally almost for free. Stateful things do not.
❌ STATEFUL APP SERVER
User logs in → session stored in server A's memory
Next request hits server B → "who are you?" → logged out
Server A dies → session gone
Add a server → it has no sessions → uneven load
✅ STATELESS APP SERVER
User logs in → session stored in Redis (shared)
Any request, any server → looks up session in Redis → works
Any server dies → no user impact
Add a server → it works immediately
The universal move: push state out of the app tier.
| State | Move it to |
|---|---|
| Sessions | Redis, or a signed token the client carries (JWT) |
| Uploaded files | Object storage (S3), never local disk |
| Background jobs | A queue |
| Cached computation | A shared cache |
| Application data | The database (and then you scale that, which is the hard part) |
You haven’t eliminated the state problem — you’ve concentrated it into systems built to handle it. That’s the win: one hard problem in one place, instead of a hard problem in every service.
Sticky sessions (session affinity) route a user always to the same server, so in-memory state works.
⚖️ It’s a real tool, but understand what it costs: uneven load distribution (some servers get the heavy users), lost state when a server dies, disruption on every deploy, and it makes autoscaling much less effective (you can’t freely remove a machine).
Use it when you genuinely must — WebSocket connections have to live somewhere — and pair it with a shared registry so you can still route messages to the right node. Don’t use it as a substitute for externalizing session data.
Systems grow through predictable stages. Knowing the order lets you say “we’re at stage 4, so the next thing that breaks is X.”
flowchart TB
S1["1 · Single server<br/>app + DB together"] --> S2["2 · Separate the database"]
S2 --> S3["3 · Add a cache"]
S3 --> S4["4 · Multiple app servers + load balancer<br/>(requires stateless app tier)"]
S4 --> S5["5 · Read replicas"]
S5 --> S6["6 · CDN + object storage for static/media"]
S6 --> S7["7 · Async work → queues + workers"]
S7 --> S8["8 · Shard the database"]
S8 --> S9["9 · Split into services"]
S9 --> S10["10 · Multi-region"]
Two things to notice:
→ Full walkthrough with numbers: 1 user → 1 billion users
Amdahl’s Law. If 5% of your work is inherently serial, then even with infinite parallelism your maximum speedup is 20×. The serial fraction dominates.
Speedup = 1 / (S + P/N) S = serial fraction, P = parallel fraction, N = workers
S = 0.05: N=10 → 6.9× N=100 → 16.8× N=∞ → 20×
In practice, it’s worse than Amdahl — because adding machines adds coordination, which is work that didn’t exist before. The Universal Scalability Law adds a crosstalk term, and the result is a curve that rises, flattens, and then goes back down.
Throughput
│ ╭─────╮
│ ╭─╯ ╰──╮ ← adding more nodes now makes it SLOWER
│ ╭─╯ ╰───
│ ╭─╯
│╭╯
└──────────────────────► Nodes
🚨 This is real and people hit it. Add nodes to a database cluster and throughput can drop, because every node now coordinates with every other node. If an interviewer asks “what if we just add more servers?”, the sophisticated answer is: “up to a point — beyond that, coordination overhead grows faster than capacity, and we’d need to reduce coordination instead, by sharding or partitioning the work.”
Where the serial/coordination fraction hides: a shared database, a distributed lock, a leader node, a global sequence generator, cache invalidation broadcasts, and cross-shard transactions.
Before scaling anything, consider making the work smaller. This is frequently the cheapest win and almost nobody proposes it in interviews.
🎙️ “Before adding capacity I’d check whether we’re doing unnecessary work — an N+1 or a missing index can cost more than the entire traffic increase we’re worried about.”
| Decision | Gain | Cost |
|---|---|---|
| Vertical first | Simplicity, no distributed problems | Ceiling, SPOF, expensive at the top |
| Horizontal | Unlimited capacity, fault tolerance | Statelessness required; network in the path; partial failure |
| Stateless app tier | Free horizontal scaling | State moves to Redis/DB, which you now must scale |
| Sticky sessions | Keeps in-memory state working | Uneven load, state loss on failure, painful deploys |
| Sharding | Unlimited data scale | No cross-shard joins or transactions; rebalancing pain |
| Autoscaling | Cost efficiency | Scale-up lag (cold starts), thrashing, harder capacity reasoning |
1. Break statefulness, then fix it. Build a tiny app that stores the login session in an in-process dictionary. Run two instances behind Nginx round-robin. Log in and refresh a few times — watch yourself get randomly logged out. Now move sessions to Redis and watch it work. This takes 30 minutes and makes the concept permanent.
2. Find the non-linearity. Load-test a service with 1, 2, 4, and 8 worker processes against a shared database. Plot throughput vs workers. You will not get a straight line — find where it bends and work out what the shared bottleneck is.
3. Do less work. Take a slow query in any toy database with a million rows. Time it. Add the right index. Time it again. Compute how many servers that index just saved you.