Skip to the notes
JSGroundwork
JSGroundwork handwritten · web dev
✎Playground→⌘Problems↻Review🔥Progress

Chapters

24 chapters
⌕
Beginner7›
B1The mental modelB2Client-server basicsB3Vertical vs horizontalB4Databases in designB5Caching fundamentalsB6APIs & communicationB7Walkthrough: URL shortener
Intermediate9›
I1Load balancing in depthI2CDNsI3Message queues & asyncI4Consistency modelsI5Rate limiting & throttlingI6Designing for availabilityI7Storage systemsI8Search systemsI9Walkthrough: feed/chat
Advanced8›
A1CAP theorem in depthA2Sharding at scaleA3Distributed consensusA4Fault toleranceA5Observability at scaleA6Capacity estimationA7Case studiesA8Tradeoff thinking
/ search[ ] chaptert top

System Design levels

1Beginner2Intermediate3Advanced

Ready to read

JSJavaScript⑂Git◎Interview prepΣDSA in JSSDSystem Design
More topics15›
</>HTML{ }CSS⚛ReactNNext.jsNeNest.jsTSTypeScriptNoNode.js🐳DockerDBSQL & Databases✓Testing🔒Web Security☁Cloud & DevOps◈GraphQL◆Redis☸Kubernetes
100%
B3

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.

day one app database 20x balancer app 1 app 2 app 3 writes reads primary read replica lag: 10 ms - 2 s The app tier scaled out because it holds no state. The database did not — it grew a replica, and writes still go to exactly one node.
Notice the asymmetry: three interchangeable app servers, still one writer. That asymmetry is the whole story of scaling, and everything harder in distributed systems is an attempt to break it.

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.

ResourceWhat one rentable machine offers today
CPUUp to ~900 vCPUs on the largest high-memory cloud instances; 128-192 vCPUs is an ordinary large instance
MemoryUp to 24-32 TiB on high-memory instances; 768 GiB is routine and cheap-ish
Local storageTens of terabytes of NVMe on storage-optimised instances, at millions of random IOPS and single-digit-microsecond latency
Network100 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 boxRealistic throughput
nginx or Envoy proxying HTTP50,000-100,000+ requests/sec
A typical JSON API server doing real work per request500-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 RAMHundreds of gigabytes to a few terabytes — which is most companies' entire production database
The anchor worth remembering Stack Overflow served hundreds of millions of page views a month from about nine web servers and a two-node SQL Server cluster, with the database machines sitting at single-digit CPU utilisation. If your design needs more than 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 wallWhat 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 loseWhyWhat 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
client load balancer app 1 session: alice app 2 no session in memory login next request 200 OK 401 — logged out The bug appears only under load balancing, only sometimes, and only in production.
Nothing here is broken in isolation. Statefulness in the app tier is a correctness bug that scale-out reveals rather than causes.

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 getLinear capacity, immediatelyAnother copy to keep consistent — read capacity maybe, write capacity usually not
Losing a node costsThe in-flight requestsAvailability, or data, or both, depending on replication settings
Scaling mechanismAutoscaling group behind a load balancerReplication, then partitioning/sharding, each with real consistency consequences
Practical limitWhatever the data tier behind it can takeThe 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.

PropertyReality you should state
What it buysRead 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 buyWrite capacity — every write still executes on the primary and is replayed on every replica, so replicas add write work rather than absorbing it
Replication lagTypically single-digit milliseconds to a couple of seconds; it spikes during bulk writes, schema changes, and long-running queries on the replica
The correctness trapA user writes, is redirected, reads from a lagging replica, and their own change is missing
The standard fixesRoute 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 replicationRemoves 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 triggers, and neither is true at the numbers we estimated."

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
←previousClient-server basics↑ CovernextDatabases in design→