QuickCart Stage 2: Scaling Reads With Balancers, Caches and a CDN
Scale QuickCart's read path with stateless app servers, a load balancer, auto-scaling, layered caches, Redis and a CDN, sized for 2,300 requests per second.
What you'll learn: By the end of this lesson you will be able to explain how QuickCart grows from one server to a fleet: why servers must be stateless, how balancing algorithms and health checks work, what an auto-scaler measures and where it goes wrong, how cache layers, strategies, eviction, invalidation and stampedes behave on worked examples, what Redis and Memcached are each good at, and how a CDN takes images off your servers. You will size the read path with labelled illustrative numbers.
A small grocery shop has one assistant who greets customers, fetches stock, carries bags, and remembers every regular's order in their head. When the Saturday queue forms, customers wait, the assistant cannot take a break, and if they fall ill the shop closes.
The owner's fixes are familiar. Hire assistants who do the same job and keep notes on paper, not in their heads. Put someone at the door who sends each customer to a free assistant. Put best sellers on a shelf by the entrance. Let a van deliver bulky goods straight to customers. In software: stateless servers, a load balancer, caches and a CDN.
In Stage 1 QuickCart, our educational reference design for an online grocery marketplace, got one web app server and one database. Here we rebuild the read path, which answers "show me products" far more often than "take my order". Every number below is an illustrative assumption.
Where QuickCart is now
Stage 1 ended with a two-tier system in one region. The single app server renders pages, answers the API, keeps sessions in its own memory, and serves product images from its disk. The browsing peak at the final targets is about 2,300 requests per second (the arithmetic is in Stage 1). If peak traffic grows in proportion to monthly active users (an illustrative simplification), the stages look like this:
Stage
Monthly active users (illustrative)
Browsing peak, proportional (illustrative)
1, launch
10,000
about 2.3 requests per second
2, this lesson
100,000
about 23 requests per second
3
1,000,000
about 230 requests per second
4 to 10
5,000,000 growing to 10,000,000
about 1,150 growing to 2,300 requests per second
At 23 requests per second one server has capacity to spare, so why change anything? Because of what is structurally wrong, not what is slow today.
One server is a single point of failure. A crash or a deploy restart takes QuickCart offline.
The server is not replaceable. Sessions live in its memory, so a second server would log half the shoppers out at random.
Images and pages compete for the same threads and network link (this lesson assumes an illustrative 10 images per page).
The database repeats itself. Every view of a popular item runs the same queries again.
Promotions multiply traffic. A "free delivery today" push notification can raise the peak several times within minutes.
The judgement call: remove these structural blockers now, size the design for the final target, and run only the machines today's traffic needs.
Figure 1. QuickCart at the end of Stage 1: everything on one server
How to read this: the three amber boxes are the three structural problems. The app server is a single point of failure, its memory holds state no other server can see, and the database receives the same product reads over and over.
The core idea
A read-heavy system scales by making servers interchangeable and by answering most requests without touching the database. Three moves, in this order:
Make the app tier stateless, so any server can answer any request, then run several behind a balancer.
Do each piece of expensive work once, keeping copies of results at several layers.
Send heavy, shared bytes (images) through a CDN, a network of caches near shoppers.
Each move costs something: statelessness pushes state into another system, caches trade freshness for speed, and a CDN trades control (you cannot recall a file from every edge at once) for reach.
Making the app tier scale out
Vertical versus horizontal scaling
Vertical scaling (scaling up) gives one machine more CPU or memory. Horizontal scaling (scaling out) adds machines that share the work.
Vertical
Horizontal
Code changes
None
Servers must not hold shared state
Ceiling
The largest machine you can rent
Limited by shared parts (database, cache)
Failure
One machine, one outage
Lose one of many, the rest carry on
Vertical scaling is the right first step when you need relief today with no design change, but the ceiling is real and it never removes the single point of failure. QuickCart goes horizontal because availability, not only capacity, is the Stage 1 weakness. Only duplicable layers scale this way: app servers can; a database is harder, hence the later replication and sharding stages. See Scaling Basics.
Stateless services
A stateless service keeps nothing between requests that another instance would need. Each request carries, or points to, everything required to answer it.
QuickCart's Stage 1 server is stateful: it remembers who is signed in, keyed by a session id in the shopper's cookie. With two servers, a shopper who signs in on A and is sent to B looks signed out. There are two ways out.
Approach
How it works
Cost
Sticky sessions
The balancer keeps sending a shopper to the server they used first
A dead server logs out its shoppers; uneven load; awkward scale-in and deploys
Shared session store
The session lives in Redis; every server reads it by session id
Another system to run; a network hop per request
QuickCart picks the shared store. The cookie stays the same; only where the session data lives changes.
Figure 2. A sign-in and a later request on two different servers
sequenceDiagram
accTitle: Session kept in a shared store
accDescr: A shopper signs in through the load balancer to server A, which writes the session to Redis. The next request goes to server B, which reads the same session from Redis.
participant S as Shopper
participant L as Load balancer
participant A as App server A
participant B as App server B
participant R as Redis
S->>L: POST sign-in
L->>A: forward
A->>R: SET session id with expiry
R-->>A: OK
A-->>S: cookie with session id
S->>L: GET products with cookie
L->>B: forward to a different server
B->>R: GET session id
R-->>B: session data
B-->>S: personalised page
How to read this: server B has never seen this shopper, yet it answers correctly because the session lives in Redis, not in either server.
The Redis commands behind it, in redis-cli style (> is the prompt; EX 1800 makes the key expire after 1,800 seconds):
> SET session:9f2c41 s_1042 EX 1800
OK
> TTL session:9f2c41
(integer) 1800
> EXPIRE session:9f2c41 1800
(integer) 1
TTL shows the remaining life (a second later it prints 1799). EXPIRE resets it on activity, giving a sliding 30-minute timeout, and returns 1 when the timeout was set. A plain SET discards a key's earlier time to live, so pass EX again when rewriting a session.
A session lost to a Redis restart should cost a shopper only a new sign-in, so nothing precious lives in it: the cart stays in the database. Statelessness covers more than sessions: uploaded files and local caches must also move out or be safe to lose.
Spreading requests across the fleet
Load balancing: layer 4 versus layer 7
A load balancer forwards each request to one of several targets (the app servers) and gives clients one stable address while the servers behind it change (Load Balancing has the full treatment). A layer 4 balancer works with TCP or UDP connections: it sees addresses and ports and passes bytes through without reading HTTP. A layer 7 balancer understands HTTP, so it can route by path, host, header or cookie and spread each request separately. QuickCart uses layer 7 at the front door (/api/* to app servers, /images/* to the image origin), because one browser connection carries many requests and each should be spread. Layer 4 suits non-HTTP traffic such as a database connection pool.
Balancing algorithms
The algorithm answers: for a new request, which healthy target?
Round robin goes in turn: A, B, C, A, B, C. It is the default in nginx and AWS Application Load Balancer target groups, and assumes requests cost about the same.
Weighted round robin gives bigger machines a bigger share. In nginx, weight=3 against two servers of weight 1 sends 3 of every 5 requests to the heavy one.
Least connections sends the request to the target with the fewest active connections (least_conn in nginx; AWS offers least_outstanding_requests). It suits requests of very different duration.
Hash-based, including consistent hash, hashes something in the request (shopper id, store id) so the same key keeps landing on the same server. nginx has ip_hash and a hash directive with a consistent option. Use it when a server keeps a warm local cache per key. Plain hashing remaps most keys when a server joins; consistent hashing remaps few (see Redis below).
A worked trace shows when round robin misleads. Three servers; QuickCart's search is slow and product pages are fast. A new request arrives when active connections are A with 4 (slow searches), B with 1 and C with 1, and it is A's turn. Round robin sends it to A, so the shopper waits behind slow searches. Least connections picks B or C (they tie, and each balancer has its own tie-break rule).
Figure 3. Choosing a balancing algorithm for QuickCart
flowchart TD
accTitle: Choosing a load balancing algorithm
accDescr: A decision tree. If servers differ in size, use weights. If request cost varies a lot, use least connections. If a server keeps a per key local cache, use hashing. Otherwise use round robin.
Q1{"Servers equal in size?"} -->|no| W["Weighted round robin"]
Q1 -->|yes| Q2{"Request cost varies a lot?"}
Q2 -->|yes| LC["Least connections"]
Q2 -->|no| Q3{"Local state per key to keep warm?"}
Q3 -->|yes| H["Consistent hash on the key"]
Q3 -->|no| RR["Round robin"]
How to read this: answer from the top. QuickCart's mix of fast product pages and slow search lands on least connections.
Health checks at the balancer
A balancer must stop sending traffic to broken targets. Active checks call a URL on each target (QuickCart uses /healthz) at a fixed interval; a target is marked unhealthy after several consecutive failures and healthy again after several consecutive successes, so one slow reply does not remove a good server. Passive checks watch real traffic: in open-source nginx, max_fails failed attempts (default 1) within fail_timeout mark a server failed for that period.
What /healthz tests is a design choice. A shallow check (the process answers) catches crashes. A deep check of every dependency has a nasty failure: if Redis blips, every server fails at once, the balancer removes them all, and a small problem becomes a total outage. Check what the server alone controls (Stage 9 returns to this).
Three more features matter. Connection draining (AWS: deregistration delay, 300 seconds by default) lets in-flight requests finish on a target being removed. Slow start (AWS: 30 to 900 seconds, off by default) ramps up a new target's share. Fail-open: AWS can send traffic to all targets, healthy or not, when too few are healthy, because a degraded answer often beats none.
Figure 4. QuickCart's fleet behind a balancer
How to read this: solid arrows are requests; the dashed arrow is the balancer probing each server on a timer. A server that fails its probes leaves rotation without any shopper noticing.
Growing and shrinking the fleet
Auto-scaling
Auto-scaling changes the number of servers automatically from a measured signal, under a policy, between a minimum and maximum. CPU suits CPU-bound rendering but misleads when the database is the bottleneck; requests per server suits web tiers once you have load-tested a target; queue length suits workers. QuickCart starts with requests per server.
The arithmetic. The Kubernetes Horizontal Pod Autoscaler documents a proportional rule: desired replicas = ceil[current replicas x (current metric / desired metric)]. With 6 servers at 90 percent CPU against a 60 percent target, 6 x 90 / 60 = 9, so scale to 9. The same document skips scaling when the ratio is within a tolerance of 1.0 (0.1 by default), so 64 percent against 60 percent (ratio about 1.07) changes nothing.
Cooldowns and stabilisation. New servers take time to start, so a scaler that reacts at once overshoots. A cooldown pauses further scaling after an action: Amazon EC2 Auto Scaling's simple scaling policies use a 300-second default, while target tracking and step scaling use instance warm-up instead. A stabilisation window uses the highest recent recommendation before scaling down; the Kubernetes documentation gives a default downscale window of 5 minutes. The principle: scale out fast, scale in slowly.
The dangers of scaling in. Removing servers is where outages start. Without connection draining a terminated server drops its in-flight requests. Anything kept only in the server vanishes (the second reason for statelessness). Scaling out, in and out again within minutes (flapping) wastes money and chills caches, so use wider thresholds for scale-in.
There is also a danger on the way out. More servers means more database connections. At an illustrative 20 connections per server, 12 servers hold 240 and 40 servers hold 800. A database healthy at 12 servers can fall over when a scaler reacts to a slow database by adding servers. Always set a maximum the database can survive. That loop is the star of the 2 a.m. failure below.
Caching in layers
Cache layers
A cache keeps a copy of a result so the next request can skip the expensive work (Caching Basics explains why: locality and skew). A request can be answered at four levels, and the closer to the shopper, the cheaper.
Layer
Where it lives
QuickCart examples
Risk
Client
Browser or app
Images and scripts with long lifetimes
No forced refresh before the lifetime ends
CDN (edge)
Servers near the shopper
Product images; public lists for seconds
Purge delay; caching personal pages
Application
Redis or in-process memory
Product details, price and stock, sessions
Staleness; invalidation
Database
The database's own memory
Hot table and index pages (automatic)
Sized by the database
Figure 5. The path of a product page request through the layers
Cache layers between a shopper and the databaseBrowser cache to CDN edge cache (miss); CDN edge cache to App servers (miss); App servers to Redis cache (1 look up); App servers to Database (2 on miss)Browser cache: layer 1Browser cachelayer 1CDN edge cache: layer 2CDN edge cachelayer 2App servers: behind the balancerApp serversbehind thebalancerRedis cache: layer 3Redis cachelayer 3Database: layer 4: buffer cacheDatabaselayer 4: buffercachemissmiss1 look up2 on miss
How to read this: each arrow is a miss. A hit at any layer ends the journey there, so everything to its right is never touched. The leftmost hit is the cheapest.
Sizing the read path. Take the 2,300 requests per second peak and assume (illustrative) 3 lookups per page request: product details, the store's price and stock, a rating summary. That is 2,300 x 3 = 6,900 lookups per second. The hit ratio is hits divided by lookups. With the caching lesson's illustrative latencies (1 ms for a hit, 21 ms for a miss):
Hit ratio (illustrative)
Database reads per second
Average read time
0 percent (no cache)
6,900
21.0 ms
50 percent
3,450
11.0 ms
80 percent
1,380
5.0 ms
90 percent
690
3.0 ms
95 percent
345
2.0 ms
99 percent
69
1.2 ms
Going from 90 to 99 percent cuts database reads tenfold. The fleet follows the same arithmetic: if one app server handles 200 requests per second (illustrative), 2,300 / 200 = 11.5 means 12 servers, and 14 leaves room to lose two.
Hit ratio varies by key: a product read 0.5 times per second with a 30-second TTL gets 15 reads per window and at most one miss (about 93 percent), while a product read once a minute gets nearly none.
Cache strategies
A cache strategy decides who loads the cache and how writes reach it.
Strategy
How it works
Cost
QuickCart fit
Cache-aside
App reads the cache; on a miss it reads the database, stores the result, returns it. On a write it updates the database, then deletes the key
First request slow; a read/write race can leave stale data until TTL
Default for product details, categories, store info
Read-through
The cache layer itself loads from the database on a miss
Needs a library or service that supports it
Where the platform offers it
Write-through
A write goes to cache and database together and returns after both succeed
Slower writes; caches data never read
Small, hot data such as a store's open status
Write-back
A write goes to the cache and is saved to the database later, in batches
Unsaved data is lost if the cache fails first
View counters. Never orders, payments or stock
QuickCart uses cache-aside almost everywhere: it degrades safely when Redis is down. Pseudocode in TypeScript (cache is any client with get, set(key, value, ttlSeconds) and del; db is a placeholder):
// Pseudocode: cache-aside for one productasyncfunctiongetProduct(id: string): Promise<Product | null> {
const key = `product:v1:${id}`;
const cached = await cache.get(key);
if (cached !== null) returnJSON.parse(cached) asProduct; // hitconst row = await db.getProduct(id); // missif (row !== null) await cache.set(key, JSON.stringify(row), jitteredTtl(600));
return row;
}
asyncfunctionupdateProductTitle(id: string, title: string) {
await db.updateTitle(id, title); // source of truth firstawait cache.del(`product:v1:${id}`); // then drop the stale copy
}
The v1 in the key lets you invalidate a whole kind of entry by changing the version, and only non-null results are stored. jitteredTtl is defined below. Deeper patterns: Advanced Caching.
Eviction policies
A cache has finite memory, so a new entry can force an old one out. The eviction policy picks the victim; a TTL separately removes entries by age (Redis removes expired keys when a client reads them and also by sampling in the background).
LRU (least recently used): evict the entry used longest ago. The bet is recency.
LFU (least frequently used): evict the entry with the fewest uses. The bet is stable popularity.
A cache holds 3 entries. Requests in order: milk, eggs, milk, bread, apples, rice, milk. Milk is the staple; the rest are a scan of one-time products.
Step
Request
LRU cache after (oldest to newest)
LRU
LFU cache after (use counts)
LFU
1
milk
milk
miss
milk 1
miss
2
eggs
milk, eggs
miss
milk 1, eggs 1
miss
3
milk
eggs, milk
hit
milk 2, eggs 1
hit
4
bread
eggs, milk, bread
miss
milk 2, eggs 1, bread 1
miss
5
apples
milk, bread, apples (evicts eggs)
miss
milk 2, bread 1, apples 1 (evicts eggs)
miss
6
rice
bread, apples, rice (evicts milk)
miss
milk 2, apples 1, rice 1 (evicts bread)
miss
7
milk
apples, rice, milk (evicts bread)
miss
milk 3, apples 1, rice 1
hit
LRU gets 1 hit in 7, LFU gets 2: the scan pushed milk out of the LRU cache, while its count protected it under LFU. (Ties on one use evict the older entry, a common tie-break.) LFU's weakness is the reverse: an item hot last month keeps a high count, so real implementations decay counts over time.
Redis sets the choice with maxmemory-policy. Per its documentation, allkeys-lru is a good default when a subset of keys is read far more than the rest; allkeys-lfu exists from Redis 4.0; volatile-ttl evicts the shortest remaining TTL among keys that have one; noeviction errors on writes that need memory. Redis approximates LRU and LFU by sampling. On 64-bit systems maxmemory is 0 by default (no limit), so set one before expecting eviction.
Working set (illustrative): 100,000 products at 2 KB is 200 MB, which fits. Prices and stock vary by store: 5,000 stores x 2,000 stocked products (Stage 1) is 10 million entries, about 1 GB at 100 bytes each before overhead.
Cache invalidation and stampede
Invalidation keeps the cached copy and the source of truth in agreement. Three tools, usually combined: expire by TTL; delete on write; and version the key (product:v2:..., or a version in an image URL). Races remain: a reader fetches the old row, a writer updates the database and deletes the key, then the reader stores its old row. That stale entry lives until its TTL, which is why every entry keeps one.
A cache stampede (thundering herd) happens when a hot key expires and many requests miss at once, all rebuilding the same value. If the "fresh produce" list is read 400 times per second (illustrative) and takes 250 ms to rebuild, about 400 x 0.25 = 100 requests miss together and send the same query to the database, which slows the rebuild and invites more misses.
Figure 6. A stampede, and request coalescing stopping it
sequenceDiagram
accTitle: Cache stampede and request coalescing
accDescr: A hot key expires. Many requests miss. With request coalescing the first miss rebuilds the value from the database and the others wait for the same result.
participant R as Many requests
participant A as App server
participant C as Redis
participant D as Database
Note over C: category list key expires
R->>A: 100 requests in 250 ms
A->>C: GET key
C-->>A: miss
Note over A: first miss starts one rebuild, others wait on it
A->>D: one query
D-->>A: result
A->>C: SET key with jittered TTL
A-->>R: all 100 answered from one result
How to read this: all 100 requests miss, but only one query leaves the app server. Without coalescing each would send its own.
The defences:
Request coalescing (single flight). The first miss rebuilds; others wait for its result.
const inFlight = newMap<string, Promise<unknown>>();
asyncfunction singleFlight<T>(key: string, load: () =>Promise<T>): Promise<T> {
const running = inFlight.get(key) asPromise<T> | undefined;
if (running) return running; // join the rebuild in progressconst p = load().finally(() => inFlight.delete(key));
inFlight.set(key, p);
return p;
}
This works per process, so 12 servers can still run 12 rebuilds, down from 100 but not 1. For one rebuild across servers, use a short Redis lock: SET lock:key token NX EX 10 returns OK to the one caller that got it and a null reply to the rest, and the expiry stops a crashed rebuilder blocking everyone. Release only if the stored token is yours. Redis's documentation prefers its Redlock algorithm for stronger guarantees; for a rebuild lock, where the worst case is one extra rebuild, the simple form usually suffices.
Jittered TTLs. Randomise expiry so keys loaded together do not expire together:
functionjitteredTtl(baseSeconds: number, spread = 0.1): number {
const factor = 1 - spread + Math.random() * 2 * spread; // between 0.9 and 1.1returnMath.round(baseSeconds * factor);
}
// jitteredTtl(600) returns a whole number of seconds from 540 to 660
Stale-while-revalidate. Serve the expired value while one background request fetches the new one (RFC 5861 defines the HTTP form; in your own cache, store a "soft" expiry beside the value).
Pre-warming. Load the hottest keys before a known event or after a restart.
Distributed caches: Redis and Memcached
An in-process cache has a flaw at fleet scale: each server holds its own copy, copies disagree, and each warms up separately. A distributed cache is one service all servers share.
Redis
Memcached
Self-description
An in-memory data store with data types such as hashes and sorted sets
"An in-memory key-value store for small arbitrary data"
Eviction
TTL per key; policies including LRU and LFU variants
TTL per key; LRU by default
Durability
Snapshots (RDB), append-only file (AOF), both, or none ("sometimes used when caching")
A cache, not a store of record
Scale
Replication; cluster mode
Servers are unaware of each other; the client picks one by hashing the key
Good at
Sessions, counters, locks, plain caching
Simple caching of small values
QuickCart picks Redis because one service serves sessions (per-key expiry), caches and, later, counters; for a plain cache Memcached would also do. Either way the cache is a dependency, so set a timeout on every call.
Spreading keys over nodes. The naive rule hash(key) mod N has a nasty property: going from 3 nodes to 4 keeps only keys where h mod 3 equals h mod 4, which is 1 in 4, so about 75 percent of keys move and the cache goes cold at once. At 6,900 lookups per second that sends most of them to the database at once. Consistent hashing places nodes on a ring of hash values; a key belongs to the first node clockwise from its hash. Adding a fourth node takes over only the arc beside it, so about 1 in 4 keys move. See Sharding; Stage 4 applies the idea to the orders database.
CDN and edge for product images
A CDN (content delivery network) is a set of cache servers in many edge locations. A shopper's request goes to a nearby edge. On a hit the edge returns the file; on a miss it fetches from the origin, keeps a copy and returns it, so the origin sees roughly one request per file per edge, not one per shopper. Images are large, identical for everyone and rarely change. With an illustrative 10 images per page view, 2,300 page requests per second become about 23,000 image requests per second; at an illustrative 95 percent CDN hit ratio about 1,150 per second reach the origin. Servers are stateless, so the origin cannot be one server's disk: here it is an image endpoint on the app tier that reads bytes from a database table, and Stage 3 moves them to object storage. See CDN and Object Storage.
Figure 7. Where images and API calls go
CDN edge serving product images, forwarding misses to the image originShoppers to CDN edge (all requests); CDN edge to Image origin (image miss); CDN edge to Load balancer (API miss)ShoppersShoppersCDN edge: caches imagesCDN edgecaches imagesImage origin: one fetch per fileImage originone fetch perfileLoad balancer: API and pagesLoad balancerAPI and pagesall requestsimage missAPI miss
How to read this: shoppers only talk to the edge. Image hits never leave it; misses travel to the origin, and API calls go through the balancer.
The origin tells every cache how long it may keep a file in the Cache-Control response header. QuickCart uses two policies. For product images, with the version in the file name (/img/p/1042-v7.webp):
max-age=31536000 is a year in seconds, public allows shared caches, and immutable says the file will not change while fresh. A new photo gets a new name (-v8), so nothing needs purging. For the public product list JSON:
s-maxage=30 applies to shared caches only and overrides max-age; the 0 makes a browser's copy stale at once. After 30 seconds the CDN may serve the old copy up to 60 more seconds while it refreshes in the background (RFC 5861). Support for stale-while-revalidate varies by CDN and browser; check your provider's documentation.
Never send carts, orders, account pages or responses that set cookies to a shared edge cache: mark them private or no-store. A cached page can show a price up to 30 seconds old, so checkout always reads the database for the price it charges.
Read replicas: a first look
A read replica is a second database that receives the primary's changes and serves read-only queries. In PostgreSQL it is a standby using streaming replication, which is asynchronous by default: a committed transaction may not yet be visible on the standby (the documentation says typically under one second when the standby keeps up). That causes read-your-writes surprises: a store updates a price, the page reloads from a replica, and the old price appears.
QuickCart's first rule: browsing, category lists and store listings go to the replica; reads right after the same user's write, checkout and payment reads, and stock checks go to the primary. Leaders, followers, lag and failover belong to Scale Writes and Replication.
Decisions and trade-offs
Option
Gain
Cost
When it is the wrong choice
Scale up the one server
Immediate
Ceiling, single point of failure
When uptime matters
Scale out behind a balancer
Capacity, availability
Statelessness, a balancer to run
When the database is the bottleneck
Sticky sessions
No code change
Logouts on failure
When you plan to auto-scale
Shared session store
Any server serves anyone
A dependency to keep alive
When sessions hold irreplaceable data
Cache-aside in Redis
Fewer database reads, safe fallback
Staleness, invalidation
For data that must be exact when used
Write-back
Fast writes
Unsaved data can be lost
Orders, payments, stock
CDN for images
Bytes off your servers
Purge delay, charges
For private or per-user files
Read replica
Extra read capacity
Lag
Right after the same user's write
Auto-scaling
Capacity follows demand
Flapping, connection growth
With no database-safe maximum
In practice: the Saturday promotion at 100,000 users
NOTE
This is a realistic scenario, not a case history. It is a composite built from common patterns for an educational reference design. It describes no real company, and every number is an illustrative assumption.
The situation
QuickCart is in Stage 2 with 100,000 monthly active users and a normal peak of about 23 requests per second. A Saturday push notification, "Free delivery today", is expected to raise the peak 10 times, to about 230 requests per second, within minutes. The single server tops out near 100 page requests per second when it also serves images (illustrative), and database CPU is already high at normal peaks.
Starting architecture
Figure 8. Where the load lands at the promotion peak, before the change
Promotion peak load before Stage 2: images and queries all on one server and databaseShoppers to One app server (pages and images); One app server to Image files (read files); One app server to One database (3 lookups each)Shoppers: promotion peakShopperspromotion peakOne app server: 230 page requests per secondOne app server230 page requestsper secondImage files: about 2,300 per secondImage filesabout 2,300 persecondOne database: about 690 reads per secondOne databaseabout 690 readsper secondpages and imagesread files3 lookups each
How to read this: 10 images per page add about 2,300 image requests per second and 3 lookups per page add about 690 database reads per second, on a server limited to about 100 page requests per second.
Options considered
Option
What it fixes
What it costs
Risk
Bigger single server
Capacity this Saturday
A larger bill; still one machine
An outage is still total
Two servers, sticky sessions, no cache
Availability, some capacity
Database takes every read
The database is the next ceiling
Stateless servers, balancer, Redis, CDN, replica
Availability, read load, image bytes
New parts to run and watch
Stampede and connection risks
The decision
Field
Decision record
Context
A 10 times peak exceeds one server, and the structure blocks scaling out.
Decision
Sessions in Redis. Two stateless servers behind a layer 7 balancer (least connections, active health checks). Cache-aside in Redis: product details 10 minutes with jitter and delete on update; price and stock under separate 30-second keys. Images through a CDN with versioned URLs. One read replica for browse reads. Auto-scale between 2 and 4 servers.
Consequences
Capacity with room to spare (2 x 200 = 400 against 230), no single server to lose, fewer database reads. Prices on screen up to 30 seconds old; Redis and the balancer become dependencies.
Revisit when
Product-key hit ratio stays under 80 percent for a week, or active users pass about 1,000,000 (Stage 3).
The maximum of 4 is deliberate: at 20 connections per server (illustrative) the database carries at most 80.
Rollout and verification
Add the balancer and a second server, with sticky routing as a bridge; then move sessions to Redis and turn stickiness off. Sign in and refresh 20 times: the shopper stays signed in whichever server answers.
Run the cache in shadow mode (read it, still serve from the database, log mismatches), then serve from it for 5, 25, 50 and 100 percent of requests, watching database reads per second and p95 and p99 latency.
Put the CDN in front of images and check that a repeat request shows a cache-hit indicator.
Rehearse failure. In staging stop Redis and confirm pages load from the database; drain one server and confirm no request fails.
The 2 a.m. failure
Redis is restarted for maintenance at 02:00 and returns empty while the nightly catalogue import is slowing the database. Every request misses.
Figure 9. The cold-cache failure loop
sequenceDiagram
accTitle: Cold cache and auto-scaling feedback loop
accDescr: Redis restarts empty. Every request misses and hits the database, which slows down. CPU rises, the auto-scaler adds servers, which open more database connections and slow it further.
participant R as Redis
participant A as App servers
participant D as Database
participant S as Auto-scaler
Note over R: restarts empty at 02:00
A->>R: lookups
R-->>A: miss on every key
A->>D: all reads go to the database
Note over D: slows down, connections rise
S->>A: CPU high, add servers
A->>D: more connections
Note over D: slower still
How to read this: the cold cache starts the loop and the auto-scaler feeds it. More servers open more connections to an already slow database.
The page reads "p99 above 3 seconds, cache hit ratio below 20 percent". Detection: Redis keyspace_hits and keyspace_misses (INFO stats) show the hit ratio near zero while database reads climb. First three checks: 1) INFO server on Redis (an uptime reset proves a restart); 2) database connections and slowest queries (identical product queries?); 3) the scaler's event log (did it reach its maximum of 4? The cap kept connections at 80).
Mitigation: pause the import, serve category pages from a stale copy behind a flag, and run the warm-up script for the most viewed products. Follow-ups: jittered TTLs, single-flight rebuilds, Redis with a replica and failover, a timeout on every cache call, and no maintenance during imports.
Architecture after this stage
Figure 10. QuickCart after Stage 2 (new components in the accent colour)
QuickCart architecture after Stage 2Shoppers to CDN; CDN to Image origin (image miss); CDN to Load balancer (miss); Load balancer to App servers; App servers to Redis (cache, session); App servers to Primary database (writes); App servers to Read replica (browse reads); Primary database to Read replica (replicate); Auto-scaler to App servers (add or remove)ShoppersShoppersCDN: images, public listsCDNimages, publiclistsImage originImage originLoad balancer: layer 7, health checksLoad balancerlayer 7, healthchecksAuto-scaler: requests per serverAuto-scalerrequests perserverApp servers: stateless, 2 to 4App serversstateless, 2 to 4Redis: cache and sessionsRediscache andsessionsPrimary databasePrimarydatabaseRead replica: browse readsRead replicabrowse readsimage missmisscache, sessionwritesbrowse readsreplicateadd or remove
How to read this: accent boxes are new in this stage; the primary database is unchanged. Requests flow left to right and stop at the first layer that can answer. Dashed arrows are background work.
What QuickCart looks like now
Component
Why it exists
What it costs
CDN and image origin
Serve images near shoppers
Charges; purge delay
Load balancer
One address; removes sick servers
A new dependency
Stateless app servers
Scale by adding copies
Externalised state
Auto-scaler
Follows demand within limits
Tuning; flapping and connection risks
Redis
Sessions and cache shared by all servers
A system to run and monitor
Read replica
Extra read capacity
Lag; read-your-writes care
Primary database
The only writer
Still a single writer: Stage 4
Architect's notes
Remove structural blockers before chasing throughput. At 23 requests per second QuickCart needed no capacity; it needed to stop being one machine with a memory.
Measure the skew first: how many products cover 80 percent of requests predicts the hit ratio.
Decide freshness per field, and ask what happens on a cold cache. If the answer is "the database falls over", test it.
Cap auto-scaling at what the database survives, and watch hit ratio per layer (Observability and SLOs).
Common mistakes
State left in a server while adding servers: shoppers are logged out at random.
A deep health check that fails every server when one dependency blips and empties the fleet.
Auto-scaling on a signal that is not the bottleneck, or with no maximum.
Caching without a TTL, or caching errors. A blip becomes a ten-minute outage.
Leaving Redis maxmemory unlimited,modulo hashing then resizing, and reading the checkout price from a cache.
Key takeaways
Scale out the stateless parts; keep stateful parts few. Sessions go to a shared store.
Layer 7 balancers read HTTP. Least connections for varied cost, weights for unequal servers, hashing for warm per-key caches.
Keep health checks shallow, require consecutive results, drain on removal.
Auto-scaling needs a good signal, cooldowns and limits: scale out fast, in slowly, and cap at what the database holds.
The leftmost cache hit is cheapest; the last points of hit ratio protect the database most.
Cache-aside with TTL, jitter and delete-on-write is the default; write-back only for data you can afford to lose.
LRU bets on recency, LFU on frequency, TTL bounds staleness. Give Redis a maxmemory and a policy.
Defend against stampedes with coalescing, jitter and stale-while-revalidate.
CDN for large, shared, rarely changing files with versioned URLs; never for personal data.
Practice exercises
Exercise 1, starter (30 min): Redo the eviction trace with a 2-entry cache and the sequence milk, eggs, bread, milk, eggs, milk, counting LRU and LFU hits.
Exercise 2, intermediate (1 hour): At 1,150 requests per second (Stage 4, illustrative) with 3 lookups per request, compute database reads per second at 80, 90 and 95 percent hit ratios, and the servers needed at 200 requests per second each.
Exercise 3, intermediate (2 hours): For ten QuickCart items (description, image, price, stock, rating, cart, search results, delivery estimate, order status, session) choose layer, TTL, invalidation and strategy, and justify any item you refuse to cache.
Exercise 4, advanced (half day): Run Redis locally with a service whose database call sleeps 100 ms. Implement cache-aside, then fire 200 concurrent requests at one expired key with and without single flight, counting database calls. Compare allkeys-lru and allkeys-lfu under maxmemory 10mb on a skewed request pattern.
Lesson 2 of 11 · Backend System Design Project: Build QuickCart
Scale QuickCart's read path with stateless app servers, a load balancer, auto-scaling, layered caches, Redis and a CDN, sized for 2,300 requests per second.
In the rest of this lesson
Growing and shrinking the fleet
Caching in layers
Decisions and trade-offs
In practice: the Saturday promotion at 100,000 users
Architecture after this stage
Architect's notes
Enrolling is free. It unlocks every lesson in Backend System Design Project: Build QuickCart, 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.