# HTTP caching and CDNs for web apps: a practical guide

Last updated: 2026-11-10

Give files with a content hash in their name a one-year lifetime and mark them `immutable`. Send HTML with `no-cache` plus an `ETag`, so browsers check for a new version on every visit, and let a CDN or your host's shared cache keep public pages for a short time with `s-maxage` or `CDN-Cache-Control`. Anything tied to a signed-in user gets `private` or `no-store` and never reaches a shared cache.

## Cache headers at a glance

| What you serve | Header to send | Browser | CDN or shared cache |
|---|---|---|---|
| Hashed JS, CSS, fonts, images | `Cache-Control: public, max-age=31536000, immutable` | Keeps it a year, no recheck | Keeps it a year |
| HTML pages that are the same for everyone | `Cache-Control: no-cache` with `CDN-Cache-Control: max-age=60` | Rechecks on every load | Serves its copy for 60 seconds |
| Public API responses | `Cache-Control: no-cache` with `CDN-Cache-Control: max-age=30, stale-while-revalidate=60` | Rechecks on every load | 30 seconds fresh, then up to 60 seconds stale while it refetches |
| Pages or APIs for a signed-in user | `Cache-Control: private, no-cache` | May keep it, rechecks first | Never stores it |
| Secrets, tokens, one-time pages | `Cache-Control: no-store` | Never stores it | Never stores it |
| Files whose URL never changes, such as `favicon.ico` | `Cache-Control: max-age=86400` with an `ETag` | One day, then a cheap recheck | Depends on the CDN's defaults |

The lifetimes are examples. Pick values that match how often the content really changes.

## Two caches, two jobs

HTTP has two kinds of cache. A **private cache** is the visitor's browser: it holds copies for one person. A **shared cache** sits between your app and many visitors, and it can be a CDN, a reverse proxy or the response cache your host runs. RFC 9111, the HTTP caching standard, lets you speak to them separately:

- `max-age` sets how long a copy stays fresh in any cache.
- `s-maxage` overrides `max-age` for shared caches only. Browsers ignore it.
- `private` tells shared caches not to store the response at all.
- `no-cache` lets a cache store the response but forces it to ask your server before reusing it.
- `no-store` forbids storing it anywhere.

RFC 9213 adds `CDN-Cache-Control`, a separate header that only CDNs read. It's the cleanest way to say "browsers, recheck every time; edge, keep this for a minute" without the two rules fighting inside one header.

## What to check before you turn caching on

- **Does the response change per user?** If it reads a session cookie or an `Authorization` header, a shared cache must not keep it. One mistake here serves one person's account page to the next visitor.
- **Can the URL change when the content changes?** Build tools put a hash in asset file names. Those URLs can live in caches for a year, because a new build produces a new URL.
- **How stale is acceptable?** A blog post can be a minute old. A stock count or a checkout total usually can't.
- **Does the response vary by a request header?** Language, device type or encoding change the body, so the cache needs a `Vary` header naming that request header, or every visitor gets the first one's version.
- **How will you clear it?** Know whether your CDN or host purges by URL, by tag or everything at once, and whether a new deployment starts from an empty cache.
- **What does your CDN do by default?** Cloudflare's docs say it picks what to keep by file extension, and HTML and JSON aren't on its default list.

## Static assets: cache them for a year

Frameworks such as Next.js, Vite, Astro and SvelteKit emit files like `app.3f9c2e.js`. The hash ties the URL to the exact bytes, so the copy can never be wrong. Send `max-age=31536000` and add `immutable`, defined in RFC 8246, so browsers that support it don't recheck the file while it's fresh. If you deploy a fix, the HTML points to a new file name and the old copy simply stops being requested.

Files without a hash, such as `robots.txt`, `manifest.json` or images referenced from a CMS, need a shorter lifetime and an `ETag` or `Last-Modified` header. When the copy expires, the browser sends the tag back and your server answers `304 Not Modified` with no body if nothing changed.

## HTML pages: recheck in the browser, cache at the edge

HTML can't carry a hash in its URL, so browsers should revalidate it. `Cache-Control: no-cache` with an `ETag` makes that check cheap. The heavy lifting moves to the shared cache: if a page looks the same for every visitor, let the edge keep it for 30 to 300 seconds with `s-maxage` or `CDN-Cache-Control`. Your app renders that page once per lifetime per cache location instead of once per visitor.

Add `stale-while-revalidate` from RFC 5861 so a cache can hand out the expired copy while it fetches a fresh one in the background. Visitors don't wait for the render, and your app sees a background refresh instead of a queue of blocked visitors. `stale-if-error` does the same when your app is failing, which buys you time during an incident.

## APIs and signed-in pages: be strict

Personal data and shared caches don't mix. Mark anything built from a session as `private`, or `no-store` if it holds tokens or payment details. Under RFC 9111, a shared cache can't reuse a response to a request with an `Authorization` header unless the response explicitly allows it. Cloudflare's docs list a `Set-Cookie` header, `private`, `no-store`, `no-cache` and `max-age=0` as reasons it won't cache. Don't rely on those defaults alone: send the header that says what you mean.

Public, read-heavy endpoints are a different story. A product list or a price table that's the same for everyone can sit in a shared cache for a few seconds and absorb most of the read load.

## Purging and deployments

Long lifetimes are only safe if you can take content back. Three tools cover most cases:

1. **New URLs** for assets, via the build hash. Nothing to purge.
2. **Tag purges** for pages. You label responses, for example `Cache-Tag: products`, and clear every page with that tag when a product changes.
3. **Path or full purges** for the rare case where you can't tell what changed.

Check how your platform treats a new deployment. Some keep cached pages from the previous version until they expire, which can show an old layout next to new assets. Others start each deployment with an empty cache.

## Debugging a cache

Look at the response headers, not the page. RFC 9211 defines `Cache-Status`, which says which cache answered and whether it was a hit or a forward to your app. Many CDNs also send their own headers with similar information, and an `Age` header tells you how many seconds a copy has been sitting in a cache. Run `curl -sI https://your-domain/` twice and compare.

## Where Eurobase fits

Eurobase hosts the app itself on European infrastructure run by Scaleway, a French provider. For apps on V8 isolates it keeps a response cache in front of your code: a GET response is stored when it sends `s-maxage` with `public`, or `CDN-Cache-Control`, and plain `max-age` stays a browser-only rule. Requests with an `Authorization` header or a cookie your app set skip the cache, and so do responses that set a cookie. Next.js responses are stored only when your app sends `CDN-Cache-Control` itself, so its middleware still runs on every other request. A cached answer doesn't run your code but still counts as a request, every request that can be cached gets a `Cache-Status` header, and you can purge by tag, by path or everything from the CLI, the API or a coding agent. A new deployment or rollback starts with an empty cache. Details are in [the cache docs](/docs/cache).

It isn't a worldwide CDN. Each server that runs your app keeps its own copies, all in Europe. For an audience based mainly in Europe, that can be all the caching you need. If they're spread across continents, put one of the [European CDN providers](/i/cdn-europe) in front, with the same headers. For hosting static builds, see [static site hosting in Europe](/i/static-site-hosting-europe), and for framework-specific behaviour, [Next.js hosting in Europe](/i/nextjs-hosting-europe).

## Frequently asked questions

### What's the difference between no-cache and no-store?

`no-cache` lets a browser or CDN store the response but makes it check with your server before each reuse, which is cheap with an `ETag`. `no-store` forbids storing it at all. Use `no-cache` for HTML and `no-store` for responses that hold secrets.

### Should I cache HTML on a CDN?

Yes, when the page is the same for every visitor. Keep the browser rule at `no-cache`, give the CDN a short lifetime with `s-maxage` or `CDN-Cache-Control`, and purge or redeploy when content changes. Don't cache HTML that shows a signed-in user's name or data.

### How long should max-age be for JavaScript and CSS?

A year, `max-age=31536000`, with `immutable`, as long as the file name contains a content hash. Without a hash, use a short lifetime and an `ETag` instead, or visitors may keep an old file after a deploy.

### Why is my CDN not caching my API responses?

Common causes: the response sets a cookie, it sends `private`, `no-cache` or `max-age=0`, the request isn't a GET, or the CDN only caches certain file extensions by default. Check the response headers and the CDN's own cache rules.

### Do I need a CDN if I use HTTP caching correctly?

Not always. Browser caching saves repeat visits, and a shared cache on your host absorbs repeated page views. A CDN adds copies close to visitors on other continents and protection at the edge, which matters most for a worldwide audience or large files.

## Sources

- [RFC 9111: HTTP Caching](https://www.rfc-editor.org/rfc/rfc9111)
- [RFC 9213: Targeted HTTP Cache Control](https://www.rfc-editor.org/rfc/rfc9213)
- [RFC 9211: The Cache-Status HTTP Response Header Field](https://www.rfc-editor.org/rfc/rfc9211)
- [RFC 5861: HTTP Cache-Control Extensions for Stale Content](https://www.rfc-editor.org/rfc/rfc5861)
- [RFC 8246: HTTP Immutable Responses](https://www.rfc-editor.org/rfc/rfc8246)
- [MDN: HTTP caching](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching)
- [web.dev: Prevent unnecessary network requests with the HTTP Cache](https://web.dev/articles/http-cache)
- [Cloudflare: Default cache behavior](https://developers.cloudflare.com/cache/concepts/default-cache-behavior/) and [CDN-Cache-Control](https://developers.cloudflare.com/cache/concepts/cdn-cache-control/)
- [Eurobase cache docs](/docs/cache)
