Cache Headers — A Practical Guide for Developers
What are cache headers?
Section titled “What are cache headers?”Cache headers are instructions sent by the server with every HTTP response. They tell the browser and the CDN (like Netlify, Cloudflare, etc.) how and for how long they should store a copy of the response instead of asking the server again every time.
👉 Why it matters:
- Fewer unnecessary requests to the server.
- Faster loading times for users.
Where do I see them?
Section titled “Where do I see them?”You can check headers in two quick ways:
In the browser
Section titled “In the browser”- Open DevTools → go to the Network tab.
- Click on any request and look at the Response Headers section.
- Example
cache-control: public, max-age=3600, must-revalidateage: 1000
In the terminal with curl
Section titled “In the terminal with curl”curl -I https://example.comThis shows only the headers for the request.
The most basic example
Section titled “The most basic example”Cache-Control: public, max-age=3600, must-revalidateVary: Accept-Encoding, User-AgentAge: 3600- Cache-Control → defines how caching works.
- Vary → says “keep a separate copy depending on the browser or compression used.”
- Age → shows how long (in seconds) the copy has been in cache. (Added automatically by caches, not by you.)
👉 Translation of that rule: “Keep this response cached for 1 hour. If the browser type or compression method changes, store another copy. Always recheck with the server before serving an outdated file.”
📌 Use case: Good for HTML pages or assets that change occasionally (like CSS or JS without hashing). It keeps performance high while still refreshing relatively often.
Short-lived API response
Section titled “Short-lived API response”Cache-Control: public, max-age=30, must-revalidate- public → anyone can cache it (browser + CDN).
- max-age=30 → keep it only for 30 seconds.
- must-revalidate → after 30s, confirm with the server before serving.
👉 Translation of this rule: “This API response can be cached, but only for 30 seconds. After that, always check with the server for fresh data.”
📌 Use case: Perfect for API routes or dynamic data (like stock prices, live scores, or recent blog comments) where freshness matters more than speed.
Why talk about Cache-Control directives?
Section titled “Why talk about Cache-Control directives?”Up to now, we’ve seen some basic examples of cache headers and how they look in the response. But the heart of caching is the Cache-Control header.
This header isn’t a single setting — it’s made up of directives (rules) you combine to control how caching works.
👉 The examples we saw earlier are really just different combinations of these directives. If you understand what each directive does, you’ll know how to build the right rule for your case — whether it’s a static image, an API response, or a sensitive page like a checkout.
That’s why this section explains the most common Cache-Control directives, with plain descriptions and real-world use cases.
The main Cache-Control directives
Section titled “The main Cache-Control directives”public - What it means: The response can be cached by any cache — browser, CDN, proxy.
- When to use: Content is the same for everyone.
- ✅ Example: images, CSS, JS bundles.
private - What it means: The response is meant for a single user. It can be cached only in the browser, not in shared caches like CDNs.
- When to use: User-specific or sensitive data.
- ✅ Example: a user’s profile page.
max-age=
- When to use: Any resource where you want to control the refresh window.
- ✅ Example: max-age=3600 → cache for 1 hour.
s-maxage=
- When to use: When you want the CDN to cache longer than the browser.
- ✅ Example: browser refreshes every 10 min, CDN holds for 1 day.
no-cache - What it means: The response can be cached, but it must be revalidated with the server before being used.
- When to use: Data that changes frequently, but where bandwidth savings still help.
- ✅ Example: product listings, stock prices.
no-store - What it means: Do not store the response in any cache, ever.
- When to use: Highly sensitive data.
- ✅ Example: checkout pages, banking info.
must-revalidate - What it means: Once content is expired, the cache must check with the server before serving it.
- When to use: Cases where strict consistency is required.
- ✅ Example: legal documents, compliance data.
stale-while-revalidate=
- When to use: You want speed but also up-to-date content.
- ✅ Example: news feeds, dashboards.
✅ With just these directives, you can cover 90% of real-world caching needs.
Common Caching Scenarios
Section titled “Common Caching Scenarios”| Scenario | Recommended header | Why |
|---|---|---|
| Static assets (images, fonts, hashed JS/CSS) | Cache-Control: public, max-age=31536000, immutable | Assets don’t change often, and if they do, the filename hash changes → safe to cache for 1 year. |
| Static assets (no hash in filename) | Cache-Control: public, max-age=3600, must-revalidate | Cache for 1 hour, but force validation after expiration. Useful for CSS/JS without versioning. |
| HTML pages (public, not logged-in) | Cache-Control: public, max-age=3600, must-revalidate | Cache for 1 hour, but always revalidate once expired. Keeps site fast while allowing updates. |
| User-specific pages (logged-in) | Cache-Control: private, no-cache, must-revalidate | Browser can cache, but CDNs/proxies can’t. Always validate with the server. |
| API responses (short-lived data) | Cache-Control: public, max-age=30, must-revalidate | Cache for 30s to reduce load, but revalidate quickly for fresh data. |
| Sensitive data (checkout, banking, medical info) | Cache-Control: no-store | Never cache this type of data anywhere. |
| Dynamic data with tolerance for brief staleness | Cache-Control: public, max-age=60, stale-while-revalidate=30 | User gets instant response, while cache refreshes in background. |
How Cache Freshness Works
Section titled “How Cache Freshness Works”| Scenario | Recommended header | Why |
|---|---|---|
| Static assets (images, fonts, hashed JS/CSS) | Cache-Control: public, max-age=31536000, immutable | Assets don’t change often, and if they do, the filename hash changes → safe to cache for 1 year. |
| Static assets (no hash in filename) | Cache-Control: public, max-age=3600, must-revalidate | Cache for 1 hour, but force validation after expiration. Useful for CSS/JS without versioning. |
| HTML pages (public, not logged-in) | Cache-Control: public, max-age=3600, must-revalidate | Cache for 1 hour, but always revalidate once expired. Keeps site fast while allowing updates. |
| User-specific pages (logged-in) | Cache-Control: private, no-cache, must-revalidate | Browser can cache, but CDNs/proxies can’t. Always validate with the server. |
| API responses (short-lived data) | Cache-Control: public, max-age=30, must-revalidate | Cache for 30s to reduce load, but revalidate quickly for fresh data. |
| Sensitive data (checkout, banking, medical info) | Cache-Control: no-store | Never cache this type of data anywhere. |
| Dynamic data with tolerance for brief staleness | Cache-Control: public, max-age=60, stale-while-revalidate=30 | User gets instant response, while cache refreshes in background. |
Accept-Encoding
Section titled “Accept-Encoding”So far, we’ve focused on when and how long content is cached (Cache-Control). But caching isn’t the only way to improve performance — another huge factor is how the content is delivered.
The Accept-Encoding header is part of the request/response negotiation between the browser and the server.
- The browser says: “Here are the compression methods I understand.”
- The server replies: “Great, I’ll send the response using one of those methods.”
👉 Why it matters:
- Compressed responses = smaller files → faster downloads.
- Smaller files = fewer bytes to cache and serve from CDNs.
In short: caching + compression work hand in hand. Cache headers decide how long something is reused, while Accept-Encoding decides how small the payload can be.
| Accept-Encoding Value | Description |
|---|---|
| gzip | Gzip compression |
| compress | UNIX “compress” compression |
| deflate | Deflate compression (zlib) |
| br | Brotli compression |
| zstd | Zstandard compression |
| dcb | Draft Brotli compression (legacy/rare) |
| dcz | Draft Zstandard compression (legacy/rare) |
| identity | No encoding (identity) |
| * | Any encoding supported by the server |
Content-type
Section titled “Content-type”The Content-Type header tells the browser what kind of content is being returned by the server. This way, the browser (or any client) knows how to interpret the response.
- If it’s HTML → render it as a webpage.
- If it’s JSON → treat it as data for JavaScript.
- If it’s a PDF → open the PDF viewer.
Without the correct Content-Type, the browser might:
Misinterpret the file (e.g. try to download JSON instead of using it in JavaScript).
Block the response entirely for security reasons (many proxies/firewalls won’t allow unknown or mismatched content).
Cause bugs (for example, if your API sends JSON but the header says text/html, the frontend might fail to parse it).
| Content-Type Value | Description |
|---|---|
| text/html | HTML document |
| text/plain | Plain text |
| application/json | JSON data |
| application/xml | XML data |
| application/pdf | PDF document |
| image/png | PNG image |
| image/jpeg | JPEG image |
| image/gif | GIF image |
This Content-type header is used to tell the browser what kind of content is being sent down so that it can handle it properly. For example, if we tell the browser that the content is a PDF, the browser will try to use the PDF reader to display it.
This Content-Type directive is also used by Proxies and Firewalls to determine if the content is allowed to be transferred for security reasons.
The ETag (Entity Tag) header is a unique identifier (like a fingerprint) for a specific version of a resource. Usually it’s a hash that changes whenever the file changes.
ETag: "686897696a7c876b7e"How it works:
- 1 - The server sends an ETag with the resource.
- 2 - Next time, the browser includes the ETag in the request.
- 3 - The server compares:
- If the ETag matches → resource hasn’t changed → return 304 Not Modified.
- If it doesn’t match → resource changed → return new content with a new ETag.
✅ Why it matters:
- Saves bandwidth → no need to resend the whole file if it’s unchanged.
- Extremely precise → even a single-byte change will trigger a new ETag.
Last-Modified
Section titled “Last-Modified”The Last-Modified header tells the browser the date and time when the resource was last updated.
Last-Modified: Wed, 24 Sep 2025 09:19:01 GMTHow it works:
- The server sends the Last-Modified timestamp.
- Next time, the browser asks: “Has this file changed since that date?”
- The server replies:
- If unchanged → 304 Not Modified.
- If changed → 200 OK with the new file and updated timestamp.
✅ Why it matters:
- Simpler than ETag, widely supported.
- Less precise (can miss multiple quick updates within the same second).
They allow the browser or CDN to ask the server: - 👉 “Has this file changed? If not, I’ll keep using my cached copy.”
If the server confirms nothing has changed, it returns: 304 Not Modified
This tells the browser: “Use what you already have, no need to download it again.” -> Result: less bandwidth, faster responses.
Setting Cache inside our current workflow
Section titled “Setting Cache inside our current workflow”In our stack we keep caching simple, predictable, and reviewable. We do that by:
- Defining one source of truth for headers per platform (so no conflicting rules).
- Splitting caching into two layers we can reason about:
- Browser cache — controlled by Cache-Control.
- CDN cache — controlled by s-maxage or a platform-specific header.
- Using file hashing for static assets (safe long-term cache + immutable).
- Keeping HTML/API caches short and revalidated.
- Always adding a “how to verify” step (DevTools + curl -I).
This lets juniors follow a consistent pattern and seniors tune edge cases without surprises.
Netlify + Astro
Section titled “Netlify + Astro”The Netlify-CDN-Cache-Control header allows us to set caching directives specifically for Netlify’s CDN, independent of other caches. This header takes precedence over the standard Cache-Control and CDN-Cache-Control headers when present.
Netlify-CDN-Cache-Control: public, s-maxage=31536000, must-revalidateThese headers will be ignored by the browser, but it will tell Netlify’s CDN how you would like to cache the response in the CDN, not in the browser.
Example:
Cache-Control: public, max-age=0, must-revalidateNetlify-CDN-Cache-Control: public, s-maxage=31536000This will make the browser to not cache the response and to always get the most up-to-date version. In this case, the Netlify CDN cache control directive will make it so that the CDN will serve its own cached version.
But where do I add this code in our projects ?
Section titled “But where do I add this code in our projects ?”There are multiple ways of implementing response headers in our projects.
For Astro projects, we create a src/middleware.ts file. This code will run at build time for static pages and on demand for dynamic pages (SSR).
The basic structure of the file is:
import type { MiddlewareHandler } from 'astro';
export const onRequest: MiddlewareHandler = async (context, next) => { const res = await next();};Here, we can set the headers we want:
import type { MiddlewareHandler } from 'astro';
export const onRequest: MiddlewareHandler = async (context, next) => { const res = await next();
res.headers.set('Cache-Control', 'public, max-age=3600, must-revalidate'); res.headers.set('Custom-Directive', 'true');
return res;};Netlify project (Independent of the framework)
Section titled “Netlify project (Independent of the framework)”For a project deployed on Netlify, we can create a netlify.toml file. This file will be used by Netlify automatically.
[[headers]] for = "/*" [headers.values] Cache-Control = "public, max-age=3600, must-revalidate" Custom-Directive = "true"WordPress (WP Engine)
Section titled “WordPress (WP Engine)”We host our WordPress projects on WP Engine, which includes built-in caching.
The most important feature is Edge Full Page Cache:
-
How it works
- Edge Full Page Cache is enabled from the Cache page in the WP Engine User Portal (Primary domain only).
- It can also be turned on from the Domains page menu.
- By default, posts and pages are cached for 10 minutes, controlled by
cache-controlheaders. - These defaults can be adjusted in:
- The WP Engine mu-plugin
- The Web Rules Engine
-
How to purge cache
- Clear Network Caches → from the User Portal Cache page (purges Edge Full Page Cache).
- Clear All Caches → from the User Portal or the WordPress admin (also clears Edge Full Page Cache).
👉 In short: WP Engine manages caching for us, but we can override the defaults with headers or purge manually when needed.
If you are using Swup (transition library) for navigation, we most probably need to set the headers in the fetchHeaders option when we initialize the Swup instance
const swup = new Swup({ fetchHeaders: { 'Cache-Control': 'public, max-age=3600, must-revalidate', 'Custom-Directive': 'true' }});Testing
Section titled “Testing”We can check the headers of a specific request from the browser’s developer tools. We go to the Network tab, select the request from the list, and then we can see the response headers in the Response Headers section.
We can also check it using the curl command. This will print the response headers in the terminal.
curl -I https://terra-dev.es/enRecommendations
Section titled “Recommendations”Here are some recommendations for cache headers:
For static assets, such as images, fonts, and videos, we can use the immutable directive to cache the asset for a long time.
Normally these assets will contain a hash in the filename, so if the file changes, the hash will change and the browser will download the new version.
Cache-Control: public, max-age=31536000, immutableFor dynamic routes, we can use the max-age directive to tell the browser and the CDN to cache the asset for a short time.
Cache-Control: public, max-age=3600, must-revalidateFor API routes, it is recommended to set a very short time, so that the data is as up-to-date as possible.
Cache-Control: public, max-age=30, must-revalidate