You know the drill. You deploy a shiny new version of your Flutter Web app. You ping the client: "It's live!" They open the app, and… nothing changed. Same old version. You sigh, and type the magic words:
"Could you please clear your browser cache?"
If you've shipped a Flutter Web app to real users, you've been there. I've been there more times than I'd like to admit. After months of fighting this, I finally built a solution that actually works in production. No more "clear your cache" messages. Ever.
Let me walk you through the journey.
Why Flutter Web Caching Is So Painful
Before diving into solutions, let's understand why this happens. It's not random — there's a very specific chain of failures.
When you run flutter build web, Flutter generates several files:
-
index.html— the entry point that loads everything -
main.dart.js— your compiled Dart application -
flutter_service_worker.js— a service worker that caches assets -
canvaskit/— the rendering engine -
assets/— your fonts, images, etc.
Flutter does generate content hashes for some of these files. So in theory, when you deploy a new build, the browser should detect the new hashes and fetch fresh files. In practice? It doesn't work reliably, and here's why:
The root cause is index.html itself. The browser caches index.html, which contains references to all other files. If the browser serves a stale index.html, it loads stale references, which point to stale assets. Even if you deployed new files, the user never sees them.
The service worker makes it worse. Once installed, the Flutter service worker aggressively caches everything. Even if index.html changes on your server, the service worker intercepts the request and serves the cached version. It's a cache guarding a cache.
Think of it like this:
User requests your app
│
▼
┌─────────────┐ ┌──────────────────┐
│ Browser │────▶│ Service Worker │
│ Cache │ │ Cache (stale) │
└──────┬──────┘ └────────┬─────────┘
│ │
▼ ▼
Stale index.html ──▶ Stale main.dart.js
│
▼
User sees old app 😤
So how do you break this chain?
Attempt #1: The Query String Approach
The most common advice you'll find online is: "just add ?v=version to your script tags."
<script src="main.dart.js?v=1.2.3"></script>
It sounds logical. The browser sees a new URL, so it fetches a fresh file. And it works… sometimes.
The problems:
-
Some CDNs and proxies ignore query strings for caching purposes. Your Cloudflare, Varnish, or corporate proxy might serve the cached version regardless of
?v=. - You have to inject the version everywhere. Miss one reference and you get a Frankenstein app: half old, half new.
-
It doesn't solve the
index.htmlproblem. Ifindex.htmlitself is cached, the browser never even sees your new?v=parameter.
I used this for a while. It reduced complaints by maybe 50%. Not good enough.
Attempt #2: Nuking the Cache from JavaScript
My next idea was more aggressive: detect a new build from JavaScript, then wipe every cache before loading the app. Here's roughly what I wrote in flutter_bootstrap.js:
// Check version file, compare with stored build number
var xmlhttp = new XMLHttpRequest();
xmlhttp.open("GET", '/version.txt?v=' + Date.now(), true);
xmlhttp.onload = async function () {
if (xmlhttp.status == 200) {
var buildNumber = xmlhttp.responseText;
var currentBuild = localStorage.getItem('buildNumber');
if (!currentBuild || currentBuild !== buildNumber) {
// Nuke all caches
caches.keys().then((names) => {
return Promise.all(names.map((name) => caches.delete(name)));
});
localStorage.setItem('buildNumber', buildNumber);
}
}
};
await xmlhttp.send(); // ← spot the bug?
setTimeout(() => { loadApp(); }, 200);
This worked better. On every page load, it fetches a version.txt file (with a timestamp to bypass cache), compares it to the stored version, and nukes the Cache API if there's a mismatch.
But it had a subtle and nasty bug: XMLHttpRequest.send() doesn't return a Promise. The await resolves immediately to undefined. That means setTimeout(loadApp, 200) starts running in parallel with the cache cleanup — not after it. On slow connections or large caches, loadApp() could fire before the caches were actually cleared.
It was a race condition hidden behind a 200ms prayer.
The Solution That Actually Works
After going through all this, I settled on a three-layer approach. Each layer handles a different part of the caching chain.
Layer 1: Path-Based Versioning (Build Script)
Instead of appending query strings, I change the actual path of every asset at build time. Every build gets its own unique directory name, and index.html references that directory.
The key part of the build script:
# Generate a unique version identifier
PACKAGE_VERSION="$(jq -r .version build/web/version.json)"
BUILD_NUMBER="$(jq -r .build_number build/web/version.json)"
RANDOM_SUFFIX="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 12 | head -n 1)"
VERSION="build-${PACKAGE_VERSION}+${BUILD_NUMBER}-${RANDOM_SUFFIX}"
# Move all assets into the versioned directory
mkdir -p "$WEB_PATH/build"
rsync -a --exclude='build*/' "$WEB_PATH/" "$WEB_PATH/build/"
# Clean up non-build files from root (keep only HTML + build dir)
find "$WEB_PATH" -name 'build*' -prune -o \
! -path "$WEB_PATH" -exec rm -rf {} +
# Move HTML files back to root
mv "$WEB_PATH/build/"*.html "$WEB_PATH/"
# Rewrite all references in HTML to point to versioned path
find "$WEB_PATH" -type f -name "*.html" -exec sed -i \
'/<base/! s|href="\([^"]*\)"|href="'"$VERSION"'/\1"|g' {} +
find "$WEB_PATH" -type f -name "*.html" -exec sed -i \
's|src="\([^"]*\)"|src="'"$VERSION"'/\1"|g' {} +
# Create a symlink: version-name → build directory
ln -s "./build" "./$VERSION"
After this runs, your deployment looks like this:
web/
├── index.html ← references build-1.2.0+42-a8f3b2c1d4e5/
├── build/ ← actual assets
│ ├── main.dart.js
│ ├── canvaskit/
│ ├── assets/
│ └── ...
└── build-1.2.0+42-a8f3b2c1d4e5 → ./build (symlink)
Why is this better than query strings?
- Every CDN and proxy respects path changes. A different URL path is always a cache miss. No exceptions.
-
Old cached
index.html→ old path → 404 → force refresh. If a user has a staleindex.html, it references a path that no longer exists (or points to old files). The service worker can't serve what doesn't exist. - It's atomic. You don't need to remember to version each file individually. The entire build is under one versioned path.
Layer 2: Proper Cache Cleanup (Bootstrap JS)
Even with path versioning, there's one edge case: users who already have an installed service worker from a previous version. The old service worker might still intercept requests. So we still need the cache check — but done correctly this time:
async function checkAndClearCache() {
if (!('serviceWorker' in navigator)) return;
try {
const response = await fetch('/version.txt?v=' + Date.now());
if (!response.ok) return;
const buildNumber = (await response.text()).trim();
const currentBuild = localStorage.getItem('buildNumber');
if (currentBuild && currentBuild === buildNumber) return;
console.log('[CACHE] New build detected:', buildNumber);
if ('caches' in window) {
const names = await caches.keys();
await Promise.all(names.map(name => caches.delete(name)));
console.log('[CACHE] All caches cleared.');
}
localStorage.setItem('buildNumber', buildNumber);
} catch (error) {
console.warn('[CACHE] Version check failed:', error);
}
}
window.addEventListener('load', async () => {
await checkAndClearCache();
loadApp();
});
function loadApp() {
{{flutter_js}}
{{flutter_build_config}}
_flutter.loader.load();
}
The difference with my previous attempt: fetch() returns a real Promise, so await actually waits. No more race condition. loadApp() fires only after caches are confirmed cleared.
Layer 3: HTTP Headers (NGINX)
The final layer ensures the browser always checks for a fresh index.html and service worker, while caching everything else aggressively (since path versioning already handles invalidation):
location /app/ {
# Always revalidate the entry points
location ~ (index\.html|flutter_service_worker\.js)$ {
add_header Cache-Control "no-cache, must-revalidate";
try_files $uri =404;
}
# Versioned assets: cache forever (path changes on new builds)
location ~* \.(js|css|woff2?|png|jpg|svg|ico)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
}
This is the simplest part, but also the most important. no-cache on index.html means the browser always checks with the server: "Is this still fresh?" If the server says yes (304 Not Modified), no download happens — it's just a lightweight HEAD request. But if there's a new build, the browser gets the new index.html, which points to the new versioned path, and everything flows.
The Three Layers Working Together
New deployment hits the server
│
▼
NGINX: "index.html? Always revalidate."
│
▼
Browser fetches fresh index.html
│
▼
index.html references build-1.3.0+43-x7k9m2p4q1w8/
│
▼
Path doesn't exist in cache → fresh download
│
▼
Bootstrap JS confirms new version → clears old caches
│
▼
User sees new app ✅
No query strings to manage. No race conditions. No stale service workers. No "please clear your cache."
Key Takeaways
Path-based versioning beats query strings. CDNs and proxies always respect path changes. Query strings? Not always.
XMLHttpRequest.send() does not return a Promise. If you're using await with XHR, you have a silent bug. Use fetch() instead.
Control your cache with HTTP headers, don't fight it. Let the browser cache aggressively for static assets (great for performance), but force revalidation on the two files that matter: index.html and the service worker.
Defense in depth. No single layer is bulletproof. Path versioning handles 95% of cases. The JS cleanup handles stale service workers. NGINX headers ensure the entry point is always fresh. Together, they cover every scenario I've encountered in production.
This setup has been running in production at OWLNEXT across multiple client projects for months, and we haven't heard "I don't see the update" since.
If you have a different approach or found edge cases I missed, I'd love to hear about it in the comments.
Top comments (0)