You deployed. The server has the new files. But users are still seeing the old UI. Here's exactly why — and the nginx fix that solves it permanently.
You push new code. You deploy. You check the live URL — and see the old UI.
You force-refresh. Now it's fine. But your users don't know what force-refresh means.
This is the browser caching problem. I ran into it while deploying a Compose Multiplatform WebAssembly app behind nginx on a VPS. The deployment succeeded, the new .wasm files were on the server, the backend changes were live — yet users kept seeing the previous UI.
Opening Chrome DevTools mysteriously fixed it. That clue led me straight to the root cause.
My Setup
I'm building a Kotlin Multiplatform app with a Compose WASM frontend served through nginx on a VPS. After deploying a new version with UI changes, I expected users to automatically receive the latest build. Instead, the browser kept loading the old one — until DevTools was open, which disables caching by default.
What Is Browser Caching?
When you visit a website, the browser downloads HTML, JS, WASM, CSS, and images. On future visits, instead of re-downloading everything, it loads from local cache — controlled by headers like:
Cache-Control: public, max-age=31536000
This tells the browser: cache this file for one year and don't ask the server again.
When no explicit cache headers are set, nginx falls back to heuristic caching — the browser guesses how long to keep files based on Last-Modified. The result is unpredictable.
The index.html Problem (and Why It's the Real Culprit)
Every web app starts with index.html:
<!DOCTYPE html>
<html>
<head>
<script src="app.js"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
This file is the map — it tells the browser which JS, WASM, and CSS assets to load.
Modern build tools already solve asset caching through content hashing. Instead of app.js, they emit app-a3f8c291.js. If the file changes, the hash changes, so the filename changes — the browser is forced to fetch it fresh. This is called cache busting.
But this entire strategy collapses if
index.htmlis cached.
If the browser serves a stale index.html, it never discovers the new filenames and keeps loading the old assets.
Since index.html always has the same filename and nginx wasn't sending explicit cache headers for it, Chrome was happily caching it.
The entire issue can be summarized in the following flow:
The Fix — Two nginx Location Blocks
# Never cache the HTML entry point
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# Cache hashed assets forever
location ~* \.(js|wasm|css)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
Then reload nginx:
nginx -t && systemctl reload nginx
No application changes. No rebuilds. No redeployment.
Breaking Down the Headers
For index.html:
-
no-cache, no-store, must-revalidate— always fetch a fresh copy -
Pragma: no-cache— HTTP/1.0 compatibility -
Expires: 0— immediately marks the response as expired
For JS, WASM, and CSS:
-
public, max-age=31536000— cache for one year -
immutable— the browser won't even attempt revalidation (safe, because content changes always produce new filenames)
This Is the Industry Standard
Vercel, Netlify, Next.js, and Create React App all implement this exact pattern. The rule is simple:
Cache the files that change their name. Never cache the file that doesn't.
Key Takeaways
- Never cache
index.html. - Content hashing already handles JS/WASM cache invalidation — but only if
index.htmlis fresh. - Missing cache headers create unpredictable browser behavior.
- If Chrome DevTools "fixes" a stale UI, it's a caching clue, not a solution.
- This is a deployment configuration problem, not a Compose WASM problem.
The next time a deployment looks successful but users still see an old UI, check your cache headers before touching your application code.
About Me
I'm building production apps with Kotlin Multiplatform, Compose Multiplatform, Ktor, and WebAssembly. I'll keep sharing real-world debugging stories and deployment lessons as I hit them.














