Vertical and Horizontal Scaling
Scaling up buys capacity without touching your code; scaling out also buys availability, and breaks every assumption about shared state.
scale up: same topology, bigger instance | scale out: more instances + a router, and no state in the processVertical scaling (scaling up) means running the same system on a bigger machine. Horizontal scaling (scaling out) means running more copies of it and putting something in front to spread the work. Both buy capacity; only one of them changes your code.
Why the first answer is often a bigger box
One machine goes further than most designs assume. AWS's u7in-32tb.224xlarge is a single instance with 896 vCPUs, 32 TiB of memory and up to 200 Gbps of network. A service doing tens of thousands of queries per second against a few terabytes usually needs a correct index and more RAM, not a distributed database. Vertical scaling also needs no application change at all, which is the real argument for it: it buys you months in an afternoon.
What the crossing costs
Scaling up ends at a ceiling and a steep price curve, and it never buys availability — one instance is one failure domain. Crossing to many machines, you pay once in design. Anything living inside the process is now wrong: in-process caches, session state, counters, rate-limit buckets, scheduled timers, files on local disk. Partial failure becomes the normal state, where one node of ten is slow or gone while the rest serve.
What trips people up
Scale-out is sublinear. Each node you add contends for whatever they share — the database, a lock, the leader — so throughput flattens and can decline, the shape Gunther's Universal Scalability Law describes. Doubling the nodes rarely doubles the throughput. Measure the second node before you buy the tenth.
Example
// Rate limiting: the same feature written for one box, and for many.
import { createClient } from 'redis';
// --- Correct on one process, wrong on ten ---------------------------------
// Each replica counts only the requests it happened to receive, so behind a
// load balancer with 10 replicas the effective limit is 10x what you set.
const counts = new Map();
function allowLocal(userId, limit = 100) {
const n = (counts.get(userId) ?? 0) + 1; // ?? not ||: a stored 0 must stay 0
counts.set(userId, n);
return n <= limit;
}
// --- Scales out ----------------------------------------------------------
// The counter moves out of the process into one store every replica shares.
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
async function allowShared(userId, limit = 100, windowSeconds = 60) {
// Fixed window: the bucket is part of the key, so old buckets just expire.
const bucket = Math.floor(Date.now() / 1000 / windowSeconds);
const key = `rl:${userId}:${bucket}`;
// INCR returns the new value, so increment and read are one atomic round
// trip. Two replicas cannot both read 99 and both decide to allow.
const n = await redis.incr(key);
if (n === 1) await redis.expire(key, windowSeconds); // only the creator sets TTL
return n <= limit;
}When to use it
- A single Postgres primary is at 80% CPU and the next instance size doubles the cores for one restart.
- Deciding whether a service can run two replicas, before enabling autoscaling on it.
- Explaining to an interviewer why your first diagram has one database and not six.
- A load test shows throughput flattening between 8 and 16 app nodes and you need to name the shared bottleneck.
More examples
The state that blocks a second replica
The audit to run before putting a second replica behind the load balancer. Each of these three reports as an intermittent bug that only happens sometimes, because whether it happens depends on which replica the request landed on, which makes them expensive to find after the fact rather than before.
import fs from 'node:fs/promises';
// Each of these is correct on one machine and a bug on ten.
// 1. In-process cache. Ten replicas means ten copies that expire
// independently, and an invalidation delivered to one does nothing
// to the other nine.
const cache = new Map();
// 2. A timer. Every replica runs it, so the nightly digest goes out ten times.
setInterval(sendDigests, 24 * 60 * 60 * 1000);
// 3. Local disk. The upload lands on replica 3; the download request is
// routed to replica 7 and 404s.
await fs.writeFile(`/var/uploads/${id}.jpg`, buf);
// The scale-out versions:
// 1. a shared cache (Redis, Memcached), optionally with a short local TTL
// in front of it to absorb the hottest keys
// 2. a leader-elected scheduler, or a queue with one consumer group
// 3. object storage, addressed by key, served through a CDNUp and out, spelled out in the same manifest
Worth seeing side by side because the two changes behave differently: editing replicas adds or removes Pods and leaves the existing ones alone, while editing anything under template changes the Pod template and therefore replaces every Pod in a rolling update. A vertical change is a deployment; a horizontal change is not.
# Scaling out and scaling up are two different fields of one object.
#
# Shorthand, and the patch it is exactly equivalent to:
# kubectl scale deployment/api --replicas=10
# kubectl patch deployment/api --type=merge -p '{"spec":{"replicas":10}}'
# Both write spec.replicas and neither touches the Pods already running.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 10 # scale out: this number
template:
spec:
containers:
- name: api
resources:
requests:
cpu: "2" # scale up: these numbers
memory: 8Gi
limits:
memory: 8GiDeciding whether the next node helps
The number to put on a whiteboard when an interviewer asks what adding capacity buys. Efficiency falling as nodes rise means the bottleneck has moved off the application tier onto something shared, and the honest next step is to find it rather than to scale the tier that is no longer the constraint.
// Scale-out efficiency, from a load test rather than from hope.
const runs = [
{ nodes: 1, rps: 4200 },
{ nodes: 2, rps: 8100 },
{ nodes: 4, rps: 15200 },
{ nodes: 8, rps: 24800 },
{ nodes: 16, rps: 27500 }, // nearly flat: something shared is the limit now
];
for (const r of runs) {
const speedup = r.rps / runs[0].rps;
const efficiency = speedup / r.nodes; // 1.00 would be perfectly linear
console.log(
`${r.nodes}\t${(r.rps / r.nodes).toFixed(0)} rps/node\t` +
`efficiency ${efficiency.toFixed(2)}`,
);
}
// 16 nodes returns 1.11x the throughput of 8 for twice the cost. The next
// thing to fix is the contended resource, not the node count.
Discussion