All lessons
ArchitectureAdvanced14 min readIn my stack

Scaling and load balancing

Vertical vs horizontal scaling, load balancers, caching layers, replicas, pooling and sharding.

  • Vertical / horizontal
  • Load balancers
  • CDN
  • Read replicas
  • Sharding
Step 1/4
One server, a lot of users. CPU at 100%, everyone waits. Not great.

In 30 seconds

Scaling means keeping a system fast and available as traffic grows. You can buy a bigger server (vertical) or add more servers (horizontal), which requires stateless apps and a load balancer in front. Most real bottlenecks are in the database, so caching, read replicas, connection pooling and queues usually come before sharding. Measure first, then scale the part that is actually slow.

Why I use it

  • I use it because traffic is rarely smooth: promotions, push campaigns and lunchtime orders create spikes that a single process cannot absorb.
  • Designing apps to be stateless from the start makes adding servers a configuration change instead of a rewrite.
  • Most performance problems I fix are database and caching problems, so knowing the layers saves money: a cache or an index often beats new hardware.
  • Capacity planning lets me tell a client what their setup can handle and what the next step will cost before they need it.

How it works

01Vertical vs horizontal, and why stateless matters

Vertical scaling means a bigger machine: more CPU, RAM, faster disks. It is simple, needs no code changes and is often the right first step, but it has a ceiling, gets expensive, and one machine is still one point of failure. Horizontal scaling means more machines running identical copies of the app behind a load balancer. It scales further and survives the loss of a node, but only if any instance can serve any request. That requires a stateless app: sessions in Redis or signed tokens, uploads in object storage instead of local disk, cache in a shared store, scheduled jobs running once rather than on every node, and no in-memory state that matters. Make apps stateless early, even on one server, and horizontal scaling becomes straightforward.

  • Sessions and cache: Redis, not process memory or local files.
  • Files: S3-compatible object storage, served through a CDN.
  • Cron: one scheduler or a distributed lock, so jobs do not run N times.

02Load balancers: L4 vs L7, algorithms, health checks

A load balancer spreads requests across instances. Layer 4 balancers work with TCP/UDP connections: very fast, protocol-agnostic, but blind to URLs and headers. Layer 7 balancers understand HTTP: they terminate TLS, route by host or path, add headers, retry failed requests, compress and rate limit. nginx, HAProxy, Envoy, Traefik and cloud application load balancers are common L7 choices. Algorithms include round robin (simple), least connections (better when requests vary in duration), weighted (mixed hardware) and hashing (same client to same server). Health checks remove broken instances: passive checks watch real traffic for errors, active checks poll a health endpoint. Sticky sessions pin a user to one instance; they hide statefulness and unbalance load, so prefer shared session storage and keep stickiness for cases like WebSocket fallbacks.

nginx
upstream app {
    least_conn;
    server 10.0.0.11:3000 max_fails=3 fail_timeout=10s;
    server 10.0.0.12:3000 max_fails=3 fail_timeout=10s;
    keepalive 32;
}
 
server {
    listen 443 ssl;
    server_name example.com;
    # ssl_certificate / ssl_certificate_key lines go here
    location / {
        proxy_pass http://app;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_next_upstream error timeout http_502 http_503;
    }
}

03Caching layers: CDN, reverse proxy, application, database

The cheapest request is the one that never reaches your app. A CDN caches static assets and cacheable pages at edge locations near users, and also absorbs basic DDoS traffic. A reverse-proxy cache (nginx, Varnish) can serve repeated responses for a few seconds, which flattens spikes on hot pages. In the application, a Redis cache with the cache-aside pattern stores expensive query results with a TTL. The database has its own buffer cache, so good indexes keep hot data in memory. Each layer needs a clear invalidation story: version asset filenames, use short TTLs or purge on change for pages, and delete or update keys when data changes. Protect against stampedes when a popular key expires by using locks, request coalescing or slightly randomised TTLs.

04Scaling the database: pooling, replicas, partitioning, sharding

Databases are the usual bottleneck because they hold state and are hard to multiply. Work in order of cost. First, indexes and query fixes, found with slow-query logs and EXPLAIN. Second, connection pooling: PostgreSQL uses a process per connection, so dozens of app instances can exhaust it; PgBouncer in transaction mode lets thousands of client connections share a small pool. Third, read replicas for read-heavy traffic, remembering that replicas lag, so read-your-own-writes flows must hit the primary. Fourth, partitioning large tables (by date or tenant) inside one database to keep indexes small and drop old data cheaply. Last, sharding: splitting data across several databases by a shard key such as tenant ID. Sharding scales writes but makes cross-shard queries, transactions and rebalancing hard, so delay it until you have measured the need.

ini
; /etc/pgbouncer/pgbouncer.ini
[databases]
app = host=127.0.0.1 port=5432 dbname=app
 
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20

05Queues, back-pressure and capacity planning

Not everything must happen inside the request. Sending emails, generating reports, resizing images, syncing to an ERP: push them to a queue and let workers process them at a steady rate. Queues absorb spikes, isolate slow third parties and let you scale workers separately from web servers. Add back-pressure too: rate limits per client, timeouts on every outbound call, circuit breakers for failing dependencies, and graceful degradation (serve cached data, hide non-essential widgets) when overloaded. Capacity planning ties it together. Measure current throughput, latency percentiles (p95, p99) and resource usage; load test with tools like k6 to find the breaking point; identify which resource saturates first; then keep headroom (often 30 to 50 percent) for spikes and node failures. Repeat after major features, because bottlenecks move.

  • Watch p95/p99 latency, not averages; averages hide the slow users.
  • Load test a staging copy with realistic data volumes.
  • Plan for losing one node at peak (N+1 capacity).

Use it when

  • Traffic or data growth is pushing CPU, memory, database connections or latency toward their limits.
  • You need high availability: one instance failing must not take the site down.
  • Predictable spikes are coming (campaigns, launches, seasonal peaks).
  • Slow background work is blocking user-facing requests.

Skip it when

  • Before measuring: most slowness is a missing index or an N+1 query, not a lack of servers.
  • Sharding a database that a bigger instance, replicas and caching could still handle for years.
  • Adding instances of an app that still keeps sessions or files on local disk.

Alternatives

  • Bigger server first

    Vertical scaling is cheap in engineering time and often buys a year or more.

  • Managed platforms

    PaaS or serverless platforms autoscale for you, trading control and cost predictability.

  • Edge caching

    Serving more pages from a CDN can remove most origin load for content-heavy sites.

  • Kubernetes autoscaling

    Horizontal pod autoscaling for many services; heavy for one or two apps.

Best practices

  1. Keep apps stateless: sessions, cache and files in shared services.
  2. Put a health-check endpoint in every app and let the load balancer use it.
  3. Set timeouts and retries with backoff on every network call.
  4. Use connection pooling before you add app instances that share one database.
  5. Move slow or external work into queues with retries and dead-letter handling.
  6. Load test before big launches and keep headroom for a node failure at peak.

Common mistakes

  • Scaling app servers while the single database is the real bottleneck.
  • Relying on sticky sessions to hide local state, then losing users when a node dies.
  • Reading from a lagging replica right after a write and showing stale data.
  • Caching without an invalidation plan, then serving wrong prices or stock.
  • Running cron jobs on every instance after scaling out, so they execute several times.

Deep dive

06Queueing theory you can use: Little's law and utilisation

Little's law links three numbers: requests in flight = arrival rate x average time in the system. At 200 requests per second and 250 ms per request, about 50 requests are in progress at any moment, which tells you how many workers, threads or database connections you need. The second lesson is non-linear: as a resource approaches full utilisation, waiting time explodes rather than growing steadily. A server at 50 percent busy has short queues; at 90 percent, small bursts create long queues and p99 latency jumps. That is why headroom matters and why averages mislead. It also explains why adding connections to an overloaded database makes it slower: more concurrent work means more contention, not more throughput. Size pools from Little's law with a margin, and cap concurrency so excess load waits in a queue you control.

  • In flight = rate x latency; use it to size pools and workers.
  • Plan to run key resources well below saturation at peak.
  • Limit concurrency at the edge instead of letting it pile up in the database.

07Retry storms, timeouts budgets and load shedding

Retries help with brief glitches and hurt during overload. If each of three layers retries three times, one user request can become 27 calls to the bottom service exactly when it is struggling. Retry only at one layer, only idempotent operations, with exponential backoff and full jitter, and cap retries with a budget (for example, retries may add at most 10 percent extra load). Give every request a deadline and pass the remaining time downstream, so inner calls never outlive the user's patience. When overloaded, reject early with 429 or 503 and Retry-After instead of queueing forever; a fast failure is cheaper than a slow one. Prioritise traffic: shed analytics and recommendations before checkout. Circuit breakers stop calling a dependency that keeps failing and probe it periodically, giving it time to recover.

nginx
# nginx: per-IP rate limit with a small burst, reject the rest fast
limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;
limit_req_status 429;
 
server {
    location /api/ {
        limit_req zone=api burst=40 nodelay;
        proxy_connect_timeout 2s;
        proxy_read_timeout 10s;
        proxy_pass http://app;
    }
}

08Consistent hashing and cache scaling

When you spread keys across N cache or shard nodes with hash(key) mod N, adding one node remaps almost every key, so the cache empties and the database takes the full load at once. Consistent hashing places nodes and keys on a ring; each key belongs to the next node clockwise, so adding or removing a node moves only about 1/N of the keys. Virtual nodes (many points per server) smooth out the distribution. Redis Cluster uses a related idea: 16,384 hash slots assigned to nodes, moved in chunks during resharding, with hash tags like {tenant:42} to keep related keys in one slot. In load balancers, nginx's hash directive with the consistent parameter does the same for upstream selection. For hot keys that one node cannot serve, add a small in-process cache with a short TTL in front of the shared one.

09Tail latency and fan-out

When one request fans out to many backends, the slowest one decides the response time. If each backend has a 1 percent chance of a slow response, a request touching 100 of them is slow most of the time. Tail latency comes from garbage collection pauses, cold caches, noisy neighbours, lock contention and queueing. Defences: reduce fan-out by precomputing or denormalising; set per-call timeouts and return partial results when non-essential parts are late; use hedged requests, sending a second copy to another replica after the call exceeds the p95, only for idempotent reads; and keep instance sizes and runtime settings uniform so one weak node does not drag the whole pool. Always report p99 and p99.9 for key endpoints next to p50, and investigate the slowest traces, not just the averages.

Real-world scenarios

01

Database connections exhausted after autoscaling

Problem

During a campaign, the app scaled from 4 to 16 instances. Minutes later the PostgreSQL server refused connections with 'too many clients' and the whole site went down.

How to solve it

Each instance had a pool of 25, so 16 instances asked for 400 connections against a limit of around 200. Put PgBouncer in transaction mode in front of PostgreSQL and point every instance at it, with a server pool sized from Little's law (perhaps 20 to 40 real connections). Lower per-instance pool sizes, set a statement timeout, and cap autoscaling at the number the database tier can support. Check prepared-statement support: PgBouncer 1.21 and later can track protocol-level prepared statements with max_prepared_statements. Load test the scaled configuration before the next campaign.

02

A cache expiry takes the site down

Problem

The home page's product list is cached for 10 minutes. Every 10 minutes, latency spikes and the database CPU hits 100 percent for several seconds.

How to solve it

This is a cache stampede: when the key expires, hundreds of requests miss at once and all rebuild it. Use a lock so only one request rebuilds while others serve the stale value (stale-while-revalidate), or refresh the key in the background shortly before expiry. Add random jitter to TTLs so related keys do not expire together. At the proxy, nginx's proxy_cache_lock and proxy_cache_use_stale updating achieve the same for full pages. Finally, check the underlying query with EXPLAIN; a rebuild that takes seconds usually needs an index.

03

One slow dependency drags down every page

Problem

A third-party recommendations API sometimes takes 8 seconds. While it is slow, all app workers are busy waiting, and even pages that do not use it time out.

How to solve it

Worker exhaustion: blocked calls hold every slot. Set a short timeout (for example 800 ms) on that client, wrap it in a circuit breaker, and render the page without recommendations when it is open. Isolate the dependency with a bulkhead: a separate, small concurrency limit for that API so it can never take more than a few workers. Better still, load recommendations asynchronously from the browser or precompute them in a background job. Alert on the dependency's latency separately so you see the cause, not just the symptom.

Cheat sheet

Size a pool
Concurrency = requests/s x seconds per request (+ margin)
Least-connections upstream
upstream app { least_conn; server 10.0.0.11:3000; }
Consistent hashing in nginx
hash $request_uri consistent;
Rate limit per IP
limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;
Serve stale during refresh
proxy_cache_use_stale updating error timeout; proxy_cache_lock on;
Pool many app instances
PgBouncer pool_mode = transaction
Find slow queries
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
Load test ramp
k6 run --vus 200 --duration 10m script.js
Retry policy
Idempotent only, exponential backoff + full jitter, max 3, one layer

In my real projects

  • The POS SaaS runs with Docker Compose behind an nginx reverse proxy and a CDN, with PostgreSQL, Redis and separate BullMQ workers handling 20 queues.
  • Its real-time layer uses socket.io rooms per company, branch, terminal and user, with Redis pub/sub fanning events out across instances.
  • I put Cloudflare in front of sites for DNS and basic DDoS protection, and I am moving uploads to S3-compatible object storage so app servers stay stateless.
  • On the pet-supplies platform, search runs on Algolia with rate limiting, which keeps heavy search traffic off the main database.

Questions & answers

Q1How would you scale an application that is getting slow?

I measure before I change anything. Metrics and traces tell me whether the time goes into the app, the database or an external call. Most often it is the database: missing indexes, N+1 queries or too many connections. I fix queries, add caching for hot reads, add connection pooling and move slow work into queues. If the app tier itself is saturated, I make sure it is stateless and add instances behind a load balancer. Read replicas come next for read-heavy loads. Sharding is a last resort. After each change, I load test to confirm the bottleneck actually moved.

Q2What is the difference between L4 and L7 load balancing?

An L4 balancer works at the transport layer: it forwards TCP or UDP connections based on IP and port, without looking inside. It is very fast, cheap and works for any protocol, including databases and raw sockets. An L7 balancer understands HTTP: it terminates TLS, routes by hostname or path, inspects headers and cookies, retries idempotent requests, applies rate limits and can cache. That makes it ideal for web apps and APIs, at the cost of more processing per request. Many setups combine both: an L4 balancer for raw distribution and L7 proxies behind it for routing.

Q3Why are sticky sessions considered a smell?

Sticky sessions pin each user to one instance, usually by cookie. They make a stateful app appear to work behind a load balancer, but the problems remain: when that instance restarts or is removed, the user loses their session; load becomes uneven because heavy users stay on one node; and deploys and autoscaling become harder. The better fix is to remove the state from the instance, keeping sessions in Redis or using signed tokens, and files in object storage. Stickiness is still useful in narrow cases, such as long-polling fallbacks for WebSockets.

Q4When do you need read replicas and what are the pitfalls?

When reads dominate and the primary is busy with them: reporting, catalogue browsing, analytics. Replicas copy data from the primary, usually asynchronously, and serve read-only queries. The main pitfall is replication lag: a user saves their profile, the next page reads from a replica that is a second behind, and the change seems lost. Route reads that must see recent writes to the primary, or pin a user to the primary briefly after writing. Also monitor lag, make failover a tested procedure, and remember replicas do not help with write load.

Q5What is connection pooling and why does it matter?

Opening a database connection is expensive, and PostgreSQL creates a process per connection, so a few hundred connections can use a lot of memory and slow everything down. When you scale an app to many instances, each with its own pool, the total easily exceeds what the database handles well. A pooler like PgBouncer sits between them and lets many client connections share a small number of real ones. In transaction mode, a server connection is borrowed only for the length of a transaction. Session-level features such as SET or advisory locks need care in that mode.

Q6How do you approach capacity planning for a launch?

I start from expected business numbers, such as peak orders per minute, and translate them into requests per second on the key endpoints. Then I load test a staging environment with realistic data using a tool like k6, ramping up until latency percentiles or error rates break the agreed targets, and note which resource saturates first. I size the production setup for the expected peak plus headroom, typically enough to lose one node at peak and still cope. I also prepare levers in advance: cache rules, feature switches to turn off heavy widgets, and queue workers I can scale quickly.

Senior-level questions

S1When would you shard, and how would you choose the shard key?

Only after measuring that a single primary, even a large one with replicas, caching and partitioned tables, cannot handle write volume or data size for the planned horizon. The shard key should match the dominant access pattern so most queries hit one shard: in a B2B SaaS, tenant ID is natural because nearly every query is scoped to a tenant. It must spread load evenly, so watch for very large tenants that need a shard of their own. Avoid keys that create hotspots, such as timestamps. Keep a directory that maps keys to shards, so tenants can be moved without rehashing everything. The costs are real: cross-shard queries need fan-out or a separate analytics store, transactions stay within one shard, and migrations run per shard.

S2How do you decide what to autoscale on?

Scale on the signal that best predicts saturation of the thing you are scaling. For CPU-bound web tiers, CPU utilisation works; for I/O-bound apps, concurrent requests or latency is better, because CPU stays low while workers wait. For queue workers, scale on backlog divided by processing rate, which estimates time to drain. Add cool-down periods and scale-in delays to avoid flapping, and pre-scale before known events because new instances take time to boot and warm caches. Most importantly, check what autoscaling pushes onto: more web instances mean more database connections and more pressure on shared dependencies. A good autoscaling policy has a ceiling derived from the weakest downstream component.

S3How do you keep a system responsive when demand is higher than capacity?

Accept that some work will not be done and choose which. First, protect the core: admission control at the edge rejects excess requests quickly with 429 or 503 instead of letting queues grow until everything times out. Prioritise by business value, keeping checkout and login while dropping recommendations, analytics beacons and heavy exports. Degrade features gracefully, serving cached or simplified pages. Move deferrable work to queues with bounded size. Inside, cap concurrency per dependency and use deadlines so no work continues after the client gave up. The trade-off is explicit: some users see an error or reduced page, but the system stays up for the majority instead of collapsing for everyone.

Glossary

Horizontal scaling
Adding more identical instances behind a load balancer instead of a bigger machine.
Little's law
Items in a system equal arrival rate times average time spent in it.
Tail latency
The slowest responses, such as p99, which many users still experience.
Back-pressure
Signals or limits that slow producers when consumers or servers cannot keep up.
Load shedding
Rejecting some requests early under overload to keep the rest fast.
Circuit breaker
A switch that stops calling a failing dependency for a while, then probes it.
Bulkhead
Separate resource limits per dependency so one failure cannot consume everything.
Consistent hashing
Key placement where adding a node moves only a small share of keys.
Cache stampede
Many requests rebuilding the same expired cache entry at the same moment.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.