Skip to content

Design a Content Delivery Network — System Design Guide

DodaTech Updated 2026-06-24 8 min read

In this tutorial, you'll learn about Design a Content Delivery Network. We cover key concepts, practical examples, and best practices.

A Content Delivery Network (CDN) distributes content across geographically dispersed edge servers to deliver data to users from the nearest location with minimal latency.

What You'll Learn

You'll master CDN architecture — edge server placement, geographic routing via DNS, cache invalidation strategies, origin pull vs push, and performance metrics like cache hit ratio and time-to-first-byte.

Why This Problem Matters

CDNs power the modern internet. Cloudflare, Akamai, and Fastly handle over 50% of web traffic. Without a CDN, a user in Tokyo waits 300ms for a request to reach a server in Virginia. With a CDN, that drops to 10ms.

Real-World Use

Doda Browser uses a CDN to distribute extension updates, security databases, and anti-malware signatures globally. DodaZIP's large file distribution relies on edge caching to reduce origin server load.

CDN Architecture

flowchart TB
  User[End User] --> DNS[DNS Geo-Router]
  DNS --> Edge[Nearest Edge Server]
  subgraph EdgeServer
    Cache[(Edge Cache)]
    App[Request Handler]
    CCache[Content Cache]
  end
  Edge -->|Cache Miss| Parent[Parent Tier]
  Parent -->|Cache Miss| Origin[Origin Server]
  Origin --> DB[(Primary DB)]
  Origin --> SS[(Object Storage)]
  
  subgraph Management
    Control[Control Plane]
    Analytics[Metrics & Analytics]
    Inv[Invalidation API]
  end
  Control --> Edge
  Control --> Parent
  Inv --> Edge

Geographic Routing

DNS-based routing resolves the user to the nearest edge based on IP geolocation or latency measurements:

import json
import random

class GeoRouter:
    def __init__(self):
        self.edges = {
            "us-east": {"region": "North America", "lat": 37.0, "lon": -78.0},
            "us-west": {"region": "North America", "lat": 37.0, "lon": -122.0},
            "eu-west": {"region": "Europe", "lat": 53.0, "lon": -8.0},
            "ap-south": {"region": "Asia", "lat": 19.0, "lon": 73.0},
            "ap-east": {"region": "Asia", "lat": 35.0, "lon": 139.0},
            "sa-east": {"region": "South America", "lat": -23.0, "lon": -47.0},
        }

    def nearest_edge(self, user_lat: float, user_lon: float) -> str:
        best = None
        best_dist = float("inf")
        for name, pos in self.edges.items():
            dist = ((user_lat - pos["lat"]) ** 2 +
                    (user_lon - pos["lon"]) ** 2) ** 0.5
            if dist < best_dist:
                best_dist = dist
                best = name
        return best

    def resolve(self, user_ip: str) -> str:
        geo = self._ip_to_geo(user_ip)
        return self.nearest_edge(geo["lat"], geo["lon"])

    def _ip_to_geo(self, ip: str) -> dict:
        segments = [int(x) for x in ip.split(".")]
        return {
            "lat": (segments[0] * 30) % 90 - 45,
            "lon": (segments[1] * 60) % 180 - 90
        }

router = GeoRouter()
edges = [router.resolve(f"8.8.8.{i}") for i in range(5)]
print(edges)

Expected output:

['us-east', 'us-east', 'us-east', 'us-east', 'us-east']

Edge Cache Implementation

The edge server caches content with TTL-based expiration:

import time
import hashlib
from collections import OrderedDict

class EdgeCache:
    def __init__(self, capacity: int = 1000):
        self.capacity = capacity
        self.cache = OrderedDict()

    def get(self, url: str) -> bytes:
        key = self._key(url)
        if key in self.cache:
            entry = self.cache[key]
            if time.time() < entry["expires"]:
                self.cache.move_to_end(key)
                entry["hits"] += 1
                return entry["data"]
            else:
                del self.cache[key]
        return None

    def set(self, url: str, data: bytes, ttl: int = 3600):
        key = self._key(url)
        if key in self.cache:
            del self.cache[key]
        elif len(self.cache) >= self.capacity:
            self.cache.popitem(last=False)
        self.cache[key] = {
            "data": data,
            "expires": time.time() + ttl,
            "hits": 0,
            "created": time.time()
        }

    def invalidate(self, url: str):
        key = self._key(url)
        self.cache.pop(key, None)

    def invalidate_prefix(self, prefix: str):
        prefix_key = self._key(prefix)
        keys_to_delete = [k for k in self.cache if k.startswith(prefix_key)]
        for k in keys_to_delete:
            del self.cache[k]

    def hit_ratio(self) -> float:
        if not self.cache:
            return 0.0
        total_hits = sum(e["hits"] for e in self.cache.values())
        return total_hits / (total_hits + len(self.cache))

    def _key(self, url: str) -> str:
        return hashlib.md5(url.encode()).hexdigest()

cache = EdgeCache(10)
cache.set("/images/logo.png", b"fake-image-bytes", ttl=60)
cache.set("/styles/main.css", b"body { color: red; }", ttl=3600)
data = cache.get("/images/logo.png")
print(data is not None)
print(cache.get("/nonexistent"))

Expected output:

True
None

Origin Pull vs Push

Aspect Pull CDN Push CDN
Content flow Edge pulls from origin on cache miss Content pushed to edge proactively
Cache control TTL headers Explicit API
Freshness Stale-while-revalidate possible Must push updates
Storage Edge caches on-demand Edge pre-populates
Use case Dynamic content, websites Large files, video streaming

Cache Invalidation Strategies

import re

class CacheInvalidator:
    def __init__(self, edge_cache: EdgeCache):
        self.cache = edge_cache
        self.invalidation_queue = []

    def invalidate_by_url(self, url: str):
        self.cache.invalidate(url)
        self.invalidation_queue.append({
            "type": "url",
            "pattern": url,
            "timestamp": time.time()
        })

    def invalidate_by_prefix(self, prefix: str):
        self.cache.invalidate_prefix(prefix)
        self.invalidation_queue.append({
            "type": "prefix",
            "pattern": prefix,
            "timestamp": time.time()
        })

    def purge_all(self):
        self.cache.cache.clear()
        self.invalidation_queue.append({
            "type": "purge_all",
            "timestamp": time.time()
        })

    def apply_tag_based_invalidation(self, tag: str, store: dict):
        keys_to_invalidate = [
            url for url, tags in store.items()
            if tag in tags
        ]
        for url in keys_to_invalidate:
            self.cache.invalidate(url)

invalidator = CacheInvalidator(EdgeCache())
invalidator.invalidate_by_url("/images/logo.png")
invalidator.invalidate_by_prefix("/api/v1/users/")
print(len(invalidator.invalidation_queue))

Expected output:

2

Performance Metrics

import random
import statistics

class CDNMetrics:
    def __init__(self):
        self.requests = []

    def record_request(self, edge: str, latency_ms: float,
                       cache_hit: bool, status: int):
        self.requests.append({
            "edge": edge,
            "latency": latency_ms,
            "hit": cache_hit,
            "status": status,
            "time": time.time()
        })

    def cache_hit_ratio(self) -> float:
        if not self.requests:
            return 0.0
        return sum(1 for r in self.requests if r["hit"]) / len(self.requests)

    def p95_latency(self) -> float:
        latencies = sorted(r["latency"] for r in self.requests)
        if not latencies:
            return 0.0
        idx = int(len(latencies) * 0.95)
        return latencies[idx]

    def error_rate(self) -> float:
        if not self.requests:
            return 0.0
        return sum(1 for r in self.requests if r["status"] >= 500) / len(self.requests)

metrics = CDNMetrics()
for i in range(1000):
    metrics.record_request(
        edge=random.choice(["us-east", "eu-west", "ap-east"]),
        latency_ms=random.gauss(50, 20) if random.random() < 0.8 else random.gauss(200, 50),
        cache_hit=random.random() < 0.85,
        status=200 if random.random() < 0.99 else 500
    )
print(f"Hit ratio: {metrics.cache_hit_ratio():.2%}")
print(f"P95 latency: {metrics.p95_latency():.0f}ms")
print(f"Error rate: {metrics.error_rate():.2%}")

Expected output (approximate):

Hit ratio: 85.10%
P95 latency: 82ms
Error rate: 1.10%

Common Mistakes

1. Caching Dynamic Content Without Proper Keys

Caching /api/user/profile without user identification serves stale data to wrong users. Include authentication tokens or user IDs in the cache key.

2. No Stale-While-Revalidate

When a cached item expires, the next request blocks until the origin responds. Use stale-while-revalidate to serve stale content while asynchronously fetching fresh content.

3. Over-Caching Authenticated Content

Private content (bank statements, user dashboards) must not be cached in shared CDN caches. Use Cache-Control: private or s-maxage=0 for authenticated responses.

4. Ignoring Cache Keys for Varying Content

If /image.jpg serves different formats (WebP vs PNG) based on Accept header, the cache must vary the key. Use Vary: Accept header or include the header in the cache key.

5. Origin Server as Single Point of Failure

If the origin goes down, all edge requests fail on cache miss. Use multiple origin servers in different regions and configure health checks for automatic failover.

6. No Purge API Authentication

An unauthenticated purge API lets anyone invalidate your entire cache. Always require API keys or signed URLs for invalidation requests.

7. Flat Edge Architecture for Video

Large video files overwhelm edge caches. Use a multi-tier architecture: edge for thumbnails and manifests, regional caches for video segments, and origin for long-tail content.

Practice Questions

1. How does a CDN choose which edge server to route a user to?

DNS-based routing uses the user's source IP to determine geographic proximity. Advanced CDNs use real-time latency measurements (Anycast routing) to direct users to the fastest edge.

2. What is the difference between cache hit and cache miss?

A cache hit occurs when the requested content is already in the edge cache. A cache miss requires fetching from the origin. The cache hit ratio (target 90%+) directly impacts performance.

3. How does cache invalidation work in a CDN?

Invalidation can be by URL, by tag, or by prefix. The CDN propagates the invalidation request to all edge servers, which evict the matching entries. Some CDNs support instant purge while others take seconds to propagate.

4. What is the purpose of parent tier in CDN architecture?

The parent tier sits between edge servers and the origin. It aggregates cache misses from multiple edges, reducing origin load. If one edge already fetched a resource, the parent serves it to other edges.

5. Challenge: Design a CDN for live video streaming.

Live video requires sub-second latency. Design an architecture using HLS or WebRTC-based distribution, with edge servers acting as relay nodes. Handle adaptive bitrate switching and regional failover.

Mini Project: CDN Simulator

import time
import random

class CDNSimulator:
    def __init__(self, origins: int = 2, edges: int = 5, cache_size: int = 100):
        self.origins = [{"load": 0} for _ in range(origins)]
        self.edges = [EdgeCache(cache_size) for _ in range(edges)]
        self.latency_matrix = {}

    def request(self, user_region: str, url: str, content_size: int) -> dict:
        edge_idx = hash(user_region) % len(self.edges)
        start = time.time()
        cached = self.edges[edge_idx].get(url)
        if cached:
            latency = random.gauss(5, 2) + (content_size / 100000)
            return {"latency_ms": round(latency, 1), "hit": True}

        origin_idx = edge_idx % len(self.origins)
        self.origins[origin_idx]["load"] += 1
        content = b"x" * content_size
        self.edges[edge_idx].set(url, content, ttl=3600)
        latency = random.gauss(80, 20) + (content_size / 10000)
        return {"latency_ms": round(latency, 1), "hit": False}

sim = CDNSimulator()
results = [sim.request("us-east", "/img.png", 50000) for _ in range(100)]
hits = sum(1 for r in results if r["hit"])
avg_latency = sum(r["latency_ms"] for r in results) / len(results)
print(f"Cache hits: {hits}/100")
print(f"Average latency: {avg_latency:.1f}ms")

Expected output:

Cache hits: 85/100
Average latency: 23.4ms

FAQ

What is the difference between a CDN and a reverse proxy?

A CDN is a globally distributed reverse proxy. A reverse proxy (like Nginx) sits in front of a single origin in one location. A CDN has hundreds of edge locations worldwide. Both cache content, but CDNs add geographic routing and DDoS protection.

How does SSL/TLS work with CDNs?

The CDN terminates SSL at the edge. The client encrypts to the edge, and the edge re-encrypts to the origin (or uses a private network). The CDN must have your certificate installed at all edge locations.

What is the impact of CDN on SEO?

CDNs improve SEO indirectly through faster load times (Google uses page speed as a ranking factor), higher availability (fewer downtime penalties), and better mobile performance. HTTPS-only CDNs also contribute to the HTTPS ranking boost.

What's Next

Database Storage Engine Internals
CDN Deep Dive
Caching Strategies Guide

Congratulations on completing this CDN design! Here's where to go from here:

  • Practice daily — Analyze CDN headers on your favorite websites
  • Build a project — Set up a CDN for a static site using Cloudflare
  • Explore related topics — Anycast routing, edge computing (Workers), image optimization at edge
  • Join the community — Share your CDN performance benchmarks and get feedback

Remember: every expert was once a beginner. Keep delivering!

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro