08 · Scaling & Distributed Systems

Horizontal vs Vertical Scaling

6 min read · 5 questions · 1 exercise

Vertical scaling buys a bigger machine and horizontal scaling buys more of them, and the choice is really about which ceiling you hit first.

The dashboard is unambiguous. Every weekday between nine and ten the API’s response times climb, the CPU on the one server that runs it sits pinned near the top, and support gets the same three tickets. Nobody has to argue about what is happening — the machine is out of room. The only question is what to do about it, and there are exactly two answers.

Get a bigger machine, or get another machine. That is the whole taxonomy. Vertical scaling means growing the box you have: more cores, more memory, faster disks. Horizontal scaling means running more boxes and spreading the work across them. They sound like two settings of the same dial, and they are not — one is a purchase and the other is an architecture.

Growing the box

A delivery company with one van and too many parcels can buy a bigger van. It is a good move: the driver, the route, and the paperwork are all unchanged, and by Monday the problem is gone.

Vertical scaling is that van. You resize the instance, restart, and the application is faster with no code changed, no new failure modes, and nothing new to reason about. There is one process holding one copy of the state, so a session in memory stays valid, an in-process cache stays coherent, and a lock is just a lock. This is genuinely underrated: an enormous number of systems will never need anything else, and reaching for a distributed architecture before you have to is how teams buy a year of complexity to solve a Tuesday.

It has two walls, though, and you meet them in this order. The first is the ceiling. Machines come in sizes, the largest size is finite, and past a certain point each step up costs disproportionately more for the same increment of capacity. When you are running the biggest instance your provider rents, the strategy is simply out of moves. The second wall is that one machine is one machine: while it reboots for a resize, or a disk fails, or the kernel panics, the product is down. Vertical scaling improves throughput and does nothing at all for availability.

Adding boxes

The other answer is more vans. Now the parcels have to be split between them, which needs somebody to decide who gets what, and the drivers need a shared depot rather than a pile in the back of one van.

Horizontal scaling puts a load balancer in front of several identical instances and spreads requests across them. The ceiling moves a long way out — you add capacity in units of one machine, at a linear price — and availability improves as a side effect, because losing one instance out of four costs you a quarter of your capacity instead of all of it.

The bill comes due as coordination. Any request can now land on any instance, so an instance must not hold anything the next request needs. A session in memory breaks the moment a customer’s second request lands elsewhere, so sessions move to a shared store; an in-process cache stops being coherent, because four copies drift apart; a lock stops working, because it only excludes the threads inside one process. This is what “stateless” means in practice and why REST insists on it: keep the request’s state in the request and the durable state in a store everything can reach. Operationally you now have deploys that roll across a fleet, logs from four places, and a real distributed system with real partial failures.

The scene runs the same six requests twice — once onto a single box, then across three.

Predict first

The scene sends six requests to one big box, then the same six across three small boxes. The big box hits its ceiling before the last request. Below, box 3 dies during request 5 and is replaced in time for request 6. Each badge counts what its box was sent. When the lap ends, what do the four badges read?

  1. The big box was sent all six, including the ones that reached it at its ceiling. Below, the boxes take turns: request 5 went to box 2, whose turn it was anyway, and box 3 was back to take request 6. Same traffic, a third of it per box.

  2. Request 6 waits, but it still arrived. The badge counts what a box was sent, not what it kept up with, so a box at its ceiling still counts the request it cannot serve.

  3. That takes a request from box 3 for its outage and hands it to box 2. Box 3 was down only for request 5, which was box 2's turn anyway, and it was back in time for request 6.

Show the answer

The answer is A. The reasoning for each option is now shown under it.

One big box, then three small ones

one request · a badge counts what that box was sent · rust = full, grey = down

ONE BIG BOXTHREE SMALL BOXES
clients
big box
box 1
box 2
box 3
sessions

The same six requests onto one machine, then across three — use Next step to go one at a time.

The big box’s badge counts requests that reached it, not requests it kept up with; the last two arrive at a machine that is already at its limit. In the second era the same six requests are split two apiece, and two of those requests reach down to the shared session store — the hop the single box never made, and the price of any instance being able to serve anybody.

Which one, and when

Vertical first, almost always. It is one command, it buys real time, and time is what you actually need while the product is still changing shape. The mistake is not choosing vertical; it is choosing vertical and letting the code grow assumptions that only hold on one machine.

So the useful discipline is to scale up while building as though you will scale out: no session state in process memory, no local files that matter, no background job that must run in exactly one place by accident rather than by design. Those are cheap while you have one instance and expensive to retrofit across four. Twelve-factor apps are largely a list of this habit.

The arithmetic that decides the moment fits in a few lines:

/** Requests a second one instance of this app handles. Measure it — the
 *  number is yours, and nobody else's is worth borrowing. */
const PER_INSTANCE_RPS = 400;

/** The same measurement on the largest instance your provider rents. */
const LARGEST_INSTANCE_RPS = 800;

/** Negative means no single machine is big enough: the vertical road ends. */
function verticalHeadroom(peakRps: number): number {
  return LARGEST_INSTANCE_RPS - peakRps;
}

/** How many equal instances that same peak needs behind a load balancer. */
function instancesNeeded(peakRps: number): number {
  return Math.ceil(peakRps / PER_INSTANCE_RPS);
}

const peakRps = 1_200;
const headroom = verticalHeadroom(peakRps); // -400: nothing left to buy
const fleet = instancesNeeded(peakRps); //      3: three boxes at 400 rps each

Both numbers come from one measurement, which is the part worth taking seriously. PER_INSTANCE_RPS is not a figure to copy from an article; it is what your application does on your hardware with your traffic mix, and every decision above is downstream of measuring it honestly.

One more number is hiding in that sample. Three boxes at 400 rps is exactly 1,200, so the fleet covers the peak with nothing to spare. Lose one box at half past nine and 800 rps of capacity meets 1,200 rps of traffic: a third of the load has nowhere to go, at the busiest hour of the day. Horizontal scaling only buys availability when the fleet is sized for a failure.

Try it

Leave room for a failure

instancesNeeded(1_200) returns 3, a fleet with no spare. Give instancesNeeded a second parameter, canLose, the number of instances that may be down at peak, and return the smallest fleet that still covers the peak with that many gone. Calls that leave it out should behave exactly as before.

Hint

Subtract the failures first, then ask what has to be true: the instances still running must cover the peak on their own. A default parameter keeps the old calls working.

Solution
/** How many equal instances the peak needs, with `canLose` of them down. */
function instancesNeeded(peakRps: number, canLose = 0): number {
  return Math.ceil(peakRps / PER_INSTANCE_RPS) + canLose;
}

The survivors must cover the peak by themselves, so the fleet is what the peak needs plus the instances you plan to lose. This is usually written N+1, and the spare is what turns losing a box from an incident into a non-event.

To see it work, check what is left after a loss:

const capacityAfterLosing = (instances: number, lost: number): number =>
  (instances - lost) * PER_INSTANCE_RPS;

// 800: short of the peak
console.log(capacityAfterLosing(instancesNeeded(1_200), 1));
// 1200: exactly the peak
console.log(capacityAfterLosing(instancesNeeded(1_200, 1), 1));

Check yourself

Pick an answer to lock it in. You then get the reasoning behind every option, including the ones you didn't pick. Any you miss come back on the review page a day later.

With the sample's numbers, one instance handles 400 rps and the largest for rent handles 800. Your peak is 300 rps today and should double within a year, and the business can live with a rare short outage. What is the sensible plan?

  1. That pays for 800 rps while you use 300. A resize is one command when the peak actually needs it, so there is nothing to gain from buying the headroom a year early.

  2. Doubled, the peak is 600 rps, and verticalHeadroom(600) is still 200: one machine covers it. Scaling out now buys a year of coordination costs to solve a problem you do not have yet.

  3. The vertical road still has 200 rps of room at the doubled peak, and since the business accepts a rare outage, availability does not force your hand. Keeping sessions and caches out of process memory is the cheap insurance that lets you scale out later without a rewrite.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

You scale out to four instances, each keeping its own in-process cache of product prices with a 5-minute TTL. An admin changes a price. For a few minutes, refreshing the page shows the old price and the new one by turns. Why?

  1. One database holds one current price. The old one is coming from copies kept outside it.

  2. Four caches filled at four different moments, so they expire at four different moments, and each refresh shows whichever copy the balancer happens to reach. A single instance had one cache, and it could not disagree with itself.

  3. A browser cache would show the old price on every refresh until it expired, not alternate. Alternating means the answer depends on which server you reach.

Show the answer

The answer is B. The reasoning for each option is now shown under it.

Two fleets each have 2,400 rps of capacity: three instances at 800 rps, or six at 400. Peak traffic is 1,800 rps. One instance fails at peak. Which of the two still keeps up?

  1. Their totals match, but a failure removes one instance, and the instances are not the same size. Losing 800 leaves 1,600; losing 400 leaves 2,000.

  2. Each has more room, and each takes more capacity down with it when it fails. Three at 800 drop to 1,600, which is 200 short of the peak.

  3. Only if the fleet was sized with nothing spare. Six at 400 still have 2,000 rps after losing one, which is 200 more than the peak.

  4. Three at 800 drop to 1,600, 200 short of the peak; six at 400 drop to 2,000, 200 over it. Smaller instances make each failure a smaller share of the fleet, which is the availability argument for scaling out in many small units.

Show the answer

The answer is D. The reasoning for each option is now shown under it.

A nightly job bills every customer, and an in-process lock stops it from running twice. After you scale out to three instances, every customer is billed three times in one night. Why?

  1. Nothing sent a request. A nightly job is started by a scheduler inside each instance, and the balancer only routes incoming traffic, which this job never was.

  2. The database did exactly what it was asked. Three separate processes each asked it once per customer.

  3. A lock only excludes threads inside one process. Every instance has its own scheduler and its own lock, so each found the job unlocked and ran it. A job that must run once needs a lock they all share, or one place chosen on purpose to run it.

  4. An expired lock could let a second run start, but it would not make the number of runs match the number of instances. Three instances, three bills: the count gives it away.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

Key takeaways

  • Vertical scaling is a purchase and horizontal scaling is an architecture, which is why the first is usually the right move first.

  • Vertical scaling ends at two walls: the largest machine that exists, and the fact that one machine is a single point of failure.

  • Horizontal scaling moves the ceiling out and charges you in coordination — shared session state, cache coherence, deploys and logs across a fleet.

  • Scale up while building as though you will scale out; statelessness is cheap to keep and expensive to retrofit.