Understand vertical versus horizontal scaling, why stateless services matter, how to find the real bottleneck, and what to scale first when traffic grows.
What you'll learn: By the end of this lesson you will be able to compare vertical and horizontal scaling, make an application tier stateless by moving session state out of the server, locate the bottleneck in a slow system, and decide what to scale first for a read-heavy or write-heavy workload.
Imagine a small bakery that sells out by noon. The owner has two choices. She can buy a bigger oven and hire a faster baker, or she can open more shops with the same recipe. The first is quick but runs out: there is no oven the size of a building. The second takes planning, because every shop needs the same recipe, the same ingredients and a way to order flour, but it can keep growing.
Software scaling is the same decision. Scalability means that the system can handle more load by adding resources, without a rewrite and without performance collapsing. This lesson gives you the vocabulary and the habits to scale sensibly, using a payroll product as the example: quiet most of the month, then crushed in the last three days when every customer runs payroll at once.
The core idea
Scaling is never "add servers until it is fast". It is a loop.
Measure where the system is slow or failing.
Find the bottleneck: the one resource that limits throughput.
Relieve it with the cheapest suitable technique.
Measure again, because the bottleneck moves.
A chain is as strong as its weakest link. Doubling the number of web servers does nothing if the database is already at 100% CPU. This is why load balancing alone does not equal scalability: it only helps if the servers behind it are the part that is overloaded.
NOTE
Performance is how fast one request is. Scalability is whether the system keeps working as the number of requests grows. A system can be fast for one user and fall over at a thousand.
Vertical versus horizontal scaling
Vertical scaling (scaling up) means giving one machine more power: more CPU cores, more memory, faster disks. Horizontal scaling (scaling out) means adding more machines and sharing the work across them.
Aspect
Vertical (scale up)
Horizontal (scale out)
How
Bigger machine
More machines
Code changes
Usually none
Often needed: state must be shared or partitioned
Upper limit
Hard ceiling set by the largest machine available
Very high in theory, limited by coordination
Cost curve
Price per unit of power tends to rise at the top end
Roughly linear with commodity machines
Failure impact
One big machine is a single point of failure
One machine lost is a small fraction of capacity
Downtime to scale
Often a restart or resize
Usually none, add nodes live
Best for
Quick win, databases early on, simple apps
Stateless web and API tiers, anything needing high availability
Vertical scaling is underrated. A single well-sized database server can carry a surprising amount of traffic, and it keeps your design simple. But it has a ceiling, and it leaves you with one machine to lose. Horizontal scaling removes both limits at the price of complexity: now you must think about sharing state, consistency and partial failure.
In practice the answer is "both, in different places". Scale the web tier horizontally, because it is easy once it is stateless. Scale the database vertically first, because splitting data is hard (see replication and sharding later).
Stateless services: the key that unlocks scaling out
A stateless service keeps nothing about a user between requests. Everything it needs arrives with the request or is fetched from a shared store. A stateful service remembers things in its own memory or on its local disk.
If servers are stateless, they are interchangeable. You can add ten more, kill three, or replace all of them during a deploy, and no user notices. If servers are stateful, a request must reach the particular server that remembers the user, which leads to sticky sessions and fragile failures. Think of a hotel front desk: if the receptionist memorises your room number, you must find that same receptionist every time. If the number is in the booking system, any receptionist can help.
Externalising state
A payroll application has several kinds of state that tempt developers to store them in server memory. Each has a standard home.
State
Naive place
Better place
Login session
Server memory
Shared store such as Redis, or a signed token (for example a JWT) carried by the client
A queue plus workers, with results written to shared storage
Cache
Local memory only
Shared cache, or local cache that is safe to lose
The app tier can now grow and shrink freely. All the state lives in the layers below it, which are specialised for it and replicated for safety (each app instance talks to all three stores; only one arrow each is drawn).
TIP
A quick test of statelessness: can you terminate any one server, at any moment, with no user-visible effect other than perhaps one retried request? If not, find out what that server was holding.
A caveat on tokens: signed tokens remove the session store from the request path, but they are hard to revoke before they expire, so many systems still keep a server-side element for logout or blocking. There is no free option, only trade-offs.
Finding the bottleneck
Before scaling anything, ask which resource is saturated. A bottleneck is the component that reaches its limit first and so caps the whole system.
Resource
Symptoms
Typical causes
Common fixes
CPU
High utilisation, requests queue, latency rises with load
Heavy computation, serialisation, inefficient loops
Optimise hot code, add servers, move work to async workers
Large in-memory structures, leaks, too many threads
Fix leaks, shrink data, offload to a cache or store
Disk I/O
High disk wait, slow queries scanning large tables
Missing indexes, large writes
Add indexes, faster disks, batching, move cold data away
Network
Bandwidth maxed, many connections in wait states
Big payloads, chatty services, serving media directly
Compress, use a CDN, reduce calls
Database
Slow queries, lock waits, connection pool exhausted
N+1 queries, missing indexes, hot rows, one primary taking all reads
Index, cache, read replicas, connection pooling, eventually sharding
Two terms from the table: an N+1 query problem is fetching a list and then running one extra query for every item in it, and connection pooling means keeping a set of reusable database connections that requests borrow instead of opening a new one each time (see databases basics).
flowchart TD
S["Latency or errors rising"] --> Q1{"App CPU high and database healthy?"}
Q1 -->|yes| A1["Optimise hot code or add app instances"]
Q1 -->|no| Q2{"Database CPU or connections high?"}
Q2 -->|yes| Q3{"Repeated reads or slow queries?"}
Q3 -->|yes| A2["Index, cache, read replicas"]
Q3 -->|no| A3["Tune writes, queue bursts, bigger primary"]
Q2 -->|no| Q4{"Network or disk saturated?"}
Q4 -->|yes| A4["CDN, compression, faster disk"]
Q4 -->|no| A5["Trace one slow request end to end"]
Start at the top and answer from measurements, not hunches. The last box is the honest fallback: if no resource looks saturated, follow a single slow request through a trace to see where its time goes.
How to find the bottleneck in practice:
Look at utilisation and saturation together. The USE method (utilisation, saturation, errors) asks for each resource: how busy is it, is work waiting, and is it failing?
Load test in a safe environment and watch which graph hits the ceiling first. Turn the load up gradually.
Beware averages. Look at p95 and p99 latency (the slowest 5% and 1% of requests), since a healthy average can hide a painful tail.
Scaling reads versus scaling writes
Most applications are read-heavy, and reads and writes scale differently, so treat them separately.
Scaling reads is the easier problem, because reads do not change data and can be served from copies: cache results (caching basics), use a CDN for static content, add read replicas (copies of the database that serve reads; the main copy that accepts writes is the primary; replicas lag slightly, see replication), and denormalise (store some data redundantly so reads need fewer joins).
Scaling writes is harder, because every write must go to the authoritative copy and often must be ordered and consistent: optimise the write path (batching, fewer indexes on hot tables), scale the single primary vertically as far as sensible, buffer with a queue so bursts are absorbed (message queues), and only then partition (shard) the data across several primaries (sharding).
flowchart TD
Q["What is slow?"] --> R{"Reads or writes?"}
R -->|reads| R1["Index and cache"]
R1 --> R2["Read replicas and CDN"]
R -->|writes| W1["Tune writes and bigger primary"]
W1 --> W2["Queue to smooth bursts"]
W2 --> W3["Shard the data"]
The order in each branch matters: the earlier steps are cheaper and simpler. Sharding is the last resort, not the first.
Auto scaling basics
Auto scaling adds or removes servers automatically based on load, so you do not pay for idle capacity at 3 a.m. and do not fall over at lunchtime. It works best with the stateless application tier, because new servers must start serving immediately.
Reactive (metric-based). You choose a metric, such as average CPU at 60% or requests per server, and the system adds servers when it is above the target and removes them when below.
Scheduled or predictive. You know traffic rises at month end or during a planned campaign, so you add capacity ahead of time.
Pitfalls to understand:
Start-up time. A new server needs time to boot, warm caches and pass health checks. Reactive scaling is always a little late, so keep headroom for sudden spikes.
Flapping. Scaling out and in rapidly wastes money. Use cooldown periods and scale in more slowly than out.
Limits. Set a minimum (for availability) and a maximum (to cap cost and protect downstream systems). Ten new app servers each opening fifty database connections can overwhelm the database even though the app tier scaled fine.
The wrong metric. CPU is a poor signal for a service that waits on I/O. Queue length or request latency may be better.
WARNING
Autoscaling the app tier can push the bottleneck downstream. Always check that the database, cache and any third-party APIs can handle the extra concurrency.
What to scale first
When something is slow, use this table as a starting point. It assumes you have measured and confirmed the symptom.
Symptom
First thing to try
Next step
Last resort
App CPU high, database fine
Optimise hot paths, add app servers
Autoscaling
Rewrite the hot path
Database CPU high from repeated reads
Add indexes, cache hot reads
Read replicas
Shard reads by function or key
Database slow from bad queries
Fix queries and indexes
Denormalise a hot view
Move that data to a store suited to the access pattern
Database write throughput at its limit
Batch writes, trim indexes, bigger primary
Queue to smooth bursts
Shard the data
Spiky traffic
Queue non-urgent work
Autoscaling with headroom
Rate limit low-priority traffic
Latency high only for some users
Look at geography: CDN or regional deployment
Replicate data closer
Multi-region architecture
In practice: a payroll SaaS and its month-end spike
NOTE
A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.
The situation
A payroll SaaS serves about 2,000 small and mid-size employers. A stateless Spring Boot API runs on Kubernetes behind an ingress, with the Horizontal Pod Autoscaler scaling it on CPU. A managed PostgreSQL instance (one primary, one read replica) stores employees, pay runs and tax rules. Redis holds sessions. Payslip PDFs are generated inside the API request and saved to S3-compatible storage. Prometheus and Grafana provide metrics. A team of seven engineers owns it, and a contractual rule says pay runs must complete before bank cut-off times, so slow means penalties.
On the last three days of each month, traffic is roughly eight times the monthly baseline (illustrative). The pain: the autoscaler grows the API from 6 to 40 pods, but pay-run completion time rises from about 4 minutes to about 25. Database CPU sits near 95 percent, and logs show "timeout waiting for connection from pool".
Starting architecture
Forty pods times a pool of 20 is 800 possible connections against a limit of 500, and every pod also renders PDFs while holding a database transaction open.
Options considered
Option
What it fixes
What it costs
Risk
Do nothing, bigger database for the last week of each month
Raises the ceiling temporarily
Higher bill, manual resizing
Still hits the connection limit; resizing may need a restart
Cap pods, add a connection pooler (PgBouncer), move PDF generation to a queue with workers, schedule scale-out before month end
Connection exhaustion and long transactions
A few weeks of work, new queue to operate
Medium; behaviour changes for session-level features
Shard the data by employer
Removes the single primary limit
Large engineering effort, cross-tenant reports get harder
High; premature for current numbers
The decision
Field
Decision record
Context
The app tier scales out, but the primary database is the bottleneck and connections are over-subscribed.
Decision
Cap the autoscaler at 24 pods (24 x a pool of 20 = 480 application connections at most, which the pooler multiplexes onto far fewer real database connections), add a pooler in front of PostgreSQL, move payslip PDF generation to RabbitMQ workers, and scale on a schedule from the 26th.
Consequences, good
Bounded connections, shorter transactions, burst absorbed by the queue, predictable cost.
Consequences, bad
Two more components to run, and payslips become asynchronous so users see "generating" for a short time.
Revisit when
Primary CPU stays above 70 percent after these changes at peak, or pay-run completion exceeds 8 minutes at the 95th percentile.
Rollout and verification
Put PgBouncer in front of the primary in a staging environment first and run the application's test suite against it, because pooling modes change what is safe: in transaction pooling, session-level state such as SET commands or advisory locks does not carry over between statements. Then release behind a feature flag per employer, starting with internal test accounts. Load test with twice last month's peak before the next month end. Prove it with these signals: pool wait time, database connections versus the limit, primary CPU, queue depth and age of the oldest message, and pay-run duration percentiles.
Payroll SaaS target architecture: capped API, PgBouncer, queue and PDF workersEmployers to Ingress; Ingress to API pods; API pods to PgBouncer; PgBouncer to PostgreSQL; API pods to RabbitMQ (jobs); RabbitMQ to PDF workers; PDF workers to PgBouncer; PDF workers to Object storageEmployersEmployersIngressIngressAPI pods: max 24API podsmax 24RabbitMQ: payslip jobsRabbitMQpayslip jobsPgBouncer: connection poolerPgBouncerconnection poolerPDF workersPDF workersPostgreSQL: primaryPostgreSQLprimaryObject storageObject storagejobs
The target design: the API tier stays stateless and capped, the pooler guards the database, and slow PDF work leaves the request path.
The 2 a.m. failure
On the 28th at 02:00 a scheduled bulk payroll import starts. The page reads "API error rate above 5 percent, pool wait high". Detection: the connection-wait metric and the database connection count both approach their limits while the queue depth is flat. The first three checks: SELECT state, count(*) FROM pg_stat_activity GROUP BY state; (a large number of sessions "idle in transaction"), SHOW POOLS; in the pooler (clients waiting), and kubectl get hpa (the autoscaler is at its cap).
sequenceDiagram
participant J as Bulk import
participant A as API pod
participant T as Tax service
participant D as PostgreSQL
J->>A: import 5000 employees
A->>D: BEGIN and lock rows
A->>T: validate tax codes
T-->>A: slow response
Note over D: transaction open, connection held
A->>D: more imports wait for a connection
Mitigation: pause the import job, terminate the idle-in-transaction sessions older than a few minutes, and let the pool drain. The follow-up fix is to call the slow tax service before opening the transaction, give the import its own small pool so it cannot starve interactive traffic, and set a database idle_in_transaction_session_timeout.
Architect's notes
Measure the connection budget before you autoscale. Multiply maximum pods by pool size and compare it with the database limit. Make the autoscaler's maximum a number you computed, not a default.
Ask what the business deadline is. A month-end spike with a bank cut-off is a scheduled event, so scale on a schedule and treat reactive autoscaling as a safety net.
Separate interactive from batch capacity. Bulk imports and PDF generation should not share a connection pool or queue with a user waiting on a screen.
The database is a shared resource with one owner. Decide who approves new heavy queries and who is paged when connections run out. Unowned databases get tragedy-of-the-commons failures.
Count the bill of the peak. Running 40 pods for three days is cheap; permanently oversizing the database for those three days usually is not. Prefer elastic app capacity and a carefully sized database.
Defer sharding. Revisit it only when the primary, after pooling, caching and queueing, is still saturated at the next doubling of customers.
Common mistakes
Scaling before measuring. Adding servers to fix a database problem wastes money and hides the cause. Profile first.
Keeping state on app servers. Local sessions or files prevent scaling out. Move state to shared stores.
Jumping to microservices or sharding early. These add large operational cost. Try indexes, caching, replicas and a bigger machine first.
Ignoring the tail. Average latency looks fine while p99 is terrible. Track percentiles.
Autoscaling without limits or headroom. Without a maximum, a retry storm can scale costs and connections out of control; without headroom, spikes outrun boot time.
Forgetting downstream capacity. More app servers means more database connections. Use pooling and cap concurrency.
Treating scale as one-off. Traffic patterns change. Re-measure regularly and load test before big events.
Key takeaways
Scalability is a loop. Measure, find the bottleneck, fix it with the simplest tool, and measure again.
Vertical scaling is simple but capped. A great first step and often right for databases, but it leaves a single point of failure.
Horizontal scaling needs stateless services. If any server can handle any request, you can add and remove them freely.
Externalise state. Sessions, files and data belong in shared stores, not in server memory or local disk.
Find the bottleneck before you spend. CPU, memory, disk, network and database each have distinct symptoms and fixes.
Treat reads and writes differently. Reads scale with caches and replicas; writes need tuning, queues and eventually sharding.
Autoscaling is for the stateless tier. Give it minimums, maximums, cooldowns and check downstream limits.
Cheap fixes first. Indexes, caching and a bigger machine usually come before sharding or new architecture.
Practice exercises
Exercise 1 — Starter (30 min): List every piece of state in a simple payroll app (login, uploaded timesheets, pay runs, generated payslips, cached tax tables). For each, say where it should live so that the app servers are stateless, and name one risk of the wrong choice.
Exercise 2 — Intermediate (2 hours): For each symptom, write the one measurement you would take first and the most likely fix: (a) pay-run page p95 is 2 seconds, app CPU is 15%, database CPU is 90%; (b) uploads fail during evening bursts although average load is low; (c) pages are slow only for users on another continent. Then draw the architecture after your fixes using Mermaid or paper.
Exercise 3 — Advanced (half day): Build a tiny web service with an in-memory counter and run it behind a local load balancer with two instances. Observe the wrong results. Then move the counter into Redis or a database and observe the fix. Finally, design an autoscaling policy on paper: metric, target, minimum, maximum, cooldown, and a list of downstream limits you would check. Explain what would go wrong if you removed the maximum.
Lesson 7 of 12 · System Design Foundations: From Zero to Your First Design
Understand vertical versus horizontal scaling, why stateless services matter, how to find the real bottleneck, and what to scale first when traffic grows.
In the rest of this lesson
Finding the bottleneck
Scaling reads versus scaling writes
Auto scaling basics
What to scale first
In practice: a payroll SaaS and its month-end spike
Architect's notes
Enrolling is free. It unlocks every lesson in System Design Foundations: From Zero to Your First Design, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.