Skip to content

    System Design

    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.

    By Published 26 min read

    • system-design
    • system-design-project
    • load-balancing
    • caching
    • cdn
    • scaling
    Share

    This lesson is part of Backend System Design Project: Build QuickCart. 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 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.

    1. One server is a single point of failure. A crash or a deploy restart takes QuickCart offline.
    2. The server is not replaceable. Sessions live in its memory, so a second server would log half the shoppers out at random.
    3. Images and pages compete for the same threads and network link (this lesson assumes an illustrative 10 images per page).
    4. The database repeats itself. Every view of a popular item runs the same queries again.
    5. 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

    QuickCart Stage 1: one app server, sessions in memory, one databaseShoppers to One app server (every request); One app server to One database (repeated reads); One app server to Sessions in memory (login state)ShoppersShoppersOne app server: pages, API, imagesOne app serverpages, API,imagesSessions in memory: lost on restartSessions inmemorylost on restartOne database: every read lands hereOne databaseevery read landshereevery requestrepeated readslogin state

    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:

    1. Make the app tier stateless, so any server can answer any request, then run several behind a balancer.
    2. Do each piece of expensive work once, keeping copies of results at several layers.
    3. 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

    Load balancer with health checks in front of stateless app serversShoppers to Load balancer (HTTPS); Load balancer to App servers (least connections); Load balancer to App servers (probe healthz); App servers to Redis (session, cache); App servers to Primary database (on miss)ShoppersShoppersLoad balancer: layer 7, health checksLoad balancerlayer 7, healthchecksApp servers: stateless, any can answerApp serversstateless, anycan answerRedis: sessions and cacheRedissessions andcachePrimary databasePrimarydatabaseHTTPSleast connectionsprobe healthzsession, cacheon miss

    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 product
    async function getProduct(id: string): Promise<Product | null> {
      const key = `product:v1:${id}`;
      const cached = await cache.get(key);
      if (cached !== null) return JSON.parse(cached) as Product; // hit
    
      const row = await db.getProduct(id);                        // miss
      if (row !== null) await cache.set(key, JSON.stringify(row), jitteredTtl(600));
      return row;
    }
    
    async function updateProductTitle(id: string, title: string) {
      await db.updateTitle(id, title);       // source of truth first
      await 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 = new Map<string, Promise<unknown>>();
    
    async function singleFlight<T>(key: string, load: () => Promise<T>): Promise<T> {
      const running = inFlight.get(key) as Promise<T> | undefined;
      if (running) return running;            // join the rebuild in progress
      const 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:
    function jitteredTtl(baseSeconds: number, spread = 0.1): number {
      const factor = 1 - spread + Math.random() * 2 * spread; // between 0.9 and 1.1
      return Math.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):

    Cache-Control: public, max-age=31536000, immutable

    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:

    Cache-Control: public, max-age=0, s-maxage=30, stale-while-revalidate=60

    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

    1. 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.
    2. 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.
    3. Put the CDN in front of images and check that a repeat request shows a cache-hit indicator.
    4. 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.

    Official documentation

    Enroll to keep reading

    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.

    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