# What a CDN actually does

> A CDN is a network of servers near your users that keeps copies of your responses, so most requests are answered a few milliseconds away instead of crossing an ocean to your origin server. You control what it stores and for how long with Cache-Control headers.

- Source: https://techdecoded.dev/blog/what-is-a-cdn
- Published: 2026-08-27
- Category: Networking
- Level: Beginner
- Licence: CC BY 4.0. Quote freely with a link to https://techdecoded.dev/blog/what-is-a-cdn

## Key takeaways

- A CDN's main trick is distance: it answers from a server near your user instead of from your origin, and physics does the rest.
- Cache-Control headers are the steering wheel. If you don't set them, you have handed the decision to someone else's defaults.
- The cache key decides whether two requests share a cached copy. Most CDN bugs are really cache key bugs.
- Fingerprinted filenames beat cache purging. Change the URL, and invalidation becomes a non-problem.
- A CDN does not make a slow origin fast; it makes a slow origin matter less often.

---

> **TL;DR**
> 
> A CDN keeps copies of your site’s responses on servers scattered around the world. When someone visits, the copy nearest them answers, a few milliseconds away instead of a few hundred. You decide what gets copied, and for how long, using HTTP headers.

Someone on your team says “we should put a CDN in front of it” and everyone nods. You nod too. Later you look it up and get a sentence like *“a geographically distributed network of proxy servers”*, which is technically true and explains nothing.

Let’s fix that properly.

## The problem a CDN solves

Your server lives in one place. Let’s say a data centre in Virginia.

Your users do not live in one place. Someone in Sydney wants your homepage. Their request has to physically travel to Virginia and the response has to travel back. That is roughly 16,000 km each way, through undersea fibre, at about two-thirds the speed of light.

And it isn’t one trip. Opening a secure connection takes a handshake: a few round trips before a single byte of your actual page moves. So that distance gets paid three or four times over before your user sees anything.

> **In plain English**
> 
> Ordering a book from a warehouse on the other side of the planet takes a week no matter how efficient the warehouse is. The fix is not a faster warehouse. The fix is a copy of the book in a shop down the road.

Try it. Pick where your visitor is and watch the same request take two different routes:

*\[Interactive on the web page: pick a visitor location and compare round-trip latency to the origin server versus to the nearest CDN edge.\]*

Notice what changes and what doesn’t. The server did the same work in both cases. The only difference is *how far the answer had to travel*.

> **Note**
> 
> The numbers above are estimates for a first, uncached connection. Real latency depends on routing, congestion and how quickly your origin can generate a response. The ratio is the part that holds true.

## So what is a CDN, concretely?

A CDN is a company that already owns servers in a few hundred cities. You point your domain at them. Now, when someone requests your site, they reach the CDN’s nearest server rather than yours.

That nearby server is called an (A CDN server close to the end user, at the “edge” of the network. Hundreds of these exist worldwide. Also called a PoP, short for Point of Presence.). Your own server is now called the (The server that actually generates your content: your app, your API, your S3 bucket. The CDN falls back to it whenever it doesn’t already have what was asked for.).

The edge does one of two things with any request:

-   **It already has a copy** of what was asked for → it replies immediately. This is a **cache HIT**.
-   **It doesn’t** → it fetches from your origin, keeps a copy, and replies. This is a **cache MISS**.

That’s the whole idea. Everything else is detail about *when* it’s allowed to keep a copy, and *for how long*.

## The lifecycle of one request

Here is what actually happens on a first visit, step by step.

**A request, start to finish**

1.  **DNS points at the CDN, not at you**: Your visitor's browser looks up techdecoded.dev and gets back an IP address belonging to the CDN, not your origin server. This is the part that makes everything else possible.
    
    ```
    dig +short techdecoded.dev
    104.21.x.x # a CDN address, not your server
    ```
    
2.  **The request lands at the nearest edge**: The CDN uses anycast routing: the same IP address is announced from every one of its locations, and the internet naturally delivers the packet to the closest one. A visitor in Sydney and one in London hit the same IP and reach different buildings.
3.  **The edge builds a cache key**: Before it can check whether it has a copy, the edge has to decide what 'the same request' means. By default that's the method plus the full URL, but you can widen or narrow it, and that choice matters enormously.
    
    ```
    GET https://techdecoded.dev/logo.svg -> key: GET|techdecoded.dev|/logo.svg
    ```
    
4.  **Cache MISS: nothing stored yet**: First visitor of the day. The edge has never seen this key, so it has nothing to serve. It has to go and ask your origin.
    
    ```
    cf-cache-status: MISS
    ```
    
5.  **The edge fetches from your origin**: This is the slow trip, the one your visitor was going to make anyway. The difference is that it happens once, on the CDN's well-optimised network, instead of once per visitor.
6.  **The origin's headers decide what happens next**: Your response comes back with Cache-Control. This is where you, the developer, actually control the CDN. Say it's cacheable for a day, and the edge stores it. Say nothing useful, and it may store nothing at all.
    
    ```
    Cache-Control: public, max-age=300, s-maxage=86400
    ```
    
7.  **The edge stores a copy and replies**: Your first visitor gets a normal, slightly slow response. They paid the full distance. Someone always does.
8.  **Every later visitor gets the HIT**: The next request for that key from anywhere near that edge is answered from local disk or memory. No origin trip, no ocean crossing. That's the payoff.
    
    ```
    cf-cache-status: HIT
    age: 412 # seconds this copy has been cached
    ```
    

> **Tip**
> 
> The first visitor to each edge always pays for the miss. With hundreds of edges, that means hundreds of misses, one per location, before things are fully warm. This is normal, and it’s why cache hit rates never reach 100%.

**Check yourself:** Your site is behind a CDN. A visitor in Tokyo loads your logo, then thirty seconds later a different visitor in Paris loads the same logo. What does the Paris visitor get?

-   A HIT, because the Tokyo visitor already cached it
    -   Almost, but caches are per-edge, not global. Tokyo's copy lives in Tokyo. Paris has its own separate cache, which is still empty.
-   **(correct answer)** A MISS, because Paris has its own cache and it's cold
    -   Exactly right. Each edge caches independently. The Paris visitor triggers their own fetch from your origin, and populates the Paris cache for everyone who comes after them.
-   It depends on the Cache-Control header
    -   Headers decide whether a copy is stored at all, and for how long, but they can't make Tokyo's stored copy appear in Paris. Caches are per-location.

## The headers that actually control it

This is the part worth memorising, because it is the part you will edit.

> **In plain English**
> 
> Cache-Control is a note you staple to every response, telling anyone who handles it: may you keep a copy, and if so, for how long?

Directive

Who it talks to

What it means

`public`

Everyone

Any cache may store this, including shared ones like a CDN.

`private`

Browser only

The user’s browser may keep it; shared caches must not. Use for anything personalised.

`no-store`

Everyone

Never write this to disk anywhere. For genuinely sensitive responses.

`max-age=600`

Browser

Fresh for 600 seconds. After that, ask again.

`s-maxage=86400`

Shared caches

Same idea, but only for the CDN, and it overrides `max-age` there.

`stale-while-revalidate=60`

Shared caches

Serve the slightly stale copy instantly, and refresh it in the background.

`no-cache`

Everyone

Store it, but check with the origin before reusing it. Confusingly, this does *not* mean “don’t cache”.

The combination you will use most often:

```http
Cache-Control: public, max-age=60, s-maxage=86400, stale-while-revalidate=600
```

Read that out loud: *anyone may cache this; browsers should re-check after a minute; the CDN may hold it for a day; and if it goes stale, serve the old copy immediately while fetching a fresh one.*

> **no-cache does not mean no caching**
> 
> no-cache means “revalidate before use”: the copy is still stored. The directive that means don’t store it is no-store. This trips up nearly everyone once.

## The cache key: where most bugs actually live

The edge needs to answer: *have I seen this exact request before?* The **cache key** is how it decides. By default it’s roughly the method plus the full URL.

That default is fine until it isn’t. Two failure modes, in opposite directions:

**The key is too broad.** Different responses collide under one key. Your API returns different JSON for `Accept-Language: en` and `Accept-Language: fr`, but the URL is identical, so the CDN happily serves French content to English speakers.

The fix is the `Vary` header, which tells the cache “this response also depends on that request header”:

```http
Vary: Accept-Language
```

**The key is too narrow.** Nothing ever gets shared. If your cache key includes a tracking cookie or a `?utm_source=` parameter, then every visitor generates a unique key, every request is a MISS, and your hit rate is approximately zero. You now have all the cost of a CDN and none of the benefit.

> **The classic: Vary: Cookie**
> 
> Setting Vary: Cookie on a page sounds cautious and is usually catastrophic. Every distinct cookie value becomes a separate cache entry, and analytics scripts give almost every visitor a unique cookie. Your hit rate collapses to nothing.

**Check yourself:** Your marketing team starts sending traffic to /pricing?utm\_source=twitter, /pricing?utm\_source=newsletter, and a dozen other variants. Query strings are part of the cache key by default. What happens?

-   Nothing. They all resolve to the same page
    -   They render the same page, but the CDN doesn't know that. It sees different URLs, so it treats them as different cacheable objects.
-   **(correct answer)** Each variant caches separately, so the origin gets hit more than it should
    -   Right. Twelve URLs means twelve cache entries for one page, and twelve separate cold misses per edge. The fix is to configure the CDN to ignore utm\_\* parameters when building the key.
-   The CDN refuses to cache URLs with query strings
    -   Some older CDNs behaved this way, but modern ones cache query strings fine, and that's exactly the problem here. They cache them as separate objects.

## Getting rid of stale content

You’ve told the CDN to hold your CSS for a year. Then you ship a redesign. Now what?

There are two approaches, and one of them is much better.

**Purging.** You call the CDN’s API and say “forget `/styles.css`”. It works, but it’s an action you have to remember to take, it propagates across hundreds of edges with a short delay, and if it fails silently your users see the old site.

**Fingerprinting.** You name the file after its contents:

```
styles.a3f9c2.css      # the old build
styles.7b1e04.css      # the new build, different content, different name
```

The new HTML references the new filename. That’s a URL the CDN has never seen, so it’s a fresh cache key with nothing stale behind it. The old file just sits there until it expires, harming nobody.

> **Tip**
> 
> Every modern build tool (Vite, webpack, Next, Astro) does fingerprinting for you by default. This is why you can safely cache /assets/\* for a year while caching your HTML for sixty seconds. The HTML is the thing that points at the new names, so it’s the only thing that has to stay fresh.

## What a CDN will *not* fix

This is the section that saves you a wasted sprint.

-   **A slow origin.** If your homepage takes 3 seconds to generate, every cache MISS still takes 3 seconds. A CDN reduces how *often* you pay that, not how much it costs.
-   **Content that can’t be shared.** A logged-in dashboard showing someone’s own data must not be cached publicly. Route it through the CDN for the better network path, but don’t cache the HTML.
-   **A slow first byte from your own code.** Database queries, N+1s, cold serverless starts. The CDN never sees these on a HIT, and doesn’t help at all on a MISS.
-   **Bad caching headers.** A CDN in front of an app that sends `Cache-Control: no-store` on everything is an expensive pass-through pipe.

> **The most expensive mistake**
> 
> Caching an authenticated page in a shared cache. If a logged-in user’s HTML gets stored at the edge under a public key, the next visitor to that edge is served someone else’s account page. Mark anything personalised private or no-store, and treat it as a security control, not a performance setting.

## Checking your work

Everything above is visible in response headers. You don’t need a dashboard.

```bash
curl -sSI https://techdecoded.dev/ | grep -iE 'cache|age|cf-|x-cache'
```

What to look for:

-   **`cf-cache-status`** (Cloudflare), **`x-cache`** (CloudFront, Fastly), or **`x-vercel-cache`**, showing `HIT`, `MISS`, `EXPIRED`, `DYNAMIC`, or `BYPASS`.
-   **`age`**: seconds since this copy was cached. A rising number across requests means you’re being served the same stored copy.
-   **`cache-control`**: what your origin actually sent, which is often not what you thought you configured.

### Try it yourself: Catch a CDN in the act

Pick any large site. Most are behind a CDN. Run this twice in a row:

```bash
curl -sSI https://developer.mozilla.org/en-US/ | grep -iE 'cache|age'
```

Then try a URL that almost certainly isn’t cached, by adding a random query string:

```bash
curl -sSI "https://developer.mozilla.org/en-US/?cachebust=$RANDOM" | grep -iE 'cache|age'
```

Compare the two.

**What you should see**

On the first pair of requests you’ll typically see a cache status of `HIT` and an `age` header with a non-zero value, proof you’re being served a stored copy rather than a freshly generated one.

The random query string produces a cache key nothing has ever requested before, so you should see `MISS` (or `EXPIRED`), and `age: 0` or no `age` header at all. You just forced a trip to the origin, and you can usually feel it in the response time.

That single difference, a query parameter nobody thought about, is the same mechanism behind the `utm_source` problem earlier. Now you can see it directly.

## A sensible starting configuration

If you’re setting this up for the first time and want defaults that are hard to get wrong:

```http
# Fingerprinted build assets: the filename changes when content changes
Cache-Control: public, max-age=31536000, immutable

# HTML pages: short at the browser, longer at the edge, refreshed in background
Cache-Control: public, max-age=0, s-maxage=600, stale-while-revalidate=86400

# Anything personalised or authenticated
Cache-Control: private, no-store
```

Three rules cover most sites. Start there, then look at your hit rate and tune.

**Check yourself:** Your API returns a user's own order history at /api/orders. It's slow, and someone suggests caching it at the CDN for 5 minutes to speed it up. What's the right response?

-   Good idea, 5 minutes of staleness is acceptable for order history
    -   The staleness isn't the problem. The problem is that this response is different for every user, and a shared cache keyed on the URL alone would serve one person's orders to another.
-   **(correct answer)** Don't cache it publicly; it's per-user. Fix the slow query instead
    -   Exactly. Personalised responses belong in a private cache at most. A CDN can't fix a slow database query, and trying to make it will leak data between users. Mark it private and go optimise the query.
-   Cache it, but add Vary: Cookie so each user gets their own entry
    -   This is the trap. It technically separates users, but it also creates a unique cache entry per cookie value, giving a near-zero hit rate, so no speed benefit, and you're one cookie-handling bug away from a data leak. Not worth the risk.

## Where to go from here

Once the above is comfortable, the next things worth understanding are **tiered caching** (a middle layer that shields your origin so hundreds of edges don’t all stampede it at once), **edge compute** (running your own code at the edge rather than just serving files), and **cache warming** for planned traffic spikes.

But none of those matter until the basics are right: sensible `Cache-Control` headers, a cache key that isn’t accidentally unique per visitor, and fingerprinted assets. Get those three right and you’ve captured most of the value.

## Quick answers

**Do I need a CDN for a small site?**

If your users are all in one city and your traffic is low, a CDN changes little. If your users are spread across countries, a CDN is usually the single largest performance win available for the least work, often just changing your DNS.

**Does a CDN work for dynamic, logged-in pages?**

Personalised HTML generally should not be cached publicly, or one user sees another's page. But you can still route it through the CDN for a faster network path, and cache the static assets and public API responses around it.

**What is the difference between a CDN cache and a browser cache?**

The browser cache serves one person and lives on their device. The CDN cache is shared: one visitor's request populates it, and every later visitor near that edge benefits. max-age controls the browser, s-maxage controls the shared CDN cache.

**How do I know whether the CDN is actually caching?**

Look at the response headers. Most CDNs send a status header such as cf-cache-status, x-cache or x-vercel-cache with a value of HIT or MISS, plus an Age header counting how many seconds the copy has been cached.

