Be Software Architecture Wise
Be Wise Apps

Leer en español · Lire en français · Auf Deutsch lesen

Section 9.1 of 9.8 · Intermediate · 28 min read

Scaling strategies: vertical, horizontal and the scale cube

Video lesson

5 min · narrated, with captions
Listen only (audio)
Read the transcript

Welcome to Section nine point one. When more people use a system, you have to add capacity. This lesson is about the practical choices: which resources to add, where, when, and how automatically.

Think of a busy café. You can serve more customers by buying a bigger, faster coffee machine. That's scaling up. Or you can open more counters with more staff. That's scaling out. It only works if any counter can serve any customer. You can also split the work: one counter for drinks and one for food, or one café per neighbourhood. And because extra staff take time to arrive, a sensible manager rosters them before the lunchtime rush.

Scaling up, or vertical scaling, gives one machine more power, and nothing in the software changes. But there is a largest machine, prices rise steeply near the top, and one machine is one failure domain. Scaling out, or horizontal scaling, runs more instances behind a load balancer. With eight instances, losing one costs an eighth of capacity, not all of it. The price is that any instance must handle any request, and the work must be divisible. Above all, scale the current bottleneck. Doubling the web tier when the database is ninety five per cent busy buys nothing but a larger bill.

Martin Abbott and Michael Fisher describe three different ways to scale out with the scale cube. Its corner is a single instance doing everything. The X axis is cloning: identical copies behind a load balancer, each doing all the work. It's the cheapest axis, but every clone still talks to the same data. The Y axis splits by function: separate services for menus, orders, dispatch and payments, each scaling and failing separately. This is the axis behind microservices. The Z axis splits by data: the same code, but each copy owns a slice of the customers, such as one cell or shard per city. A fault in one cell affects only its customers. Real systems combine all three. Use the cheapest axis that solves the actual problem.

Cloning works only if any clone can serve any request, so the service must be stateless. State hides in sessions held in memory, files on local disk, scheduled jobs, in-process locks and counters. Each moves to a shared store. Statelessness is cheap to design in, and expensive to retrofit.

Autoscaling closes a feedback loop. Users send traffic through a load balancer to the instances. The instances report their load to a metrics store, after a delay. The autoscaler compares the metric with its target and picks a number of instances. The orchestrator or cloud API then starts or stops instances. And each new instance must boot, warm up and pass its readiness check before it gets any traffic. From a rise in load to usable capacity is often two to ten minutes. So reactive scaling handles gradual change. Sudden steps need headroom, queues or capacity scheduled in advance.

Which signal should drive the loop? An order API mostly waits for the database and other services, so its CPU can sit at twenty five per cent while every thread is busy. A better signal is tied to the resource that runs out: requests in flight per instance, or for queue workers, the backlog per worker. Latency rises for reasons more instances can't fix, such as a slow database. Use it for alerting, not scaling. And to stop flapping, scale out quickly and scale in slowly, with a minimum and a maximum number of instances.

Let's size the food delivery platform's order API for the lunch peak. The busiest minute reached two thousand and fifty requests a second, with fifteen per cent growth expected. So plan for two thousand four hundred. Each instance has thirty two worker threads and takes a hundred and twenty milliseconds per request. By Little's law, that's two hundred and eighty eight requests in flight at peak. To keep thirty per cent headroom, run each instance at seventy per cent of its threads: twenty two point four requests. That needs twelve point nine, so thirteen instances. But the service must survive losing one of its three zones. Spread five, four and four, losing five leaves eight instances with thirty six requests each, more than their thirty two threads. Allowing eighty five per cent during a zone loss, you need eleven instances in two zones: six per zone, eighteen in total. Surviving a zone loss, not the average load, sets the fleet size. Finally, check the tier below. A maximum of thirty instances with ten connections each needs three hundred, but the database allows two hundred. So the team adds a connection pooler first.

The full section covers scaling the data tier and gives a decision guide. Next, in Section nine point two, we look at load balancing and sessions. See you there.

What you need to know

What scaling means in practice

Section 2.4 defined scalability as the ability to handle more load by adding resources, and showed with Amdahl's law and the Universal Scalability Law why adding resources does not always add capacity. This section is about the practical choices: which resources to add, where, when and how automatically. The starting point is always the same question: what is the load parameter, and which resource runs out first? A food-delivery platform's order API at lunchtime is limited by requests per second; its dispatch service by the number of active couriers whose positions must be matched every few seconds; its database by write transactions per second and by connections. Each needs a different answer.

Scaling a system really means scaling its current bottleneck, then finding the next one. Doubling the web tier of a system whose database is 95% busy buys nothing except a larger bill and a database that is now pushed harder. So every scaling decision starts with measurement: utilisation and saturation for each resource (Brendan Gregg's USE method, covered in Observability), and a load test that finds where latency starts to climb (Capacity planning and performance testing).

Vertical scaling: a bigger machine

Vertical scaling (scaling up) means giving the existing machine more CPU cores, memory, faster disks or a faster network. Its great virtue is that nothing in the software changes. A single relational database on a larger server keeps its transactions, its joins and its simple consistency; an application keeps its in-memory caches and its local files. In the cloud, resizing is an API call followed by a restart. The largest cloud virtual machines now offer hundreds of virtual CPUs and terabytes of memory, which is far more than most business systems will ever need, and some very busy websites have publicly described serving enormous traffic from a small number of powerful database servers.

The limits are real, though. There is a largest machine, and the price per unit of capacity rises steeply near the top of the range. One machine is one failure domain: when it fails, or is restarted to be resized or patched, everything on it stops, so scaling up alone does nothing for availability. Software often cannot use a bigger machine fully: a single-threaded process, a global lock or a hot database row limits the speed-up exactly as Amdahl's law predicts. And capacity comes in large, slow steps, so you pay for the peak around the clock. Vertical scaling is nevertheless the right first move far more often than fashion suggests, especially for databases, where scaling out is expensive in design effort.

Horizontal scaling: more machines

Horizontal scaling (scaling out) means running more instances and spreading the work between them, usually with a load balancer in front. It scales much further, in small and cheap steps, and the same redundancy that adds capacity also tolerates failure: with eight instances, losing one costs an eighth of capacity rather than all of it. Cloud platforms make adding an instance a matter of seconds or minutes, which is the basis of autoscaling.

The price is that the software must be designed for it. Any instance must be able to handle any request, which means instances must not keep anything that a later request depends on (the next section). Work must be divisible: a job that needs one global lock does not run faster on ten machines. Data must be reachable from every instance, which moves the pressure to the shared data tier. And the system becomes distributed, with the partial failures, timeouts and retries that Section 6.1 describes. Most real systems combine the two: stateless application tiers scale out, while databases scale up for as long as they reasonably can and then scale out with replicas and partitions.

Vertical and horizontal scaling compared

ConcernVertical (scale up)Horizontal (scale out)
Changes to the softwareNoneInstances must be stateless and work divisible
Upper limitThe largest machine availableUsually the shared data tier or coordination costs
Cost curveRises steeply for the largest sizesRoughly linear, in small steps
FailureOne failure domain; resizing needs a restartLosing one instance loses a fraction of capacity
Speed of changeMinutes plus a restartSeconds to minutes, can be automatic
Consistency and transactionsUnchanged and simpleUnchanged for stateless tiers; hard for data
Typical useDatabases, legacy and stateful software, first response to growthWeb and API tiers, workers, anything stateless

The scale cube: three different ways to scale out

Martin Abbott and Michael Fisher, who ran technology at eBay and PayPal, describe scaling out with the scale cube in The Art of Scalability (first edition 2009, second edition 2015). It separates three independent ways of splitting a system, each drawn as one axis of a cube whose origin is a single instance doing everything.

The X axis is horizontal duplication, or cloning: run N identical copies of the whole application or service and spread requests between them. It is the simplest and cheapest axis, and it works well for stateless request handling and read traffic. It does nothing for data size or write volume, because every clone still talks to the same data, and it does nothing for the size of the codebase or the number of teams working on it.

The Y axis splits the system by function: different services for menus, orders, dispatch and payments, each running only the code and data for its capability. Each service can then scale, fail and be deployed separately, and each is smaller and easier to understand. This is the axis behind microservices (Section 6.3), with all their costs. The Z axis splits by data: every copy runs the same code, but each is responsible for only a slice of the customers, tenants, regions or records, chosen by a key such as city or customer identifier. Database sharding is the Z axis applied to storage (Section 7.4); a cell-based architecture applies it to whole stacks, so that a fault or a bad deployment in one cell affects only that cell's customers.

The scale cube

The scale cube: the X axis clones identical instances, the Y axis splits the system by function, and the Z axis splits it by data so each copy serves a sliceorigin: a singleinstance of the appall three axes combinedX: cloneY: split byfunctionZ: split bydataX axis: cloneN identical copies behind aload balancer, each doing allthe work: 14 order-API copiesY axis: split by functionSeparate services percapability: menus, orders,dispatch, paymentsZ axis: split by dataSame code; each copy ownsa slice of customers or data:one cell or shard per city
Abbott and Fisher's scale cube: each axis is a different way of splitting a system, and real systems usually combine all three.

The three axes in the food-delivery platform

AxisWhat is splitExampleSolvesCosts and limits
X: cloneRequests, across identical copies14 instances of the order API behind one load balancerRequest throughput, instance failuresNeeds statelessness; all clones share one database
Y: functionCode and data, by capabilitySeparate menu, order, dispatch and payment servicesDifferent scaling and failure needs; team sizeNetwork calls, distributed data, operational overhead
Z: dataCustomers or records, by keyOne cell per group of cities, or orders sharded by cityData size, write volume, blast radiusRouting layer, cross-slice queries, rebalancing, hot keys

The axes combine. The platform's order service (a Y-axis split) runs as many clones (X) in each regional cell (Z). Abbott and Fisher's practical advice is to use the cheapest axis that solves the actual problem: X for request volume, Y when parts of the system have genuinely different scaling, failure or team needs, and Z when one data set or one blast radius has become too large. Choosing Z because it sounds impressive brings routing, rebalancing and cross-partition queries long before they are needed.

Statelessness: the precondition for cloning

X-axis scaling works only if any clone can serve any request. A stateless service keeps nothing between requests in its own memory or on its own disk; everything a later request needs lives in a shared store or travels with the request. The state that typically hides in an instance is easy to list once you look: user sessions held in memory, uploaded files written to local disk, in-memory caches that are treated as the source of truth, scheduled jobs that must run on exactly one machine, in-process locks guarding a shared resource, and counters for rate limits.

Each has a standard remedy: sessions go to a shared store or a signed token (Section 9.2); files go to object storage; caches become a performance optimisation that can be lost at any time, or move to a shared cache (Section 7.6); scheduled jobs use a scheduler with leader election or a queue; locks move to the database or a coordination service; rate-limit counters move to a shared store. The twelve-factor app's rule that processes are stateless and share nothing (Section 10.3) is this same idea. Statelessness is cheap to design in and expensive to retrofit, because state leaks into code in many small places.

Autoscaling: reactive, scheduled and predictive

Autoscaling closes a feedback loop: measure load, compare it with a target, change the number of instances. Reactive autoscaling does this continuously from a metric. Target tracking is the common form: keep average CPU at 60%, or in-flight requests per instance at 30. The Kubernetes Horizontal Pod Autoscaler computes desired = ceil(current replicas × current metric ÷ target): six pods averaging 45 in-flight requests against a target of 30 become ceil(6 × 45 ÷ 30) = 9. By default it re-evaluates every 15 seconds and ignores deviations within 10% of the target.

Scheduled autoscaling changes capacity at known times: raise the minimum to 18 instances at 11:15 every weekday before the lunchtime peak, lower it at 14:00. It is crude and very effective for predictable patterns, and it is the only way to have capacity in place before a step change such as a ticket on-sale at 10:00 sharp. Predictive autoscaling, offered by the major cloud providers, forecasts load from history (daily and weekly cycles) and scales ahead of it, with reactive scaling as a safety net for surprises.

The crucial property of any reactive scheme is lag. Metrics are collected and averaged over tens of seconds; the autoscaler acts on its next evaluation; a new virtual machine takes a minute or more to boot, a container image must be pulled, a JVM or Python service must start, warm its caches and connection pools and pass its readiness check before the load balancer sends it traffic. Under a cluster, the nodes themselves may have to be added first by a second, slower autoscaler. From the moment load rises to the moment new capacity is useful is commonly two to ten minutes. Reactive autoscaling therefore handles gradual change; sudden change must be absorbed by headroom, by queues (Section 9.3) or by capacity scheduled in advance.

The autoscaling feedback loop and where the lag comes from

The autoscaling feedback loop: instances report load, the autoscaler compares it with a target, the orchestrator starts instances, and new instances join the load balancer only when readyUsersLoad balancerInstancesserve trafficMetricsstoreAutoscalerOrchestratoror cloud API1 report loadafter a delay4 boot, warm up,pass readiness2 compare metric withtarget, pick replica count3 start or stopinstancesLoad rise to usable capacity: often 2 to 10 minutes
Each step of the loop adds delay, so reactive autoscaling follows load minutes behind; headroom and scheduled capacity cover the gap.

Which metric to scale on, and why CPU is often wrong

CPU utilisation is the default signal in most platforms because it is always available. It is the right signal only when the service is CPU-bound: image resizing, encryption, heavy serialisation. Most business services are not. An order API spends most of each request waiting for the database, the payment provider and other services; its CPU may sit at 25% while every worker thread is busy waiting and new requests queue. Scaling on CPU then does nothing until it is far too late. The opposite failure also happens: a garbage-collection storm or a JIT warm-up raises CPU without any rise in load, and the autoscaler adds instances that are not needed.

A better signal is one that is directly tied to the resource that runs out. For request-response services that is usually concurrency: the number of requests in flight per instance, compared with the number of worker threads or connections it has. Little's law makes the link exact (in flight = arrival rate × response time), and it reacts both to more traffic and to slower responses. For queue workers it is the backlog per worker or the age of the oldest message, which expresses how long the queue will take to drain. Latency is tempting but dangerous as a scaling signal: it rises for reasons more instances cannot fix, such as a slow database, and scaling out a service whose dependency is struggling sends that dependency even more load. Use latency for alerting, and scale on a measure of work.

Scaling signals and when they mislead

SignalFitsMisleads when
CPU utilisationCPU-bound work: image processing, encryption, heavy serialisationWork waits on I/O, threads or connections; garbage collection or warm-up inflates CPU
Requests per second per instanceServices whose requests cost roughly the sameThe request mix changes, for example more searches and fewer menu reads
In-flight requests per instanceMost request-response services; maps to thread and connection limitsA downstream dependency slows down; scaling out then adds load to it, so cap the maximum
Queue backlog per worker, or oldest message ageAsynchronous workersWorkers are limited by a downstream rate limit rather than by their number
LatencyAlerting and SLOs (Section 9.5)The cause is a shared dependency that more instances cannot fix
Business metric (orders per minute, active sessions)Load with a stable, measured relationship to capacityThe relationship drifts as the product changes; recalibrate with load tests

Cooldowns, stabilisation and flapping

An autoscaler that reacts to every wobble will flap: add instances when a burst arrives, remove them a minute later when the burst passes, then add them again, paying the start-up cost each time and disturbing caches and connection pools. It can also chase its own tail: new instances are cold and slow, so latency or CPU per request rises briefly after scaling out, which looks like more load.

The standard defences are asymmetric. Scale out quickly, because under-capacity hurts users; scale in slowly, because over-capacity only costs money. A cooldown or stabilisation window makes the autoscaler wait after an action, or take the highest recommendation over a recent window before scaling in: the Kubernetes autoscaler, by default, uses a five-minute (300-second) window for scaling down and none for scaling up. A tolerance band ignores small deviations. Minimum and maximum instance counts bound the loop: the minimum keeps enough capacity to survive the loss of a zone and the first minutes of a surge, and the maximum protects the database, downstream services and the budget from an autoscaler that misreads a problem as load. Scaling in must also be graceful: an instance chosen for removal must stop receiving new requests and finish the ones in progress (connection draining, Section 9.2).

Worked example

Worked example: sizing the order API for the lunchtime peak

The food-delivery platform's order API must handle weekday lunch. Over the last eight weeks the busiest minute reached 2,050 requests per second (rps) at about 12:30; marketing expects 15% growth this quarter. A load test of one instance (4 vCPU, 32 worker threads) found a mean response time of 120 ms and latency starting to climb at about 260 rps. The service runs in three availability zones and must survive the loss of one zone at peak.

Forecast peak λ2,050 × 1.15 ≈ 2,360, rounded up to 2,400 rps
Little's law for the fleetL = λ × W = 2,400 × 0.12 s = 288 requests in flight at peak
Capacity of one instance32 threads ÷ 0.12 s ≈ 267 rps, which agrees with the knee the load test found (about 260 rps)
Target utilisation70% of 32 threads = 22.4 requests in flight per instance (about 187 rps), leaving 30% headroom for bursts
Instances for the peak288 ÷ 22.4 = 12.9, so 13 instances (2,400 ÷ 13 ≈ 185 rps each)
Surviving a zone loss13 spread 5/4/4: losing 5 leaves 8 instances with 288 ÷ 8 = 36 in flight each, more than 32 threads: overload. Allowing up to 85% (27.2 in flight) during a zone loss needs 288 ÷ 27.2 = 10.6, so 11 in two zones: 6 per zone, 18 in total
Ramp and scale-out lagDemand rises from 600 rps at 11:30 to 2,400 at 12:15: 40 rps per minute. Reactive scale-out takes about 5 minutes (1 minute to detect, 4 to boot and warm up), during which demand rises 200 rps; spare capacity at 70% is about 80 rps per instance, so reactive scaling alone could keep up with this gradual ramp; what it cannot provide is protection against a zone loss
DecisionScheduled scaling raises the minimum to 18 (6 per zone) at 11:15 and lowers it to 6 (2 per zone) at 14:00; reactive scaling on in-flight requests per instance (target 22) remains as a safety net, with a maximum of 30
Database check18 instances × a pool of 10 connections = 180; at the maximum of 30 instances, 300. The database allows 200, so the team adds a connection pooler before raising the maximum

Little's law turns a forecast rate and a measured response time into a concurrency figure that can be compared directly with thread and connection limits.

The zone-loss requirement, not the average load, sets the peak fleet size: 18 instances rather than 13. Reactive autoscaling cannot provide this protection because replacing a lost zone takes minutes.

Scaling out the application tier multiplied the load on the shared database's connections, which is the next bottleneck. Every sizing exercise should end by checking the tier below.

Scaling the data tier

Stateless application tiers are easy to scale; that is the point of making them stateless. The pressure then lands on the databases, caches and brokers they share, which is why the data tier is usually the real limit of a system. The tools were covered in Chapter 7 and are summarised here in the order professionals usually reach for them. First, reduce the work: indexes, better queries, fewer round trips per request. Second, absorb reads with caching (Section 7.6) and read replicas, accepting replication lag where the use case allows. Third, protect connections: each application instance holds a pool, so the total grows with the fleet, and a connection pooler such as PgBouncer lets thousands of client connections share a few hundred database connections (PostgreSQL's default max_connections is only 100). Fourth, scale up the primary, which is often cheaper than any redesign. Only then partition writes along the Z axis by a key such as city or customer (Section 7.4), or move to a distributed SQL database.

Two warnings apply. First, autoscaling the application tier can harm the data tier: every new instance opens connections and warms its cache from the database, so a large scale-out event can itself overload the database. Second, some limits are logical, not physical: a single hot row (the stock count for the last 500 tickets of a concert, or a global sequence number) serialises writes however large the server is. Those limits are removed by design, for example by splitting the stock into several counters or by reserving seats in batches, not by hardware.

Decision guide

Decision guide: how to add capacity to a component

Work through the questions in order for the component that limits the system today.

  1. Has the bottleneck been measured (utilisation, saturation and queueing for CPU, memory, connections, locks and downstream calls) rather than guessed?
    YesContinue with the component that saturates first.
    NoLoad-test and measure first. Scaling the wrong tier adds cost and no capacity, and can push more load onto the real bottleneck.
  2. Can the work itself be made cheaper (an index, a cache, fewer round trips, a smaller payload, less logging)?
    YesDo that first. It lowers cost at every scale and often removes the need to scale at all.
    NoContinue: capacity has to be added.
  3. Is the component stateless, or can its state (sessions, files, in-memory locks, schedules) be moved to shared stores?
    YesScale out on the X axis behind a load balancer, with autoscaling on a load signal and enough headroom for the scale-out lag.
    NoScale up for now, and plan the work to externalise the state; a stateful single instance is also a single point of failure.
  4. Is the limit in the data tier: one database's write rate, storage size or connection count?
    YesUse caching and read replicas for reads, a connection pooler for connections, and Z-axis partitioning (shards or cells) for writes and storage.
    NoContinue.
  5. Do parts of the component have very different load, scaling or failure needs?
    YesConsider a Y-axis split so each part scales and fails separately, accepting the costs of distribution described in Chapter 6.
    NoStay with X-axis cloning: it is the cheapest axis to operate.

Check your understanding

An order API's CPU is at 30% but p99 latency has tripled and all worker threads are busy. Why has the CPU-based autoscaler not reacted, and what signal would have?

The service is waiting on I/O, so threads are exhausted while the CPU is idle. In-flight requests per instance, compared with the thread count, would have risen with the load.

Which axis of the scale cube addresses a database that has outgrown one server's write capacity?

The Z axis: partition the data by a key so that each shard or cell handles a slice of the writes.

Why do autoscalers usually scale out quickly but scale in slowly?

Too little capacity hurts users immediately while too much only costs money, and scaling in too eagerly causes flapping when load returns.

Why can reactive autoscaling not protect a ticket on-sale that starts at exactly 10:00?

Demand jumps within seconds, but detecting the rise, starting instances and warming them up takes minutes, so the capacity must be scheduled before the sale starts.

Next · 9.2Load balancingLayer 4 and layer 7 load balancers, the main algorithms including the power of two random choices, liveness and readiness checks, connection draining, the trouble with sticky sessions, externalised session state and global load balancing.Continue →

Sources and further reading

Have a question about this section? ArchitectBot, the course’s AI study assistant, answers from the course first.

Ask: “Our order API autoscales on CPU but still falls over at lunchtime. What should we scale on instead, and how many instances do we really need?”