Godot 4 web export for portals: threads, headers and files
Export a Godot 4.7 game for web portals: why single-threaded is the default, when COOP/COEP headers matter, which files ship, and how to test locally.
Godot 4 exports a game to the browser as WebAssembly plus WebGL 2.0. For portal work, one export option decides most of your problems: Thread Support. This guide follows the stable Godot documentation, which is the 4.7 branch as of 2026-09-30, and the engine source on GitHub where the docs are silent.
What the web export can and cannot do
The web export page sets the baseline:
- Renderer. Godot 4 targets WebGL 2.0 through the Compatibility rendering method only. Forward+ and Mobile do not run on the web, and Godot does not support WebGPU. The docs also warn that Safari has WebGL 2.0 issues other browsers do not.
- C#. Godot 4 projects written in C# cannot be exported to the web. Both the export page and the C# basics page point to Godot 3 if you need C# in the browser.
- GDExtension. Enable Extension Support in the export preset, and compile each extension for the web. Like threads, this option requires cross-origin isolation headers. The default export templates do not include GDExtension support; the Compiling for the Web page shows how to build templates with
dlink_enabled=yes. - Background tabs. The browser pauses the project when its tab is not active, so
_process()and_physics_process()stop until the player returns. - Fullscreen and mouse capture only work from inside an input event callback such as
_input. Polling theInputsingleton is not enough.
Thread Support: leave it off for portals
The option is variant/thread_support in the EditorExportPlatformWeb reference. Its default is false in the export plugin source, and the web export page calls single-threaded export the preferred and default way to ship since Godot 4.3.
The reason is SharedArrayBuffer. A threaded export uses it, and MDN says shared memory needs a secure context that is also cross-origin isolated. The Godot docs spell out the consequence: server-side headers plus complete cross-origin isolation, which means no ads and no third-party integrations on the page hosting the game. That is why they describe the single-threaded export as more compatible with stores like itch.io and with web publishers like Poki or CrazyGames. It also avoids long-standing problems with threaded exports on macOS and iOS.
The trade-offs, per the class reference and the web export page: without threads the game only needs HTTPS, but it is more prone to performance and audio issues and cannot use Thread.
Audio changed at the same time. Since 4.3, web exports default to Sample playback through the Web Audio API, which keeps latency low without threads. Samples do not support AudioEffects, reverb, doppler or procedural audio, and positional audio can misbehave. Switching a player to Stream restores Godot’s full audio pipeline at the cost of higher latency, especially without threads. Browsers still block sound until the player interacts; see the Web Audio unlock guide.
What cross-origin isolation requires
If you do enable threads, the web export page lists the response headers the files must be served with:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
With require-corp, every cross-origin resource loaded in no-cors mode must send a Cross-Origin-Resource-Policy header that allows embedding (MDN on COEP). Cross-origin scripts and images that opt in through neither CORP nor CORS are blocked, which is why the Godot docs say full isolation rules out ads and third-party integrations on the hosting page.
Portals run games inside an iframe on their own page. The web.dev isolation guide says that to use SharedArrayBuffer inside an iframe, the embedding page must add allow="cross-origin-isolated" to the <iframe>. The portal controls that markup, not you. So a threaded build works only where the portal has set this up, and you should look for it in the portal’s own docs before turning threads on.
To check a page, open the console:
console.log(window.crossOriginIsolated, typeof SharedArrayBuffer);
crossOriginIsolated must be true for a threaded export to start.
The PWA option and the header workaround
Ticking Progressive Web App > Enable does several things, according to the web export page:
- Adds icons, a display mode and a screen orientation for players who add the game to their home screen.
- Installs a service worker so the game can load offline after one visit. Browsers may evict that cache, and you can configure an Offline Page for that case.
- With Enable Cross Origin Isolation Headers checked, the service worker makes each response look as if it carried COOP/COEP. The docs present this as the fix when you cannot set headers on the host. A secure context is still required.
In the source, progressive_web_app/enabled defaults to false and progressive_web_app/ensure_cross_origin_isolation_headers defaults to true, so the second option only matters once PWA is on.
The Godot docs do not say whether this workaround works inside a third-party portal’s iframe, and this guide did not verify it. Treat it as a tool for hosts you control. If you do use it, remember the troubleshooting note on the same page: the service worker can keep serving an older build until you unregister it in DevTools.
What the export produces
Name the export index.html. The docs warn that renaming exported files afterwards can break the build, including the main HTML file, because other files expect the name set at export time. A later section of the same page says the generated HTML can be renamed at any time; exporting as index.html in the first place avoids depending on either statement. Each file in the template gets your chosen base name. From the export plugin source:
| File | What it is | When |
|---|---|---|
index.html |
Page that boots the engine | Always |
index.js |
Start-up code used by the HTML | Always |
index.wasm |
The engine as a WebAssembly module | Always |
index.pck |
Your game’s main pack | Always |
index.png |
Boot splash image | Always |
index.audio.worklet.js, index.audio.position.worklet.js |
Audio worklets | Always |
index.icon.png, index.apple-touch-icon.png |
Icons | When html/export_icon is on (default) |
index.side.wasm |
Side module for GDExtension | Extension Support on |
index.service.worker.js, index.manifest.json, index.offline.html, sized index.<w>x<h>.png icons |
PWA files | PWA enabled |
The docs ask for .wasm to be served as application/wasm, since other MIME types can block start-up optimizations, and .pck as application/octet-stream.
Do not hand-edit the exported HTML. Godot regenerates it on every export and fills in placeholders. Use a custom HTML shell or the Head Include field instead, for example to add a portal SDK script tag.
Compression and size
The web export page recommends server-side compression for .wasm and .pck. It says the WebAssembly module shrinks to around a quarter of its size with gzip, and suggests Brotli precompression where the server supports it.
Sources disagree about one host. The Godot page lists itch.io among hosts without on-the-fly compression, while itch.io’s own HTML5 doc says its CDN gzips uncompressed files of listed types, including .wasm and .pck. Trust the host’s own documentation for what it does today. On portals, compression is the host’s job, so check each portal’s page before you upload pre-compressed files.
To shrink the engine itself, Optimizing a build for size covers custom export templates. Official builds already compile with size optimization for the web only, and a build profile generated by the engine compilation configuration editor can strip unused features. More general advice is in the guide to keeping builds small.
Testing locally with the right headers
The docs point to a Python serve.py script in the Godot repository. If you prefer Node, this server uses only node:http and node:fs. It sends COOP/COEP by default, serves .wasm as application/wasm, and refuses path traversal. Pass --no-isolation to drop the headers, which is closer to how most portals serve a single-threaded build.
// serve.mjs: local static server with cross-origin isolation headers.
// Usage: node serve.mjs <export-folder> [port] [--no-isolation]
import http from 'node:http';
import fs from 'node:fs';
const args = process.argv.slice(2);
const isolate = !args.includes('--no-isolation');
const [dir = '.', portArg = '8060'] = args.filter((a) => !a.startsWith('--'));
const root = dir.replace(/[\\/]+$/, '');
const port = Number(portArg);
const TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.wasm': 'application/wasm',
'.pck': 'application/octet-stream',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.css': 'text/css; charset=utf-8',
};
function contentType(file) {
const dot = file.lastIndexOf('.');
const ext = dot === -1 ? '' : file.slice(dot).toLowerCase();
return TYPES[ext] ?? 'application/octet-stream';
}
http.createServer((req, res) => {
let pathname;
try {
pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
} catch {
res.writeHead(400).end('Bad request');
return;
}
// Refuse path traversal and null bytes before touching the file system.
if (pathname.includes('\0') || pathname.split(/[\\/]/).includes('..')) {
res.writeHead(403).end('Forbidden');
return;
}
if (pathname.endsWith('/')) pathname += 'index.html';
const file = root + pathname;
fs.stat(file, (err, stat) => {
if (err || !stat.isFile()) {
res.writeHead(404, { 'Content-Type': 'text/plain' }).end('Not found');
return;
}
const headers = {
'Content-Type': contentType(file),
'Content-Length': stat.size,
'Cache-Control': 'no-store',
};
if (isolate) {
headers['Cross-Origin-Opener-Policy'] = 'same-origin';
headers['Cross-Origin-Embedder-Policy'] = 'require-corp';
}
res.writeHead(200, headers);
if (req.method === 'HEAD') {
res.end();
return;
}
fs.createReadStream(file).pipe(res);
});
}).listen(port, '127.0.0.1', () => {
console.log(`Serving ${root} at http://127.0.0.1:${port}/ (isolation ${isolate ? 'on' : 'off'})`);
});
node serve.mjs ./build/web 8060
node serve.mjs ./build/web 8061 --no-isolation
Open http://127.0.0.1:8060/. MDN’s secure contexts page counts 127.0.0.0/8 and localhost as potentially trustworthy, so plain HTTP works locally. Cache-Control: no-store stops the browser from reusing an old .pck between exports. Use this for testing only.
How the site’s checker reads your build
The default shell, full-size.html, contains const GODOT_THREADS_ENABLED = $GODOT_THREADS_ENABLED;. At export, Godot replaces the placeholder with true or false from Thread Support. The portal readiness checker reads that constant from the HTML in your ZIP. true means the build needs a cross-origin isolated page. A custom shell that drops the constant leaves the checker to look for other threading markers instead.
What to check before you submit
- Thread Support is off, unless the portal documents COOP/COEP and iframe isolation support.
- The exported HTML contains
GODOT_THREADS_ENABLED = false. - The project uses the Compatibility renderer, and no C# or unsupported GDExtension.
- Files keep their export-time names, with
index.htmlat the ZIP root. - The game starts under
--no-isolation, with no console errors. - Audio starts after the first click or tap, and nothing relies on audio effects in Sample mode.
- PWA is off unless you need it. If you tried it, unregister the old service worker before testing.
- The ZIP passes the checker, and its size fits the target portal’s published limits (see the portal comparison).
Limits of this guide
It follows the stable (4.7) docs and the master branch source as of 2026-09-30; option defaults can differ in older 4.x releases, so check your editor’s export dialog. It does not cover threaded exports on specific portals or whether the PWA header workaround runs inside portal iframes. Neither is documented in the sources above, and neither was tested for this guide.
Questions
- Should I enable Thread Support when exporting a Godot 4 game for a portal?
- Usually not. Threaded exports need SharedArrayBuffer, which needs a cross-origin isolated page, and the Godot docs describe the single-threaded export as more compatible with web publishers such as Poki and CrazyGames.
- Can I export a Godot 4 C# project to the web?
- Not as of the Godot 4.7 stable docs. They say Godot 4 C# projects cannot be exported to the web and point to Godot 3 for C# on the web.
- How does the portal readiness checker know whether my Godot build uses threads?
- It reads the GODOT_THREADS_ENABLED constant that the default Godot shell writes into the exported HTML file as true or false.
Sources
- Exporting for the Web (Godot Engine documentation, stable)
- Godot Docs, 4.7 branch: documentation index (Godot Engine)
- EditorExportPlatformWeb class reference (Godot Engine documentation)
- C# basics (Godot Engine documentation)
- Compiling for the Web (Godot Engine documentation)
- Optimizing a build for size (Godot Engine documentation)
- Custom HTML page for Web export (Godot Engine documentation)
- platform/web/export/export_plugin.cpp (godotengine/godot on GitHub, master)
- misc/dist/html/full-size.html, default web shell (godotengine/godot on GitHub, master)
- SharedArrayBuffer (MDN)
- Cross-Origin-Embedder-Policy header (MDN)
- Window.crossOriginIsolated (MDN)
- Secure contexts (MDN)
- A guide to enable cross-origin isolation (web.dev)
- Uploading HTML5 games: compression (itch.io docs)