Skip to content
Blog

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 patternCache-ControlWhy
/index.htmlno-cache (+ ETag)Main document; must always revalidate so new hashes are discovered
/flutter_bootstrap.js, /flutter.jsno-cacheBootstraps the build config; stale copies pin old releases
/main.dart.<hash>.js, /main.dart.<hash>.wasmpublic, max-age=31536000, immutableHash in URL; content can never change under that name
/assets/** (fonts, images, AssetManifest.<hash>.bin)public, max-age=31536000, immutableSame: hashed, fetched lazily by long-lived tabs
/canvaskit/** (when bundled)public, max-age=604800Versioned with the release but not always URL-hashed
/manifest.json, /version.json, /icons/**max-age=0, must-revalidateUnhashed, must reflect the current release
/api/** (if you serve any)private, no-store or no-cache, privatePersonalized; 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-hash on Flutter 3.50 or later
  • Previous release’s hashed main.dart.* and assets/** retained for a 24–72 hour grace window
  • index.html and flutter_bootstrap.js updated 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 activate handler
  • curl -I spot-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.

Sources