
Hosting · · 9 min read
HTTP caching and CDNs for web apps: a practical guide
Which Cache-Control headers to send for hashed assets, HTML pages and APIs, how a CDN decides what to keep, and how to purge without serving stale or private pages.
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-agesets how long a copy stays fresh in any cache.s-maxageoverridesmax-agefor shared caches only. Browsers ignore it.privatetells shared caches not to store the response at all.no-cachelets a cache store the response but forces it to ask your server before reusing it.no-storeforbids 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
Authorizationheader, 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
Varyheader 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:
- New URLs for assets, via the build hash. Nothing to purge.
- Tag purges for pages. You label responses, for example
Cache-Tag: products, and clear every page with that tag when a product changes. - 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.
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 in front, with the same headers. For hosting static builds, see static site hosting in Europe, and for framework-specific behaviour, Next.js hosting in 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
- RFC 9213: Targeted HTTP Cache Control
- RFC 9211: The Cache-Status HTTP Response Header Field
- RFC 5861: HTTP Cache-Control Extensions for Stale Content
- RFC 8246: HTTP Immutable Responses
- MDN: HTTP caching
- web.dev: Prevent unnecessary network requests with the HTTP Cache
- Cloudflare: Default cache behavior and CDN-Cache-Control
- Eurobase cache docs
Keep reading
Where to host a Hugo site in Europe in 2026Which European hosts run the hugo build for you, which ones only serve the files it writes to public, and what each one costs for a blog or docs site.Frameworks10 min read
IONOS vs Hetzner in 2026Two German hosts compared on entry VPS prices, traffic, data centre cities, managed Kubernetes and databases, certifications, and what to use if you only want to ship an app.Comparisons9 min read
