Serving compressed web game builds: gzip, Brotli and .unityweb
How Content-Encoding works, which headers pre-compressed Unity and Godot files need, what Decompression Fallback does, and what portals say about compression.
There are two ways a web build gets compressed on the way to the player. Either the host compresses responses on the fly, or the engine compresses files at build time and the host has to label them correctly. On a portal you do not control the host, so the second way only works if the portal handles your file names the way your engine expects. This guide explains the headers involved, what Unity 6.3 LTS and Godot 4.7 document, and what each portal publishes about compression (as of 2026-10-01). It also includes a script that lists the headers each file in your build needs.
How HTTP compression works
MDN’s compression guide describes the negotiation. The browser sends Accept-Encoding with the algorithms it supports. The server picks one, compresses the body, and names the algorithm in Content-Encoding. Because the response depends on that request header, the server must also send Vary with at least Accept-Encoding, so caches keep the variants apart. MDN recommends compressing everything except formats that are already compressed, such as images, audio and video.
The key detail for games is in MDN’s Content-Encoding page. The header tells the browser how to decode the body to get back the content described by Content-Type. So a pre-compressed game.wasm.br must be served with:
Content-Encoding: br
Content-Type: application/wasm
Send those two headers and the browser decompresses the file before your code sees it. If the server sends the file without Content-Encoding, your loader receives Brotli bytes and has to decompress them itself, or fail. MDN also notes that with Content-Encoding present, Content-Length describes the encoded size.
Why the WebAssembly content type matters
WebAssembly.instantiateStreaming() compiles a module while it downloads. Per MDN, it rejects with a TypeError if the response’s MIME type is not application/wasm. Unity asks for Content-Type: application/wasm on .wasm, .wasm.gz and .wasm.br. The Godot docs say any other type can prevent some start-up optimizations. A wrong type on the WebAssembly file costs load time even when everything else works.
Unity: Compression Format and Decompression Fallback
Unity 6.3’s Deploy a Web application page covers the two settings that decide how your server must behave. Both live under Player settings, Web, Publishing Settings.
Compression Format has three options:
- Gzip is the default. Files are bigger than with Brotli but build faster.
- Brotli gives the smallest files and the slowest builds. Unity notes that Chrome and Firefox only support Brotli natively over HTTPS.
- Disabled produces uncompressed files. Unity suggests it when you compress in post-processing yourself, or when the host compresses statically.
The setting only applies to release builds. Development builds are not compressed.
Decompression Fallback is off by default. With it off, the build files keep the extension of the compression method (.gz or .br), and the server must send the matching Content-Encoding. With it on, Unity embeds a JavaScript decompressor in the loader and renames the build files to .unityweb (Web Build folder). The build then works without server configuration. Unity lists the costs: a larger loader, a less efficient loading scheme, and no WebAssembly streaming, because the file has to pass through the JavaScript decompressor first.
Unity’s server samples (Apache, Nginx, Node.js) add three details:
.data.gzis sent asapplication/gziprather thanapplication/octet-stream, which the Apache sample explains as a workaround for WebKit bug 247421..unitywebfiles are sent asapplication/octet-stream. The Apache page says addingAddEncoding gzip .unityweb(orbr) improves loading for fallback builds, soContent-Encodingon.unitywebis optional.- The Node.js sample sets
Content-Typeexplicitly, because compressed files can otherwise get the wrong type.
Godot: compression is up to the host
The Godot 4.7 web export docs list the exported files: .html, .js, .wasm, .pck and a .png splash. The .pck is usually served as application/octet-stream and the .wasm as application/wasm. The docs recommend server-side compression, especially for .pck and .wasm. They say the WebAssembly module shrinks to around a quarter of its size with gzip, and suggest Brotli pre-compression where the server supports it. There is no compression option in the export itself: if you pre-compress, you do it after export and configure the server yourself.
The same page lists GitHub Pages as compressing on the fly (gzip), and itch.io and GitLab Pages as not. itch.io’s own documentation, below, says its CDN gzips several file types automatically, .wasm and .pck among them. For itch.io, trust itch.io’s page, since it describes itch.io’s own servers.
What portals publish
| Portal | What its docs say (2026-10-01) |
|---|---|
| itch.io | Detects gzip by file content and sets Content-Encoding: gzip. Treats .br files as Brotli by extension, because Brotli cannot be detected. Sets Content-Type from the extension after removing .gz/.br. Gzips html, js, css, svg, wasm, wav, glb and pck files uploaded uncompressed. |
| YouTube Playables | Do not pre-compress: the initial bundle is measured after compression, and uploaded files are compressed after ingestion. If you must, use “zip” pre-compression and decompress in your own loader (it suggests the Compression Streams API). Extra HTTP headers and Brotli are not available. |
| Poki | The Unity guide says to set the compression method to Disabled and turn on Name Files As Hashes. It adds that Asset Bundles can be left uncompressed because the Poki server compresses them. |
| CrazyGames | Recommends Brotli for Unity builds. The first-frame article suggests Brotli for Godot’s .wasm, .js and .pck, served with Content-Encoding: br. The headers CrazyGames itself sends are not published on the pages checked. |
The portals do not agree. A Unity build set to Brotli with fallback off can suit CrazyGames and itch.io and still be the wrong build for YouTube Playables and Poki. Either build once per portal, or use Disabled for a single build: it depends least on how the host handles headers, at the cost of a larger download wherever the host does not compress. See the Unity portal guide for the other settings.
List the headers your build needs
The script walks a build folder, finds .gz, .br, .unityweb, .wasm, .pck and .data files, and prints the Content-Encoding and Content-Type each one needs. The mapping lives in a pure module so it can be unit-tested:
// file: examples/snippets/serving-compressed-web-builds/encoding-map.mjs
// encoding-map.mjs: which Content-Encoding and Content-Type a server should send for a build file.
// Pure functions, no I/O. Types follow MDN's common MIME types list; .data, .symbols.json and
// .unityweb follow Unity 6.3's server configuration samples; .pck follows Godot's web export docs.
export const MIME = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'text/javascript', // Unity's samples send application/javascript; both are JavaScript types
'.mjs': 'text/javascript',
'.json': 'application/json',
'.wasm': 'application/wasm', // required for WebAssembly streaming compilation
'.pck': 'application/octet-stream', // Godot main pack
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.svg': 'image/svg+xml',
'.mp3': 'audio/mpeg',
'.m4a': 'audio/mp4',
'.opus': 'audio/ogg',
'.wav': 'audio/wav',
'.woff2': 'font/woff2',
'.txt': 'text/plain',
};
const extOf = (name) => {
const dot = name.lastIndexOf('.');
return dot > name.lastIndexOf('/') ? name.slice(dot).toLowerCase() : '';
};
/** gzip streams start with 0x1f 0x8b. Brotli has no magic number, so it cannot be sniffed. */
export function sniffEncoding(firstBytes) {
return firstBytes.length >= 2 && firstBytes[0] === 0x1f && firstBytes[1] === 0x8b ? 'gzip' : null;
}
/**
* @param {string} file path relative to the build folder
* @param {{ unitywebEncoding?: 'gzip' | 'br' | null }} [opts] what a .unityweb file contains
*/
export function headersFor(file, { unitywebEncoding = null } = {}) {
const lower = file.toLowerCase();
const notes = [];
let encoding = null;
let inner = lower;
if (lower.endsWith('.unityweb')) {
notes.push('Decompression Fallback build: the loader can decompress it in JavaScript.');
if (unitywebEncoding) notes.push(`Optional: Content-Encoding: ${unitywebEncoding} lets the browser decompress it instead.`);
else notes.push('Encoding unknown (Brotli cannot be detected): check the Compression Format setting.');
return { file, encoding: unitywebEncoding, contentType: 'application/octet-stream', notes };
}
if (lower.endsWith('.br')) encoding = 'br';
else if (lower.endsWith('.gz')) encoding = 'gzip';
if (encoding) inner = lower.slice(0, lower.lastIndexOf('.'));
let contentType = MIME[extOf(inner)] ?? null;
if (inner.endsWith('.data')) {
// Unity's Apache and Nginx samples use application/gzip for .data.gz because of WebKit bug 247421.
contentType = encoding === 'gzip' ? 'application/gzip' : 'application/octet-stream';
} else if (inner.endsWith('.symbols.json') && encoding) {
contentType = 'application/octet-stream';
}
if (!contentType) {
contentType = 'application/octet-stream';
notes.push(`No type for "${extOf(inner) || inner}" in the table: check what your server sends.`);
}
if (inner.endsWith('.wasm')) notes.push('Any other Content-Type breaks WebAssembly streaming compilation.');
return { file, encoding, contentType, notes };
}
// file: examples/snippets/serving-compressed-web-builds/list-compressed.mjs
// list-compressed.mjs: list pre-compressed and engine files in a web build, with the headers to send.
// Usage: node list-compressed.mjs <build-folder>
import fs from 'node:fs';
import path from 'node:path';
import { headersFor, sniffEncoding } from './encoding-map.mjs';
const root = process.argv[2];
if (!root) {
console.error('Usage: node list-compressed.mjs <build-folder>');
process.exit(1);
}
const INTERESTING = /\.(gz|br|unityweb|wasm|pck|data)$/i;
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() && INTERESTING.test(entry.name)) out.push(full);
}
return out;
}
function firstBytes(file) {
const fd = fs.openSync(file, 'r');
const buf = Buffer.alloc(2);
const n = fs.readSync(fd, buf, 0, 2, 0);
fs.closeSync(fd);
return buf.subarray(0, n);
}
const files = walk(root).sort();
if (files.length === 0) console.log('No .gz, .br, .unityweb, .wasm, .pck or .data files found.');
for (const full of files) {
const rel = path.relative(root, full).split(path.sep).join('/');
const sniffed = sniffEncoding(firstBytes(full));
const h = headersFor(rel, { unitywebEncoding: sniffed });
if (rel.endsWith('.gz') && sniffed !== 'gzip') h.notes.push('Named .gz but does not start with the gzip magic bytes.');
if (!/\.(gz|unityweb)$/.test(rel) && sniffed === 'gzip') h.notes.push('Contains gzip data but has no .gz suffix.');
console.log(rel);
const optional = rel.endsWith('.unityweb') && h.encoding ? ' (optional)' : '';
console.log(` Content-Encoding: ${h.encoding ?? '(none)'}${optional}`);
console.log(` Content-Type: ${h.contentType}`);
for (const note of h.notes) console.log(` note: ${note}`);
}
node list-compressed.mjs ./Build
The script reads the first two bytes of each file. That catches a .gz file that is not really gzip, and a .unityweb file that holds gzip data (Brotli cannot be recognized this way, as itch.io’s docs also note). Use the output as a checklist for your own server configuration, or as a list of questions for a portal whose docs say nothing.
Check what the browser received
Read the headers on a page that serves your build. In the Chrome DevTools Network panel, open the .wasm request and look at the response headers: Content-Encoding and Content-Type should match the script’s output. The filter has-response-header:content-encoding shows which responses were compressed. For transferred versus uncompressed sizes, see Measuring time to first frame.
What to check before you submit
- The Unity Compression Format and Decompression Fallback settings match what the target portal documents, not your own server.
- Every
.gzand.brfile is served with the matchingContent-Encodingand the content type of the file inside. .wasmfiles arrive asapplication/wasm.- Decompression Fallback is on only when you cannot control headers, and you accept losing WebAssembly streaming.
- For YouTube Playables, nothing is pre-compressed unless the size limits force it, and then only as the FAQ describes.
- For Godot, you know whether the host compresses
.wasmand.pckon the fly. - The build passes the portal readiness checker.
Limits of this guide
No portal’s actual response headers were inspected for this guide. The portal rows above are only what each portal’s documentation says. GameDistribution, GameMonetize and Y8 were not checked for compression rules. Brotli support claims are Unity’s, and browser support was not re-checked separately. The script was run on a temporary folder of test files, not on a real engine build.
Questions
- Should I choose Brotli, gzip or Disabled in Unity for a portal build?
- Follow the portal. Poki's Unity guide says to set compression to Disabled, YouTube Playables advises against pre-compressing and says Brotli is not available, and CrazyGames recommends Brotli. On itch.io, .br files are served with Content-Encoding: br.
- What is a .unityweb file?
- It is what Unity produces with Decompression Fallback enabled. The loader includes a JavaScript decompressor, so the build works even when the server sends no Content-Encoding header, at the cost of a larger loader and no WebAssembly streaming.
- Why does my .wasm not use streaming compilation?
- The server has to send Content-Type: application/wasm. MDN says instantiateStreaming() rejects with a TypeError on any other type, and Unity and Godot both ask for application/wasm, including for .wasm.gz and .wasm.br.
Sources
- Compression in HTTP (MDN)
- Content-Encoding header (MDN)
- Accept-Encoding header (MDN)
- Common media types (MDN)
- WebAssembly.instantiateStreaming() (MDN)
- Deploy a Web application (Unity 6.3 LTS manual)
- Web Build folder (Unity 6.3 LTS manual)
- Set up your Apache server configuration for Web builds (Unity 6.3 LTS manual)
- Set up your Nginx server configuration for Web builds (Unity 6.3 LTS manual)
- Set up your Node.js server configuration for Web builds (Unity 6.3 LTS manual)
- Exporting for the Web: serving the files (Godot Engine 4.7 documentation)
- Uploading HTML5 games: compression (itch.io docs)
- Certification FAQ: Unity precompression (YouTube Playables)
- PokiSDK: Unity, optimizing your Unity build (Poki for Developers)
- Optimization tips: compression (CrazyGames developer docs)
- Getting to the first frame (CrazyGames developer docs)