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.
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 ~900 vCPUs on the largest high-memory cloud instances; 128-192 vCPUs is an ordinary large instance |
| Memory | Up to 24-32 TiB on high-memory instances; 768 GiB is routine and cheap-ish |
| Local storage | Tens of terabytes of NVMe on storage-optimised instances, at millions of random IOPS and single-digit-microsecond latency |
| Network | 100 Gbps and above on large instances — roughly 12 GB/s, more than most systems' entire dataset per minute |
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 | ~10,000-50,000 reads/sec |
| Postgres writes (WAL and fsync bound) | ~1,000-10,000 transactions/sec, higher 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 |
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. |
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 |
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 |
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.
Recognizing it in an unseen problem
- Do the scale math first: below roughly 10,000 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