HTML5 game publishing
How to Prevent Old Cached Files From Breaking Game Updates
How browser and CDN caches break new game builds, and how to fix it with hashed file names, Cache-Control headers, manifests and service worker versioning.
You ship a bug fix. Half your players get it immediately; the other half still see the old bug, or worse, a black screen. Nothing is wrong with the new build. Their browser (or a CDN in between) is running a mix of old and new files.
This guide explains how that happens and how to make it impossible, with working examples for HTTP headers and build tools.
Where old files hide
- The browser cache. Stores files on the player's device and reuses them without asking the server, for as long as the response headers allow.
- The CDN or proxy cache. Stores copies on servers around the world. A player in one region can get an old file while another gets the new one.
- A service worker. If your game registers one, it can keep serving its own cached copies until it updates.
- The game portal. A host that serves your files may add its own caching rules on top of yours.
How a stale file breaks a new build
Imagine index.html loads game.js and levels.json. In version 2 you change the format of levels.json and update game.js to read it.
- A player has version 1's
game.jscached. - They load the new
index.html, which is fresh, and the newlevels.json. - The old
game.jscannot read the new format. The game throws an error and shows a blank screen.
The reverse happens too: a cached old index.html asks for files that no longer exist after you deleted them, and gets 404 errors.
The rule that fixes it
Give every file a different URL when its content changes, then tell browsers to cache those URLs forever. The only file with a fixed URL is index.html, and that one is never cached without checking first.
| File | URL | Cache-Control |
|---|---|---|
index.html | Fixed | no-cache (always revalidate) |
game.3f9a1c.js, style.b72e90.css, atlas.19cd44.png | Changes with content | public, max-age=31536000, immutable |
no-cache does not mean "do not store". It means "store it, but ask the server whether it is still current before using it". Responses with an ETag or Last-Modified header make that check cheap.
Fingerprinting in your build tool
Vite
Vite already adds content hashes to built JavaScript, CSS and imported assets. Check dist/assets/ after npm run build; names should look like index-4a8d12ef.js. If you need to control names, set them in vite.config.js:
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]'
}
}
}
}
Webpack
// webpack.config.js
module.exports = {
output: {
filename: '[name].[contenthash].js',
assetModuleFilename: 'assets/[name].[contenthash][ext]',
clean: true
}
};
Plain files, no bundler
Add a version to the URL in index.html and change it on every release:
<script src="game.js?v=2026-10-17-1"></script>
<link rel="stylesheet" href="style.css?v=2026-10-17-1">
This works for most browsers and CDNs, but some caches ignore query strings. A hash in the file name is more reliable.
Files loaded by code
Fingerprinting only helps if every file is requested under its hashed name. Assets your code loads by a fixed string, such as fetch('levels.json') or an engine's asset manifest, stay stale. Generate a manifest at build time that maps names to hashed names, and load that manifest with no-cache:
// build step (Node): write manifest.json mapping logical names to hashed files
const fs = require('fs');
const crypto = require('crypto');
const files = ['levels.json', 'atlas.png'];
const manifest = {};
for (const f of files) {
const hash = crypto.createHash('sha1').update(fs.readFileSync(f)).digest('hex').slice(0, 8);
const name = f.replace(/(\.[^.]+)$/, `.${hash}$1`);
fs.copyFileSync(f, `dist/${name}`);
manifest[f] = name;
}
fs.writeFileSync('dist/manifest.json', JSON.stringify(manifest));
// game start
const manifest = await (await fetch('manifest.json', { cache: 'no-cache' })).json();
const levels = await (await fetch(manifest['levels.json'])).json();
Cache-Control on the server
nginx
location = /index.html {
add_header Cache-Control "no-cache";
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
Express
app.use('/assets', express.static('dist/assets', {
maxAge: '1y',
immutable: true
}));
app.get('/', (req, res) => {
res.set('Cache-Control', 'no-cache');
res.sendFile('index.html', { root: 'dist' });
});
Netlify or Cloudflare Pages _headers file
/index.html
Cache-Control: no-cache
/assets/*
Cache-Control: public, max-age=31536000, immutable
CDN tips
- Check that your CDN respects the origin's
Cache-Control. Some have their own default TTLs. - If you cannot fingerprint a file, purge it from the CDN after every release.
- Upload new files before the new
index.html, and keep the previous release's files for a while. Players who loaded the old page can still finish their session.
Service workers
If your game uses a service worker for offline play, version its cache name, delete old caches in the activate event, and serve index.html network-first. Never cache the service worker file itself with a long lifetime.
const CACHE = 'game-v2026-10-17-1';
self.addEventListener('activate', (e) => {
e.waitUntil(caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))));
});
How hosting on PlayKovo helps
When a developer uploads a ZIP, PlayKovo stores it under a new unique path made of a timestamp and a random ID, and serves hosted game files with a one-year immutable cache header. Because each upload has its own path, a new build never overwrites an old one in cache. The practical rule for you: keep references inside the build relative, and do not depend on a file with the same name changing in place.
Release checklist
- Every JS, CSS, image, audio and data file has a content hash or a version in its URL.
index.htmlis served withno-cache.- Fingerprinted files are served with
public, max-age=31536000, immutable. - Files loaded from code go through a manifest, not fixed names.
- New files are uploaded before the new HTML; old files are kept for a while.
- The service worker, if any, versions and clears its caches.
- After release, open the game in a fresh private window and in a normal window that already had the old version, and check the Network tab for the expected file names.
Next: once updates are reliable, work on how fast the game opens in what makes an HTML5 game start feel instant.


