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.

PlayKovo Team4 min read
Games currently available on PlayKovo. Open a title to see its current listing.

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.

  1. A player has version 1's game.js cached.
  2. They load the new index.html, which is fresh, and the new levels.json.
  3. The old game.js cannot 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.

FileURLCache-Control
index.htmlFixedno-cache (always revalidate)
game.3f9a1c.js, style.b72e90.css, atlas.19cd44.pngChanges with contentpublic, 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.html is served with no-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.

We use optional cookies for site analytics and site advertising. You can change your choice later. Privacy policy.