Keeping web game builds small: textures, audio, code and loading
Cut the download size of an HTML5 game: KTX2 and WebP/AVIF textures, Opus/MP3/AAC audio, minified and split code, host compression and a size report script.
Portals cap upload size and players on mobile networks pay for every megabyte before the first frame. This guide covers the four places where web game builds usually grow: textures, audio, JavaScript and delivery. It does not list portal limits. Those are published per portal and change; see the portal comparison and pages such as YouTube Playables and itch.io.
Measure what ships
Start by measuring the folder you zip, not your source tree. This script uses only Node built-ins. It lists the largest files and shows a gzip -9 size for each, a rough guide to what host compression could save.
// largest-files.mjs: list the biggest files in a build folder.
// Usage: node largest-files.mjs <folder> [count]
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
const root = process.argv[2];
const count = Number(process.argv[3] ?? 15);
if (!root) {
console.error('Usage: node largest-files.mjs <folder> [count]');
process.exit(1);
}
function walk(dir, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (entry.isFile()) out.push({ file: path.relative(root, full), bytes: fs.statSync(full).size });
}
return out;
}
const kib = (n) => `${(n / 1024).toFixed(1)} KiB`.padStart(14);
const files = walk(root).sort((a, b) => b.bytes - a.bytes);
const total = files.reduce((sum, f) => sum + f.bytes, 0);
console.log(`${files.length} files, ${kib(total).trim()} total`);
if (files.length === 0) process.exit(0);
console.log(`${'raw'.padStart(14)} ${'gzip -9'.padStart(14)} ${'share'.padStart(6)} file`);
for (const f of files.slice(0, count)) {
const gz = zlib.gzipSync(fs.readFileSync(path.join(root, f.file)), { level: 9 }).length;
const share = `${((f.bytes / total) * 100).toFixed(1)}%`.padStart(6);
console.log(`${kib(f.bytes)} ${kib(gz)} ${share} ${f.file}`);
}
node largest-files.mjs ./dist 20
Read the two columns together. When the gzip size barely differs from the raw size, the file is already compressed (PNG, Ogg, MP3), and only re-encoding the asset will shrink it. When gzip cuts a file sharply, as with JavaScript, JSON, WebAssembly or uncompressed audio, the host’s HTTP compression will do much of the work for you. The file count line matters too, because some portals cap the number of files as well as the bytes (itch.io is one).
To see what is inside a JavaScript bundle, esbuild’s --analyze flag prints a per-module size report, and its metafile can be loaded into the Bundle Size Analyzer linked from the same page.
Textures
Download size and GPU memory are different problems
MDN’s compressed texture page makes the split explicit. GPU formats such as S3TC, ETC or ASTC save video memory, but they do not save bandwidth: over the network they are generally far larger than PNG or JPEG. WebGL also cannot compress textures for you. You ship them already encoded, and each format is only usable if the device exposes the matching extension.
MDN’s WebGL best practices add that no single format works everywhere. Desktop GPUs usually expose S3TC, Android devices ETC, iOS devices PVRTC, and ASTC only appears on newer hardware.
KTX2 with Basis Universal
This is the problem Basis Universal addresses. You encode once into a supercompressed intermediate format, stored in a .ktx2 (or .basis) file, and a transcoder converts it at load time to whatever the GPU supports: BC1-5 and BC7, ETC1/2, ASTC, PVRTC or uncompressed RGBA as a last resort. The README lists several codecs. The two long-standing LDR ones are ETC1S (high compression, low to medium quality) and UASTC LDR 4x4 (high quality); v2.5 adds newer ASTC- and BC7-based modes. MDN’s best-practices page says this extra compression brings Basis files much closer to JPEG sizes over the wire than regular GPU textures.
KTX 2.0 is the Khronos container that carries Basis Universal data. KTX-Software provides the ktx command-line tool (ktx create, ktx encode, ktx validate) and a WebAssembly transcoder, msc_basis_transcoder, for JavaScript loaders.
The trade-off is a transcoder download. Whether that pays off depends on how much texture data you ship. For a 2D game with a few sprite atlases, WebP or PNG is often enough. For a 3D game with many large textures, KTX2 is the tool built for the job.
WebP and AVIF for 2D art
For ordinary images, MDN’s image format guide recommends WebP or AVIF over PNG, JPEG and GIF because they generally compress better. The support details from the same page:
- WebP: listed as supported by all versions of Chrome, Edge, Firefox, Opera and Safari, although the same section notes it lacks deep historical support and suggests a JPEG or PNG fallback. Maximum size is 16,383 × 16,383 pixels, which matters for big atlases.
- AVIF: Chrome 85, Edge 121, Opera 71, Firefox 93 and Safari 16.1. MDN advises keeping a fallback in a more widely supported format.
Both decode to ordinary bitmaps, so on the GPU they take as much memory as a PNG of the same dimensions. WebP also has a lossless mode, which MDN says is typically smaller than the same image as PNG; use it for pixel art, where lossy artefacts show on hard edges.
Audio
Music is often the single largest file in a small game. Pick the codec by browser support first, then tune the bitrate by ear.
From MDN’s web audio codec guide:
| Codec | Support noted by MDN | Containers |
|---|---|---|
| MP3 | All major browsers | MPEG, MP4, ADTS, 3GP |
| AAC | All major browsers in an MP4 container. Chrome supports AAC only in MP4; Firefox relies on the platform’s decoder. | MP4, ADTS, 3GP |
| Opus | Chrome, Edge, Firefox, Opera. For Safari, the note says Opus in <audio> worked only in a CAF file, citing macOS High Sierra and iOS 11. |
Ogg, WebM, MP4, MPEG-TS |
| Vorbis | Safari listed as “No” | Ogg, WebM |
The Safari row is the trap. An Ogg-only build can lose all its sound on iPhones and Macs. The safe patterns are MP3 or AAC everywhere, or Opus/Vorbis with an MP3 or AAC fallback chosen at runtime. The MDN table does not give current Safari versions for Opus in Ogg or WebM, so feature-detect instead of hard-coding browser names:
// Pick the first audio format this browser reports it can play.
const probe = document.createElement('audio');
const AUDIO_FORMATS = [
['audio/ogg; codecs="opus"', '.opus.ogg'],
['audio/mp4; codecs="mp4a.40.2"', '.m4a'],
['audio/mpeg', '.mp3'],
];
export const audioExt =
AUDIO_FORMATS.find(([type]) => probe.canPlayType(type) !== '')?.[1] ?? '.mp3';
// Load `music${audioExt}`, `jump${audioExt}` and so on.
canPlayType() returns "", "maybe" or "probably", so treat any non-empty answer as a yes. The codec strings follow MDN’s codecs parameter guide, where mp4a.40.2 is AAC-LC. canPlayType describes media elements. If you decode through Web Audio, also confirm on your target browsers that decodeAudioData() accepts the file. That method needs complete file data, not fragments, so a long track has to finish downloading before it can play this way.
Code
Minify and tree-shake in the production build
esbuild’s API docs define both steps. Minify produces smaller output that behaves the same but is harder to debug. Tree shaking removes unused top-level declarations. In esbuild it is on by default when bundling or when the format is iife. It only works through ES module import/export, not CommonJS. The MDN glossary describes the same idea. Import only what you use from large libraries so the bundler has something to remove.
esbuild’s --drop:debugger removes debugger statements. --drop:console removes console calls, and the docs warn that it also removes their arguments, so any side effects inside those arguments disappear too.
esbuild src/main.js --bundle --minify --format=esm --splitting \
--outdir=dist/js --drop:debugger --analyze
Split what the first scene does not need
A static import is evaluated at load time. MDN’s import() page recommends dynamic imports for code you are unlikely to need, or will only need later. It also says to keep static imports for startup dependencies, because they work better with static analysis and tree shaking.
With esbuild, code reached through import() goes into its own file only when --splitting is on. Splitting currently works only with the esm format, and the docs call it a work in progress with a known import-ordering issue. Without splitting, import() stays asynchronous but the code stays in the main bundle.
// Load the level editor only when the player opens it.
editorButton.addEventListener('click', async () => {
const { openEditor } = await import('./editor.js');
openEditor(gameState);
});
Loading: fetch assets when they are needed
The same idea applies to assets. Load what the menu and first level need, start the game, and fetch the rest in the background or at level boundaries. A minimal pattern:
const cache = new Map();
export function loadLevel(n) {
if (!cache.has(n)) {
cache.set(n, fetch(`levels/level-${n}.json`).then((r) => {
if (!r.ok) throw new Error(`level ${n}: HTTP ${r.status}`);
return r.json();
}));
}
return cache.get(n);
}
// After level 1 starts, warm the next one without blocking play.
loadLevel(2).catch(() => cache.delete(2));
Deleting the failed entry lets a later call retry instead of reusing a rejected promise.
HTTP compression is the host’s job
Browsers and servers negotiate compression per request. The browser sends Accept-Encoding, and the server picks an algorithm and labels the response with Content-Encoding (MDN, Compression in HTTP). MDN recommends compressing everything except files that are already compressed, such as images, audio and video.
On a portal, you do not control that server. Upload plain files unless the portal documents how it serves pre-compressed ones. itch.io, for example, documents which file types its CDN gzips automatically and how it detects .gz content and .br files. Where a portal’s docs say nothing, assume it serves your files as uploaded, and check the response headers in DevTools on a test page.
For Godot’s .wasm and .pck, see the Godot 4 web export guide.
What to check before you submit
largest-files.mjshas been run on the exact folder you zip, and every large file is explained.- No source maps, PSD/Aseprite files, unused audio takes or
.DS_Storefiles in the build. - Textures use WebP, AVIF with a fallback, or KTX2 where they are large, and pixel art still looks right.
- There is no Ogg-only audio path. A format probe or an MP3/AAC fallback exists and has been tried in Safari.
- The JavaScript is minified, with debug code dropped, and the bundle report shows no library you did not expect.
- Non-essential code and levels load after the first scene.
- Total size and file count fit the target portal’s published limits: see the portal comparison.
- The build passes the portal readiness checker.
Limits of this guide
This guide gives no benchmark numbers. The savings from each technique depend on your assets, and none were measured for it. Browser support notes come from MDN pages as of 2026-09-30. The Opus row in MDN’s codec table is dated for Safari, which is one more reason to feature-detect. Engine-specific texture pipelines (Phaser, three.js, PlayCanvas) are not covered. Check your engine’s documentation for KTX2 loader support.
Questions
- Should I gzip or Brotli my files before uploading to a portal?
- Only if the portal documents how it serves pre-compressed files. HTTP compression is negotiated between the browser and the host, so on most portals it is the host's job.
- Which audio format works in every browser?
- MDN's codec guide lists MP3 as supported by all major browsers and AAC in an MP4 container as supported by all major browsers. Vorbis is listed as unsupported in Safari, so ship a fallback if you use Ogg.
Sources
- Compressed texture formats (MDN)
- WebGL best practices (MDN)
- Basis Universal README (BinomialLLC/basis_universal on GitHub)
- KTX overview (Khronos Group)
- KTX-Software README (KhronosGroup on GitHub)
- Image file type and format guide (MDN)
- Web audio codec guide (MDN)
- Codecs in common media types (MDN)
- HTMLMediaElement: canPlayType() method (MDN)
- BaseAudioContext: decodeAudioData() method (MDN)
- esbuild API: minify, tree shaking, splitting, analyze (esbuild)
- Tree shaking (MDN glossary)
- import() dynamic import (MDN)
- Compression in HTTP (MDN)
- Uploading HTML5 games: compression (itch.io docs)