
Netlify Caching Headers: _headers File and netlify.toml Configuration
Caching is one of the biggest performance wins on the web, and also one of the easiest things to mess up. Cache too aggressively and users see stale content. Cache too little and you're wasting your CDN. I've managed to do both on the same project.
Netlify gives you fine-grained control over HTTP headers, so you can tune caching behavior for different file types. Here's how to set it up without shooting yourself in the foot.
How Netlify caches things by default
Netlify's CDN sits in front of your site. Out of the box:
- Static assets (images, JS, CSS) get cached aggressively
- HTML gets cached conservatively so visitors don't see stale pages
- Standard HTTP cache headers are respected
For many sites, the defaults are fine. But if you want more control — say, immutable caching for hashed assets or short TTLs for API responses — you'll need to set headers explicitly.
Two ways to set headers
You can use either a _headers file or netlify.toml. They do the same thing.
The _headers file
Drop it in your publish directory (wherever Netlify serves from — usually public/, dist/, or build/).
/assets/*
Cache-Control: public, max-age=31536000, immutable
netlify.toml
If you prefer keeping config in one place:
[[headers]]
for = "/assets/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
I tend to use netlify.toml for larger projects because everything's in one file. For a quick static site, _headers is less ceremony.
Caching strategies that actually work
HTML pages: don't cache aggressively
HTML changes often. You want browsers to check for updates every time:
/*
Cache-Control: public, max-age=0, must-revalidate
This tells the browser "always revalidate with the server." Netlify's CDN is fast enough that this doesn't hurt performance — the CDN still caches the page, it just checks if it's changed.
Hashed assets: cache forever
If your bundler produces files like app.83hd73.js (with a content hash in the filename), you can cache them aggressively:
/assets/*
Cache-Control: public, max-age=31536000, immutable
One year, immutable. When the content changes, the hash changes, so the browser fetches the new file. This is the ideal caching setup — zero unnecessary requests for assets that haven't changed.
Important: this only works if your filenames include hashes. If you're serving app.js without a hash, don't set immutable or users will be stuck with stale code.
API responses: short TTL
If you're serving JSON from Netlify Functions:
/api/*
Cache-Control: public, max-age=60
One minute. Keeps things reasonably fresh while avoiding hammering your functions on every request.
Security headers
While you're setting up headers, you might as well add the security basics:
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
These prevent clickjacking, MIME type sniffing, and leaking referrer information. They cost nothing and take 30 seconds to add.
When your headers aren't working
If your headers aren't showing up, check these in order:
- Open browser dev tools — Network tab, click a request, look at response headers
- Is
_headersin the right folder? — It has to be in the published directory, not the project root. This is the most common mistake. - Do your path patterns match? —
/assets/*won't match/static/assets/app.js - Did you redeploy? — Netlify applies headers at deploy time, not dynamically
- Check rule ordering — more specific rules need to come before generic ones, just like redirects
I've spent way too long debugging a caching issue that turned out to be _headers sitting in the project root instead of dist/.

Developer Advocate at RevenueCat and creator of Netli.fyi. Building on Netlify since 2019. Writes from hands-on experience deploying dozens of production sites.
Manage Netlify on the go
Download Netli.fyi and monitor your sites, check deploys, and manage your projects from anywhere.


