B3 · Part 1 · Beginner
Scaling: vertical vs horizontal
Buy a bigger box or buy more boxes — and one modern box is far bigger than the candidate who instantly reaches for a cluster believes.
08On this page
- There are only two knobs, and they cost different things
- How big is one box, actually
- Where vertical scaling actually hits the wall
- What horizontal scaling takes away
- Stateless and stateful tiers scale differently
- Read replicas: the first real scaling move for most systems
- When not to scale out
- Recognizing it in an unseen problem
There are only two knobs, and they cost different things
When a system runs out of capacity you can make the machine bigger (vertical, scale up) or add machines (horizontal, scale out). Everything else — caching, replicas, sharding, queues — is either a way to need less capacity or a specific tactic for making scale-out work on a tier that resists it.
The reason interviewers open here is that the choice reveals judgement. Vertical scaling costs money and has a ceiling. Horizontal scaling costs architecture: it forces you to give up in-process state, easy transactions, and the ability to reason about your system as one program. Candidates who reach for a distributed cluster before they have exhausted a single box are showing an interviewer that they will over-build in production too, and that is a genuine downgrade signal at senior level.
How big is one box, actually
Most engineers' intuition about a "single server" was formed on a laptop or a 4-vCPU cloud instance. The real top end is startling, and quoting it is one of the cheapest ways to sound like you have operated systems rather than only read about them.
| Resource | What one rentable machine offers today |
|---|---|
| CPU | Up to 1,920 vCPUs on the largest high-memory cloud instance (AWS U7inh, 32 TiB, built for in-memory databases such as SAP HANA); 128-192 vCPUs is an ordinary large instance |
| Memory | Up to 32 TiB on the largest high-memory instance; 768 GiB is routine and cheap-ish |
| Local storage | Tens of terabytes of NVMe on storage-optimised instances, at hundreds of thousands of random IOPS or more and read latencies in the tens of microseconds |
| Network | 100 Gbps and above on large instances — roughly 12 GB/s at line rate, which is rarely the first bottleneck for a typical JSON API |
What that translates to in throughput terms is more useful than the specs:
| Workload on a single well-tuned box | Realistic throughput |
|---|---|
| nginx or Envoy proxying HTTP | 50,000-100,000+ requests/sec |
| A typical JSON API server doing real work per request | 500-5,000 requests/sec per instance |
| Redis, single node | ~100,000 ops/sec, into the millions with pipelining |
| Postgres, indexed point reads, working set in RAM | Planning figure ~20,000 reads/sec; 100,000+ is reported on strong hardware |
| Postgres writes (WAL and fsync bound) | Planning figure ~5,000 committed writes/sec; 20,000-50,000 is reported with batching and group commit |
| Dataset that fits entirely in RAM | Hundreds of gigabytes to a few terabytes — which is most companies' entire production database |
The Postgres rows are conservative planning numbers, not limits. The course rule: start planning a change when sustained reads pass ~20k/s (add a cache or replicas) or sustained writes pass ~5k/s (partition or queue), and treat ~20k+ sustained writes as "you are sharding or queueing now". How far past that a single primary can go depends on hardware and batching: OpenAI has described running ChatGPT on one Azure PostgreSQL primary with about 50 read replicas at millions of aggregate queries per second. Measure, do not assume.
The anchor worth remembering (2016 data)
In Nick Craver's 2016 write-up of Stack Overflow's architecture, the site
handled about 66 million page loads a day (209 million HTTP requests) with
nine primary web servers and two SQL Server availability-group clusters,
each a primary with replicas, and the team aimed to keep database CPU very
low. That is 2016 hardware and a 2016 workload, so treat it as an anchor for
the idea, not a current benchmark. If your design needs more than a setup
like that, be able to say why your workload is different. Usually
the honest answer is media, machine learning, or genuine consumer scale —
and if it is none of those, one big box plus a replica is the correct
design.
Where vertical scaling actually hits the wall
The naive story is "vertical scaling gets exponentially expensive." That is only partly true, and saying it as stated invites a correction: within an instance family, cloud pricing is close to linear — twice the vCPUs is roughly twice the price. The real wall is made of five other things, and naming them precisely is the senior version of this answer.
| The wall | What it actually looks like |
|---|---|
| Availability | One box is one failure domain. There is no rolling deploy, no instance-failure tolerance, and maintenance means downtime. This usually binds long before capacity does. |
| Resize is not online | Changing instance class means a stop/start or a failover — minutes of downtime, or a managed failover of 30-120 seconds. You cannot scale up mid-incident in time to matter. |
| Diminishing returns | Doubling cores rarely doubles throughput: lock contention, NUMA effects across sockets, single-threaded components (Redis, a WAL writer, a Node event loop), and GC pauses that grow with heap size. Past a point you buy cores that idle while one thread is the bottleneck. |
| A genuine ceiling | There is a largest instance, and when you reach it there is no next step — the migration you postponed now has to happen under load, which is the worst possible time. |
| Cost at the extremes and licensing | The top-of-catalog high-memory tiers do carry a premium per unit, huge instances are not always available in your AZ, and per-core commercial licensing turns a linear hardware curve into a brutal software bill. |
Say it like this → "At 1,200 writes per second I'm
nowhere near a single primary's limit, so I'm not sharding. I'd scale the
database vertically and add a replica — but I want two nodes from day one
for availability, not for throughput. Those are different reasons and only
the first one applies today."
What horizontal scaling takes away
Adding a second server silently invalidates a set of assumptions that were true and invisible when there was one. This is the part candidates skip, and it is where the interesting follow-ups live.
// Perfectly correct on one server. Quietly broken on two.
const hits = new Map();
function allowRequest(userId) {
const n = (hits.get(userId) || 0) + 1;
hits.set(userId, n);
return n <= 100; // "100 requests per user"
}
// With 10 app servers behind a round-robin LB, each user gets ~1000.
// A deploy resets every counter. Autoscaling changes the limit.
// The fix is not a bigger Map — it is moving the counter out of the process.
| What you lose | Why | What you do instead |
|---|---|---|
| In-process state | Rate limit counters, WebSocket connection maps, uploaded file chunks, in-memory caches all become per-node and inconsistent | Move it to a shared store, or partition deliberately so a given key always lands on the same node |
| Session affinity for free | Consecutive requests from one user hit different servers | Externalise the session; use sticky sessions only as a last resort and know what they cost |
| Easy transactions | Only holds while all the data is in one database; the moment state spans nodes or services, you need sagas, outbox patterns, or idempotency keys | Keep transactional data in one store as long as you possibly can — this is a strong reason not to split services early |
| Simple debugging | "Check the log" becomes "check twelve logs"; a bug may reproduce on one node only | Centralised logging, request IDs, distributed tracing — real infrastructure you now have to own |
| Total-failure simplicity | Systems now fail partially: one node slow, one AZ unreachable, half the writes succeeded | Timeouts, retries with jitter, idempotency, circuit breakers, health-check-driven ejection |
| Cheap in-memory calls | A function call becomes a network round trip, ~0.5 ms and occasionally failing | Batch, cache, and resist splitting components that chat constantly |
Stateless and stateful tiers scale differently
The reason the app tier is easy and the data tier is hard comes down to one question: does adding a node add capacity, or does it add a copy that must be kept in agreement with the others?
| Stateless tier (web, API, workers) | Stateful tier (databases, caches, queues) | |
|---|---|---|
| Add a node and you get | Linear capacity, immediately | Another copy to keep consistent — read capacity maybe, write capacity usually not |
| Losing a node costs | The in-flight requests | Availability, or data, or both, depending on replication settings |
| Scaling mechanism | Autoscaling group behind a load balancer | Replication, then partitioning/sharding, each with real consistency consequences |
| Practical limit | Whatever the data tier behind it can take | The write throughput of a single partition |
Linear capacity turns sizing into a division: peak load over the load one instance may safely carry, which is its tested throughput times a utilisation ceiling you choose. At 12,000 requests a second peak, an instance tested at 800 and held to 60% carries 480, so the tier needs 12,000 ÷ 480 = 25 instances.
This is why "make the app tier stateless" is not a style preference. It is the move that concentrates all the hard problems into one tier, where you can attack them with dedicated tools, instead of spreading them everywhere.
Read replicas: the first real scaling move for most systems
Nearly every consumer-facing system is read-heavy, often by 10:1 or 100:1. That asymmetry means the highest-leverage change is almost always to serve reads from somewhere other than the primary: a cache first, and replicas right behind it. A replica is a full copy of the database that applies the primary's write stream and serves read-only queries.
| Property | Reality you should state |
|---|---|
| What it buys | Read throughput scales roughly linearly with replica count, plus a warm failover candidate and a place to run analytics without touching production load |
| What it does not buy | Write capacity — every write still executes on the primary and is replayed on every replica, so replicas add write work rather than absorbing it |
| Replication lag | Typically single-digit milliseconds to a couple of seconds; it spikes during bulk writes, schema changes, and long-running queries on the replica |
| The correctness trap | A user writes, is redirected, reads from a lagging replica, and their own change is missing |
| The standard fixes | Route reads to the primary for a short window after a write (read-your-writes), pin a session to the primary, or track a write timestamp/LSN and pick a replica that has caught up |
| Synchronous replication | Removes the lag problem, adds the round trip to every commit and couples your write availability to the replica's health |
⚠ "I'll add read replicas" is only half an answer
The immediate follow-up is always some form of "what does a user see right
after they post?" If you have not decided how reads are routed and what
staleness is acceptable per endpoint, the replica has introduced a
user-visible bug. State the policy explicitly: profile reads tolerate two
seconds of staleness, the checkout page does not and goes to the primary.
When not to scale out
Premature distribution is one of the most reliable senior-level red flags, precisely because it looks like sophistication. The cost is not the servers — it is that a distributed system fails in partial, non-reproducible ways, and you have taken on that tax before you had the problem it solves. Work this list before adding nodes, out loud:
- Measure. Which resource is saturated — CPU, memory, disk IOPS, connections, or a lock? "The database is slow" is not a diagnosis, and an interviewer will notice if you skip this.
- The missing index. A single index has turned a dying database into an idle one more times than every scaling technique combined.
- N+1 queries and chatty calls. 200 sequential queries per page request is an application bug, not a capacity problem.
- Connection pool configuration. Databases die from connection exhaustion far more often than from CPU.
- Caching the hot 1%. Read distributions are power laws; a small cache usually absorbs most of the traffic.
- Move work off the request path. Emails, thumbnails, analytics, and webhooks belong in a queue, not in the user's 200 ms.
- Then buy the bigger box. It is a one-line change and it buys months. Take the months.
Say it like this → "Before I distribute anything I
want to know what's actually saturated. If it's read CPU on the database,
a cache and a replica fix it for a fraction of the complexity of sharding.
I'd shard when a single primary can no longer absorb the write rate or the
dataset no longer fits — those are the two main triggers, and neither is
true at the numbers we estimated. There are others: one hot partition or an
IOPS ceiling that a bigger box cannot lift, a blast radius that has grown
too large to tolerate, or data-residency rules that force data to live in a
particular region."
Dry run: applying "when not to scale out" to one incident before reaching for a shard
Scenario: p99 latency on checkout climbs from 120 ms to 800 ms during a flash sale, and the team's first instinct is to shard the primary database.
| Step | Applied to this incident | Conclusion |
|---|---|---|
| 1. Measure | App-tier CPU is 40%; database CPU is 95% with low disk IOPS, and the connection pool sits at 198/200 | The database is CPU-bound, and the full pool is a symptom (queries take so long that connections stay checked out). Something specific is burning that CPU, so find it before changing the database's shape |
| 2. The missing index | The slow query log shows a full scan over 40M rows on orders.customer_id, no index present | Add the index first — the chapter's own claim is that one index has fixed more dying databases than every scaling technique combined |
| 3. N+1 queries | Checkout issues one query per line item — about 12 round trips per request | Batch into one query — an application bug, not a capacity problem, and it generates load even on a well-sized database |
| 4. Connection pool configuration | 20 app instances × a 50-connection pool each = up to 1,000 connections against a 200-connection database limit | Far more connections than cores makes a CPU-bound database slower, not faster. Cap the total, size pools to a small multiple of the core count, and add a pooler if needed |
| 5. Caching the hot 1% | 90% of reads during the sale hit the same ~500 discounted SKUs | A small Redis cache in front absorbs nearly all of that traffic — a textbook power-law read distribution |
| 6. Move work off the request path | Order-confirmation emails and analytics events are sent synchronously inside the checkout request | Move both to a queue — neither belongs in the user's 200 ms budget |
| 7. Then buy the bigger box | If the primary is still CPU-bound after steps 1–6 | Resize vertically — "a one-line change that buys months" — before sharding, which is a one-way door |
Five of the seven steps never touch the database's shape at all, and the first step, measuring, is what pointed at them — the incident that looked like "we need to shard" turned out to be an index, a query pattern, a pool setting, a cache, and a queue. Sharding stays the last resort precisely because the chapter orders the checklist that way.
Recognizing it in an unseen problem
- Do the scale math first: below roughly 5,000 sustained writes/sec or a few terabytes, one primary plus replicas is a defensible design and reaching past it needs justification
- Any prompt with "high availability" is a horizontal question even at tiny scale — you add the second node for failure tolerance, and it happens to also add capacity
- Read-heavy with tolerable staleness (feeds, catalogs, profiles) → cache then replicas; write-heavy or strongly consistent (ledgers, inventory, counters) → replicas do not help and you should say so
- If the design keeps anything per-user in process memory — sessions, WebSocket maps, rate limit counters, upload buffers — that component is stateful and cannot simply be autoscaled; decide where the state goes before you add the second instance
- Distinguish scaling out from sharding: adding stateless app servers is nearly free, and partitioning a database is a one-way door involving hot keys, cross-shard queries, and resharding pain
- The trap is symmetric — under-designing for a stated billion-user scale reads as naive, and over-designing for a stated thousand-user scale reads as undisciplined; the number they gave you is the tiebreaker
Before you move on
- Walk the "when not to scale out" checklist against a real incident, in order, before proposing sharding or adding nodes.
- Explain how a missing index or N+1 queries can masquerade as "we need more capacity," and how measuring the saturated resource tells them apart.
- State the main triggers that actually justify sharding a database (write rate, dataset size, hot-partition limits, blast radius, data residency), and explain why "traffic went up" on its own is not one of them.
- Explain why adding a read replica adds write work rather than absorbing it, and name a fix for the read-your-own-writes staleness trap.
- Given a design, point to the component holding per-user state in process memory and explain why it can't simply be autoscaled like the rest of the app tier.
Sources and further reading
- Stack Overflow: the architecture, 2016 edition — the dated anchor for a small fleet serving a very large site
- AWS: memory optimized instances — the largest rentable single machines, including the 1,920 vCPU and 32 TiB U7inh
- AWS: I4i storage-optimized instances — up to 30 TB of local NVMe per instance
- OpenAI: scaling PostgreSQL — one primary plus about 50 replicas at millions of queries per second
- PostgreSQL docs: high availability and replication — synchronous versus asynchronous replication and what each costs
- Analyzing the limits of connection scalability in Postgres — why very large connection counts hurt even when idle
- Designing Data-Intensive Applications, second edition — replication, partitioning and the scaling tradeoffs behind this chapter
Claims in this chapter were checked against these sources in October 2026. Figures marked "planning figure" are rules of thumb, not guarantees.
5 questions drawn from 8, scored out of 5. This is practice: it runs in your browser, saves nothing and does not change what you have marked read.
Finished reading?
0 of 36 chapters read in this series