Skip to content

Cache Headers — A Practical Guide for Developers

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.

You can check headers in two quick ways:

  • 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-revalidate
    age: 1000
curl -I https://example.com

This shows only the headers for the request.

Cache-Control: public, max-age=3600, must-revalidate
Vary: Accept-Encoding, User-Agent
Age: 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.

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.

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.

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= - What it means: How long (in seconds) the content is considered fresh.

  • When to use: Any resource where you want to control the refresh window.
  • ✅ Example: max-age=3600 → cache for 1 hour.

s-maxage= What it means: Same as max-age, but applies only to shared caches (CDNs, proxies).

  • 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= - What it means: Allows serving an “old” version of the file while a fresh copy is fetched in the background.

  • 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.

ScenarioRecommended headerWhy
Static assets (images, fonts, hashed JS/CSS)Cache-Control: public, max-age=31536000, immutableAssets 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-revalidateCache 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-revalidateCache 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-revalidateBrowser can cache, but CDNs/proxies can’t. Always validate with the server.
API responses (short-lived data)Cache-Control: public, max-age=30, must-revalidateCache for 30s to reduce load, but revalidate quickly for fresh data.
Sensitive data (checkout, banking, medical info)Cache-Control: no-storeNever cache this type of data anywhere.
Dynamic data with tolerance for brief stalenessCache-Control: public, max-age=60, stale-while-revalidate=30User gets instant response, while cache refreshes in background.
ScenarioRecommended headerWhy
Static assets (images, fonts, hashed JS/CSS)Cache-Control: public, max-age=31536000, immutableAssets 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-revalidateCache 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-revalidateCache 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-revalidateBrowser can cache, but CDNs/proxies can’t. Always validate with the server.
API responses (short-lived data)Cache-Control: public, max-age=30, must-revalidateCache for 30s to reduce load, but revalidate quickly for fresh data.
Sensitive data (checkout, banking, medical info)Cache-Control: no-storeNever cache this type of data anywhere.
Dynamic data with tolerance for brief stalenessCache-Control: public, max-age=60, stale-while-revalidate=30User gets instant response, while cache refreshes in background.

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 ValueDescription
gzipGzip compression
compressUNIX “compress” compression
deflateDeflate compression (zlib)
brBrotli compression
zstdZstandard compression
dcbDraft Brotli compression (legacy/rare)
dczDraft Zstandard compression (legacy/rare)
identityNo encoding (identity)
*Any encoding supported by the server

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 ValueDescription
text/htmlHTML document
text/plainPlain text
application/jsonJSON data
application/xmlXML data
application/pdfPDF document
image/pngPNG image
image/jpegJPEG image
image/gifGIF 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.

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 GMT

How 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.

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.

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-revalidate

These 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-revalidate
Netlify-CDN-Cache-Control: public, s-maxage=31536000

This 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:

src/middleware.ts
import type { MiddlewareHandler } from 'astro';
export const onRequest: MiddlewareHandler = async (context, next) => {
const res = await next();
};

Here, we can set the headers we want:

src/middleware.ts
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.

netlify.toml
[[headers]]
for = "/*"
[headers.values]
Cache-Control = "public, max-age=3600, must-revalidate"
Custom-Directive = "true"

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-control headers.
    • 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'
}
});

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.

Terminal window
curl -I https://terra-dev.es/en

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, immutable

For 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-revalidate

For 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