0Pricing
DSA Interview Prep · Lesson

Caching, CDNs, and Load Balancing

Add Redis caching layers, push static assets to a CDN, and distribute traffic across replicas with round-robin and consistent-hashing load balancers.

Caching, CDNs, and Load Balancing is a free DSA Interview Prep lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the DSA Interview Prep learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Why Caching Is Essential at Scale

Caching stores copies of frequently accessed data in a faster storage layer so that future requests can be served without hitting the slower backing store (database, external API). At scale, a small number of popular items receive the vast majority of requests — the 80/20 rule (Pareto principle) often applies: 20% of items account for 80% of traffic.

A cache that fits the hot 20% in memory can absorb 80% of database load. This is why adding a Redis cache often reduces database CPU by 70–90% and cuts p99 latency from 10ms to under 1ms for cache hits — without changing the database or application logic significantly.

# Demonstrating the 80/20 caching benefit
import random

# Simulate 1000 requests to 100 items with Zipf-like distribution
def zipf_sample(n_items, n_requests):
    access_counts = {}
    weights = [1.0 / (i + 1) for i in range(n_items)]  # Zipf: item 0 most popular
    total = sum(weights)
    probs = [w / total for w in weights]
    for _ in range(n_requests):
        item = random.choices(range(n_items), weights=probs)[0]
        access_counts[item] = access_counts.get(item, 0) + 1
    return access_counts

random.seed(42)
counts = zipf_sample(100, 10000)
top_20_items = sorted(counts, key=counts.get, reverse=True)[:20]
top_20_requests = sum(counts[i] for i in top_20_items)
print(f'Top 20% of items ({20} of 100) handle {top_20_requests/100:.1f}% of requests')

Cache-Aside Pattern (Lazy Loading)

The cache-aside pattern (also called lazy loading) is the most common caching strategy. The application code is responsible for managing the cache: on a read, check the cache first. On a cache hit, return immediately. On a cache miss, fetch from the database, write to cache, then return. On a write, update the database and invalidate (delete) the cache entry so the next read refreshes it.

This pattern ensures the cache only holds data that was actually requested (no unnecessary pre-loading) and stays consistent with the database via invalidation. The trade-off: first access after cache miss pays the full database cost (cold start).

# Cache-aside pattern in Python
class CacheAsideService:
    def __init__(self, db, cache):
        self.db = db
        self.cache = cache   # e.g., Redis client

    def get_user(self, user_id):
        cache_key = f'user:{user_id}'
        # 1. Check cache
        cached = self.cache.get(cache_key)
        if cached:
            return cached    # cache hit
        # 2. Cache miss: fetch from DB
        user = self.db.query('SELECT * FROM users WHERE id=%s', user_id)
        # 3. Write to cache with TTL
        self.cache.set(cache_key, user, ttl=3600)  # 1 hour TTL
        return user

    def update_user(self, user_id, data):
        # 1. Write to DB
        self.db.execute('UPDATE users SET ... WHERE id=%s', user_id, data)
        # 2. Invalidate cache (delete, not update)
        self.cache.delete(f'user:{user_id}')
        # Next read will re-populate cache from DB

print('Cache-aside: READ from cache, miss? load from DB + write cache')
print('         WRITE to DB, then DELETE from cache (invalidate)')

Write-Through and Write-Behind Caching

Write-through: on every write, update both the database and the cache synchronously. The cache always contains fresh data. Trade-off: writes are slower (two operations), and the cache fills with data that may never be read again.

Write-behind (write-back): on write, update the cache only; asynchronously flush to the database later. This makes writes extremely fast but risks data loss if the cache fails before the flush. Used in write-heavy workloads where some data loss is acceptable (e.g., view counters, analytics).

# Write-through vs Write-behind comparison
strategies = {
    'Cache-aside (Lazy)': {
        'read':  'Check cache; miss => DB + populate cache',
        'write': 'Write DB; delete from cache (invalidate)',
        'consistency': 'Strong (invalidation ensures freshness)',
        'write_latency': 'Fast (one DB write)',
        'risk': 'Cache stampede on popular key expiry',
    },
    'Write-through': {
        'read':  'Always check cache; miss => DB',
        'write': 'Write DB AND cache atomically',
        'consistency': 'Strong (cache always has latest)',
        'write_latency': 'Slower (two writes per operation)',
        'risk': 'Cache polluted with rarely-read data',
    },
    'Write-behind': {
        'read':  'Check cache; miss => DB',
        'write': 'Write cache only; async flush to DB',
        'consistency': 'Eventual (flush may be delayed)',
        'write_latency': 'Very fast (cache write only)',
        'risk': 'Data loss if cache crashes before flush',
    },
}
for name, info in strategies.items():
    print(f'\n{name}:')
    for k, v in info.items(): print(f'  {k}: {v}')

Cache Eviction Policies

When the cache is full, the eviction policy decides which entry to remove. The most common policies:

  • LRU (Least Recently Used): evict the entry that has not been accessed the longest. Performs well for temporal locality workloads. Used by Redis by default.
  • LFU (Least Frequently Used): evict the entry accessed fewest times. Better for workloads where some items are permanently popular but LRU would not capture this.
  • FIFO: evict oldest insertion. Simple but poor performance for typical web workloads.
  • Random: evict a random entry. Surprisingly competitive with LRU in practice at very large caches.
# Implementing LRU cache
from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.cache = OrderedDict()  # maintains insertion/access order

    def get(self, key):
        if key not in self.cache:
            return -1
        self.cache.move_to_end(key)   # mark as recently used
        return self.cache[key]

    def put(self, key, value):
        if key in self.cache:
            self.cache.move_to_end(key)
        self.cache[key] = value
        if len(self.cache) > self.capacity:
            self.cache.popitem(last=False)   # evict LRU (oldest)

cache = LRUCache(3)
for k, v in [('a',1),('b',2),('c',3)]:
    cache.put(k, v)
print('Get a:', cache.get('a'))   # 1 (a now most recently used)
cache.put('d', 4)                  # evicts 'b' (LRU)
print('Get b:', cache.get('b'))   # -1 (evicted)
print('Get c:', cache.get('c'))   # 3

Content Delivery Networks (CDNs)

A CDN is a geographically distributed network of edge servers (Points of Presence, PoPs) that cache static and dynamic content close to end users. Instead of every user's request travelling to an origin server in one data centre, CDN edge nodes serve the content from the nearest PoP — reducing latency from ~200ms (cross-continent) to ~5ms (nearby PoP).

CDNs are essential for: static assets (images, CSS, JS), video streaming (HLS segments), and increasingly, API responses and server-rendered HTML. The CDN checks its edge cache; on a cache miss, it fetches from the origin and caches for future requests.

# CDN architecture flow
cdn_flow = [
    'User requests https://example.com/image.jpg',
    'DNS resolves to the nearest CDN PoP (e.g., Frankfurt for EU users)',
    'CDN edge checks its local cache:',
    '  HIT:  Return cached image directly (5ms latency)',
    '  MISS: Fetch from origin server (e.g., AWS S3 in us-east-1)',
    '        Cache image at edge with Cache-Control: max-age=86400',
    '        Future requests for this image served from edge (HIT)',
    'Cache-Control headers control CDN behaviour:',
    '  max-age=31536000 s-maxage=31536000  -- cache 1 year',
    '  no-cache                             -- always revalidate',
    '  private                              -- CDN must not cache (user-specific)',
]
for step in cdn_flow:
    print(step)

print('\nCDN providers: Cloudflare, AWS CloudFront, Fastly, Akamai')

Load Balancing: Distributing Traffic

A load balancer distributes incoming requests across multiple backend servers, preventing any single server from becoming a bottleneck. It also provides high availability: if one server fails, the load balancer routes traffic to healthy servers automatically (health checks every 5-30 seconds).

Load balancers operate at different OSI layers: Layer 4 (transport — routes by IP/port, very fast) and Layer 7 (application — routes by URL path, headers, cookies, allowing more intelligent routing). AWS ALB, Nginx, and HAProxy are common Layer 7 load balancers. AWS NLB is a Layer 4 load balancer.

# Load balancing algorithms
algorithms = {
    'Round Robin': {
        'how': 'Rotate through servers in sequence',
        'best_for': 'Stateless servers with similar capacity',
        'weakness': 'Does not account for server load or response time',
    },
    'Weighted Round Robin': {
        'how': 'Round robin but servers with more capacity get more requests',
        'best_for': 'Heterogeneous server fleet',
        'weakness': 'Static weights; does not adapt to runtime load',
    },
    'Least Connections': {
        'how': 'Send to server with fewest active connections',
        'best_for': 'Long-lived connections (WebSocket, streaming)',
        'weakness': 'More complex tracking of connection state',
    },
    'Consistent Hashing': {
        'how': 'Hash request key (user_id, session) to server',
        'best_for': 'Sticky sessions, cache locality per server',
        'weakness': 'Uneven distribution if hash space is not balanced',
    },
    'Random': {
        'how': 'Choose server at random',
        'best_for': 'Simple stateless workloads',
        'weakness': 'No guarantee of load balance in short windows',
    },
}
for alg, info in algorithms.items():
    print(f'{alg}: {info["how"]}')

Consistent Hashing: Adding and Removing Nodes

Consistent hashing solves the problem of redistributing cache keys when servers are added or removed. In naive modulo hashing (server = hash(key) % n), changing n remaps nearly all keys — causing a cache stampede. Consistent hashing maps both keys and servers onto a ring; each key is served by the nearest server clockwise. Adding a server only remaps keys between the new server and its predecessor — about 1/n of all keys.

Virtual nodes (vnodes) improve load distribution: each physical server is assigned multiple positions on the ring, so keys are more evenly spread even with a small number of servers.

import hashlib
import bisect

class ConsistentHashRing:
    def __init__(self, replicas=100):
        self.replicas = replicas      # virtual nodes per server
        self.ring = {}
        self.sorted_keys = []

    def add_server(self, server):
        for i in range(self.replicas):
            key = int(hashlib.md5(f'{server}:{i}'.encode()).hexdigest(), 16)
            self.ring[key] = server
            bisect.insort(self.sorted_keys, key)

    def remove_server(self, server):
        for i in range(self.replicas):
            key = int(hashlib.md5(f'{server}:{i}'.encode()).hexdigest(), 16)
            del self.ring[key]
            self.sorted_keys.remove(key)

    def get_server(self, item):
        key = int(hashlib.md5(item.encode()).hexdigest(), 16)
        idx = bisect.bisect(self.sorted_keys, key) % len(self.sorted_keys)
        return self.ring[self.sorted_keys[idx]]

ring = ConsistentHashRing()
for s in ['server-1', 'server-2', 'server-3']:
    ring.add_server(s)
for item in ['user:1', 'user:2', 'product:abc', 'session:xyz']:
    print(f'{item} => {ring.get_server(item)}')

Cache Stampede and Solutions

A cache stampede (or thundering herd) occurs when a popular cache entry expires and many concurrent requests all miss the cache simultaneously, flooding the database with the same query. Solutions:

  • Mutex/lock: only one request computes the value; others wait
  • Probabilistic early expiry: slightly before TTL, a request randomly decides to refresh the cache, preventing simultaneous expiry
  • Stale-while-revalidate: serve stale content immediately while asynchronously refreshing the cache
  • Background refresh: a separate process refreshes popular keys before they expire
import time, threading, random

# Probabilistic early expiry (XFetch algorithm)
class ProbabilisticCache:
    def __init__(self):
        self._cache = {}

    def get(self, key, ttl, recompute_fn, beta=1.0):
        if key in self._cache:
            value, expiry, delta = self._cache[key]
            # XFetch: decide to refresh early with probability proportional to delta/TTL
            remaining = expiry - time.time()
            if remaining > 0:
                early_refresh_score = delta * beta * (-1) * (remaining / ttl)
                if random.random() > (1 - early_refresh_score):  # simplified
                    pass  # could trigger async refresh here
                return value
        # Cache miss or expired
        start = time.time()
        value = recompute_fn()
        delta = time.time() - start          # computation time
        expiry = time.time() + ttl
        self._cache[key] = (value, expiry, delta)
        return value

print('XFetch: refresh probabilistically before expiry based on computation cost')
print('High-cost computations => refresh earlier to avoid stampede')
print('Low-cost computations => refresh closer to TTL')

CDN Cache Invalidation

Cache invalidation is famously difficult: 'There are only two hard problems in computer science: cache invalidation and naming things.' When content changes at the origin, CDN edge nodes must serve the new version. Strategies:

  • TTL-based expiry: let content expire naturally (simple but stale window)
  • URL versioning: embed content hash in URL (e.g., main.a3f2b.js); new content = new URL, no invalidation needed
  • CDN API purge: explicitly purge URLs via API call after deployment (fast but requires CDN API integration)
# Cache invalidation strategies for CDN/browser
strategies = [
    {
        'name': 'Long TTL + URL versioning (best for static assets)',
        'example': '<script src="/app.a3f2b1c.js"></script>',
        'ttl': 'Cache-Control: max-age=31536000 (1 year)',
        'how': 'Content hash in filename; new deploy = new URL; old URL cached forever (OK)',
    },
    {
        'name': 'Short TTL (for frequently changing content)',
        'example': '/api/v1/config',
        'ttl': 'Cache-Control: max-age=60 (1 minute)',
        'how': 'Simple; content is at most 60s stale; no invalidation needed',
    },
    {
        'name': 'CDN API purge (for news / social media)',
        'example': '/news/breaking-story.html',
        'ttl': 'Cache-Control: s-maxage=3600',
        'how': 'On publish, call CDN.purge(url); edge serves new version immediately',
    },
]
for s in strategies:
    print(f'{s["name"]}:')
    print(f'  Example: {s["example"]}')
    print(f'  TTL: {s["ttl"]}')
    print(f'  Strategy: {s["how"]}\n')

Architecture: Putting It All Together

A fully scaled web application layer uses all three techniques together: load balancing distributes traffic, CDN absorbs static and cacheable API requests, and Redis caches dynamic data. The database only sees cache misses — typically 5-20% of requests.

A typical request flow for a read-heavy API: user → DNS → CDN edge (cache hit: served immediately) → CDN miss → load balancer → app server pool → Redis cache (hit: 1ms response) → Redis miss → database (10-50ms) → response cached in Redis + optional CDN → user. Each layer reduces database load significantly.

# Request flow with cache hit rates
request_flow = [
    ('Browser Cache',       '10%',  '0ms',   'Browser caches GET responses per Cache-Control'),
    ('CDN Edge Cache',      '60%',  '5ms',   'CloudFront/Fastly caches cacheable API responses'),
    ('Load Balancer',        None,  '1ms',   'Routes to healthy app server replica'),
    ('App Server',           None,  '2ms',   'Business logic, auth check'),
    ('Redis Cache',         '25%',  '1ms',   'Caches computed data, hot DB rows'),
    ('Database Read Replica','5%',  '10ms',  'Cache miss: query read replica'),
    ('Database Primary',    '0.1%', '15ms',  'Cache+replica miss: query primary (rare for reads)'),
]
print(f'{'Layer':30s} {'Hit Rate':10s} {'Latency':10s} {'Notes'}')
print('-'*80)
for layer, hit_rate, latency, note in request_flow:
    hr = hit_rate if hit_rate else '-'
    print(f'{layer:30s} {hr:10s} {latency:10s} {note}')
print('\nResult: DB sees ~5% of requests; Redis sees ~25%; CDN absorbs 60%; browser 10%')

Interview Tips: Caching and Load Balancing

When discussing caching in a system design interview, always address: what to cache (hot data, expensive computations), where to cache (browser, CDN, application, database query cache), when to invalidate (on write, on TTL expiry, or via background refresh), and what consistency guarantees are acceptable. A cache introduces a consistency window — be explicit about it.

For load balancing, mention the algorithm choice, health checks, sticky sessions (if needed), and whether horizontal scaling of stateless app servers is possible. If the app has state (WebSocket connections, sessions), address how that state is managed across replicas.

# Caching design questions checklist
cache_checklist = [
    'What data to cache? (read-heavy, expensive to compute, rarely updated)',
    'Cache layer: client-side / CDN / app-level / DB query cache?',
    'Cache invalidation strategy: TTL / event-driven / write-through?',
    'Eviction policy: LRU / LFU?',
    'Cache key design: ensure uniqueness, avoid hotspots',
    'Consistency window: acceptable staleness in seconds?',
    'Cache stampede prevention: mutex / stale-while-revalidate?',
    'Cache capacity: how much RAM needed for hot set?',
]
lb_checklist = [
    'Layer 4 vs Layer 7: routing by IP or by URL/headers?',
    'Algorithm: round robin / least-connections / consistent hashing?',
    'Health checks: interval, failure threshold, recovery',
    'Session stickiness: needed? Use cookie-based affinity or external session store',
    'Auto-scaling: scale out when CPU > 70%; scale in when < 30%',
]
print('Cache checklist:')
for item in cache_checklist: print(f'  [ ] {item}')
print('\nLoad balancer checklist:')
for item in lb_checklist: print(f'  [ ] {item}')

Quick Check

Test your understanding of Data Structures & Algorithms — Coding Interview Prep concepts from this lesson.

Lesson Recap

In this lesson you learned: caching stores hot data in fast memory layers (Redis, CDN) to absorb the majority of reads and reduce database load, cache-aside is the most common pattern — miss means load from DB, hit means return immediately, write means delete from cache, and consistent hashing distributes cache keys across nodes so adding or removing nodes only remaps ~1/n of keys instead of remapping everything. Next up we design a rate limiter and a Twitter feed to apply all system design concepts in end-to-end problems.

Frequently asked questions

Is the “Caching, CDNs, and Load Balancing” lesson free?

Yes — the full text of “Caching, CDNs, and Load Balancing” is free to read here on the web, and the DSA Interview Prep course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the DSA Interview Prep course, upgrade to CoddyKit PRO.

What will I learn in “Caching, CDNs, and Load Balancing”?

Add Redis caching layers, push static assets to a CDN, and distribute traffic across replicas with round-robin and consistent-hashing load balancers. You practise DSA Interview Prep with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start DSA Interview Prep?

No prior experience is required. DSA Interview Prep on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Caching, CDNs, and Load Balancing” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this DSA Interview Prep lesson?

Yes. Every DSA Interview Prep lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. The System Design Interview Framework
  2. Scalable Data Storage: SQL vs NoSQL
  3. Caching, CDNs, and Load Balancing
  4. Design Rate Limiter and Design Twitter Feed
← Back to DSA Interview Prep