Skip to content

    System Design

    System Design: Scaling Basics

    Understand vertical versus horizontal scaling, why stateless services matter, how to find the real bottleneck, and what to scale first when traffic grows.

    By Published 15 min read

    Share

    This lesson is part of System Design Foundations: From Zero to Your First Design. Enroll for free, then sign in, to read it in full.

    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.

    1. Measure where the system is slow or failing.
    2. Find the bottleneck: the one resource that limits throughput.
    3. Relieve it with the cheapest suitable technique.
    4. 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
    Uploaded timesheet files, generated payslip PDFs Local disk of the server Object storage (see CDN and object storage)
    Employees, pay runs, tax rules In-memory objects Database
    Long work such as a pay run in progress A request thread or local temp files A queue plus workers, with results written to shared storage
    Cache Local memory only Shared cache, or local cache that is safe to lose
    Stateless app tier with shared state underneathClients to Load balancer; Load balancer to App 1; Load balancer to App 2; Load balancer to App 3; App 1 to Session store; App 2 to Database; App 3 to Object storageSTATELESS APP TIERSHARED STATEClientsClientsLoad balancerLoad balancerApp 1App 1App 2App 2App 3App 3Session storeSession storeDatabaseDatabaseObject storageObject storage

    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
    Memory Swapping, out-of-memory kills, garbage collection pauses 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

    Payroll SaaS starting architecture: autoscaled API against one PostgreSQLEmployers to Ingress; Ingress to Spring Boot; Spring Boot to Redis; Spring Boot to PostgreSQL; Spring Boot to Object storage; PostgreSQL to Read replicaAUTOSCALED, 6 TO 40 PODSEmployersEmployersIngressIngressSpring Boot: pool of 20/podSpring Bootpool of 20/podRedis: sessionsRedissessionsPostgreSQL: 500 connectionsPostgreSQL500 connectionsRead replicaRead replicaObject storage: payslip PDFsObject storagepayslip PDFs

    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.

    Enroll to keep reading

    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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    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.

    Join engineers building production AI systems

    Comments