Flutter Web Caching Strategies: HTTP Headers, Hashed Builds, and Safe Deploys
Cache-Control rules for a Flutter web deployment so repeat visits load instantly and a new release never breaks clients still running the old bundle.
Published on • November 11, 2026
AI Assistant

A Flutter web build is a pile of static files, so caching is entirely your server’s job — and getting it wrong is expensive in both directions: too little cache and every visit re-downloads the bundle, too much and users run yesterday’s main.dart.js against today’s API. This post lays out the HTTP rules, the headers Flutter’s own build gives you, and a deploy shape that keeps open tabs alive.
The four caching rules that matter
Everything below follows from the HTTP caching model. A private cache lives in the browser; a shared cache (CDN, proxy) sits between browser and origin, so personalized responses must say private or they leak across users. A response is fresh while its Cache-Control: max-age (seconds from generation) has not elapsed; once stale, the cache asks the origin with a conditional request — If-None-Match against the ETag — and gets either a full 200 or a bodyless 304 Not Modified. Two directives are constantly confused: no-cache means “store it, but revalidate before every reuse”, while no-store means “do not store it at all” (and costs you the back/forward cache, so use it sparingly). With no Cache-Control at all you get heuristic caching — the browser guesses a freshness lifetime from Last-Modified, which is exactly the “stale for a day” mystery you are trying to avoid.
What flutter build web gives you
Flutter 3.50+ added --web-content-hash, which embeds an 8-character truncated SHA-256 hash into compiled entrypoints and everything under build/web/assets/**, records the hashed paths in flutter_bootstrap.js, and — the part you care about — tells you the exact headers to serve:
flutter build web --wasm --web-content-hash # requires Flutter 3.50+
Hashed files get Cache-Control: public, max-age=31536000, immutable (the main.dart.<hash>.js/.wasm entrypoints, AssetManifest.<hash>.bin, fonts, images, shaders). Unhashed bootstrap files — index.html, flutter_bootstrap.js, flutter.js, manifest.json, version.json, 404.html, icons/** — get Cache-Control: max-age=0, must-revalidate (or no-cache). The hash in the filename is the cache-busting: change the bytes and the URL changes, so a year-long max-age is safe.
immutable is what makes that year safe: the browser will not send a conditional request at all while the freshness lifetime holds, so a repeat visit to a hashed file is a pure local read with no round trip.
One documented gap: --web-content-hash does not yet support deferred imports in either dart2js or dart2wasm builds, so chunks created with deferred as need their headers planned by hand.
Cache-Control per URL
| URL pattern | Cache-Control | Why |
|---|---|---|
/index.html | no-cache (+ ETag) | Main document; must always revalidate so new hashes are discovered |
/flutter_bootstrap.js, /flutter.js | no-cache | Bootstraps the build config; stale copies pin old releases |
/main.dart.<hash>.js, /main.dart.<hash>.wasm | public, max-age=31536000, immutable | Hash in URL; content can never change under that name |
/assets/** (fonts, images, AssetManifest.<hash>.bin) | public, max-age=31536000, immutable | Same: hashed, fetched lazily by long-lived tabs |
/canvaskit/** (when bundled) | public, max-age=604800 | Versioned with the release but not always URL-hashed |
/manifest.json, /version.json, /icons/** | max-age=0, must-revalidate | Unhashed, must reflect the current release |
/api/** (if you serve any) | private, no-store or no-cache, private | Personalized; never let a shared cache hold it |
Pair every row with a strong ETag (a file hash is easy to compute at deploy time) so that a no-cache document round-trips as a 304 instead of a full body. For assets you cannot hash — a favicon, a renamed logo — stale-while-revalidate lets the cache serve the old copy for a few seconds while it fetches a fresh one in the background.
A minimal nginx configuration for that table:
location ~ ^/main\.dart\.[0-9a-f]+\.(js|wasm)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location = /index.html {
add_header Cache-Control "no-cache";
etag on;
}
The same policy on Firebase Hosting is a headers array in firebase.json — same rules, different syntax:
{
"hosting": {
"headers": [
{ "source": "/main.dart*", "headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] },
{ "source": "/assets/**", "headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] },
{ "source": "/index.html", "headers": [
{ "key": "Cache-Control", "value": "no-cache" }] }
]
}
}
Whatever your host, set the policy explicitly rather than accepting its defaults; most static hosts apply a global TTL that is wrong for one class of file or the other.
Precache with a service worker
--web-content-hash also writes build/web/precache_manifest.json — a bill of materials with url, hash, size, and a urlHashed flag for every file. Feed it to your own service worker (Flutter no longer manages a caching worker itself; its generated file only cleans up leftovers from older versions):
// build-sw.mjs merges the Flutter manifest into a Workbox precache
import { readFileSync } from 'node:fs';
const { entries } = JSON.parse(
readFileSync('./build/web/precache_manifest.json', 'utf8'),
);
precacheAndRoute(
entries.map((e) => ({
url: '/' + e.url,
// hashed URLs must not be re-cached under a ?revision= query,
// or you double-store the same bytes
revision: e.urlHashed ? null : e.hash,
})),
);
Precaching makes repeat visits and offline starts instant, but keep index.html out of the precache or users will never see a new release — network-first (or no-cache) for the document, precache-first for hashed subresources.
Version the worker with the build too: a service worker serving last week’s precache while index.html points at this week’s hashes is a self-inflicted outage, so delete old caches in the worker’s activate handler.
Safe deploys and caching footguns
A tab running release N-1 will lazily request main.dart.<old-hash>.js after you ship release N. The fix is an overlay deploy: keep the previous release’s hashed main.dart.* and assets/** files alongside the new ones for a 24–72 hour grace window, update index.html and flutter_bootstrap.js last and atomically, and exclude /main.dart.*, /assets/**, /canvaskit/** from the SPA catch-all rewrite so a genuinely missing chunk returns a real 404 instead of 200 text/html (which crashes the loader with a syntax error). Keep *.map out of the public tree while you are at it — upload source maps to your error monitor during CI, not to the CDN.
Vary splits the cache key by request header — Vary: Accept-Encoding is correct, Vary: User-Agent fragments the cache into thousands of near-identical entries and mostly defeats it. Remember that no-cache does not guarantee revalidation on Back/Forward navigations: bfcache restores a full page snapshot without touching the network, which is a feature, just not one your header controls. And avoid the kitchen-sink header (no-store, no-cache, max-age=0, must-revalidate); MDN explicitly recommends plain no-cache plus private for personalized documents.
Verify the headers after every deploy rather than trusting platform defaults:
curl -sI https://app.example.com/index.html | grep -i cache-control
curl -sI https://app.example.com/main.dart.f215ce97.js | grep -i cache-control
curl -sI https://app.example.com/assets/FontManifest.9c2f1a4b.bin | grep -i cache-control
If you sit behind a CDN, confirm it honors the origin’s Cache-Control instead of imposing its own default TTL — a CDN that holds index.html for an hour undoes everything above, and most let you override it per path.
Deploy checklist:
- Built with
flutter build web --wasm --web-content-hashon Flutter 3.50 or later - Previous release’s hashed
main.dart.*andassets/**retained for a 24–72 hour grace window index.htmlandflutter_bootstrap.jsupdated last, atomically, after the new files land- SPA catch-all rewrite excludes
/main.dart.*,/assets/**, and/canvaskit/** - Source maps stripped from the public tree and uploaded to the error monitor in CI
- Old service-worker caches purged in the worker’s
activatehandler curl -Ispot-checks on the document, one entrypoint, and one asset after every deploy- A previous-release tab still loads after the new release ships (test it, don’t assume it)
Takeaway: Hash every immutable file with --web-content-hash, serve it public, max-age=31536000, immutable, serve index.html and the bootstrap files no-cache with an ETag, precache hashed assets from precache_manifest.json, and keep the previous release on disk for a 24–72 hour grace window.