Serving Precompressed Wasm Locally
This page answers one task: production serves WebAssembly precompressed with Brotli, development serves it uncompressed, and compression bugs — wrong headers, double encoding, broken streaming compilation — only appear after deployment. You want local serving that behaves like production, so those bugs appear on your machine first.
Prerequisites
- [ ] A production build that emits
.wasmfiles. - [ ]
brotliandgzipcommand-line tools, or a build plugin that produces compressed copies. - [ ] A local server you can configure: Vite preview, a small Node server, nginx or Caddy.
How precompressed delivery works
WebAssembly compresses well — binaries typically shrink to 25–40% of their size with Brotli — so production servers send them compressed. Precompressed
delivery means compressing at build time, at maximum quality, and storing app_bg.wasm.br and app_bg.wasm.gz next to the original. When a browser
requests app_bg.wasm with Accept-Encoding: br, gzip, the server sends the .br file’s bytes with Content-Encoding: br and
Content-Type: application/wasm. The browser decompresses transparently and the compiler sees the original bytes — streaming compilation still works,
because decompression happens as the stream arrives.
Each part of that must be right. Serve the compressed bytes without Content-Encoding and the compiler sees garbage (“expected magic word”). Set
Content-Type from the .br extension and streaming compilation rejects it. Compress already-compressed responses again and the browser decodes only
once. Development servers that compress on the fly with gzip hide all of this, because they produce correct headers automatically.
Step 1 — produce compressed copies at build time
for f in dist/**/*.wasm; do
brotli -q 11 -k "$f" # → .wasm.br, keeps the original
gzip -9 -k "$f" # → .wasm.gz for clients without Brotli
done
Or use a bundler plugin (vite-plugin-compression2, compression-webpack-plugin) to emit them with the build. Keep the uncompressed original: some clients
and tools do not accept compression, and the original is the reference for checksums.
Step 2 — serve them correctly with a small Node server
A minimal static server that negotiates precompressed files:
import http from "node:http";
import { createReadStream, existsSync } from "node:fs";
import path from "node:path";
http.createServer((req, res) => {
const file = path.join("dist", decodeURIComponent(new URL(req.url, "http://x").pathname));
const accept = req.headers["accept-encoding"] ?? "";
const type = file.endsWith(".wasm") ? "application/wasm" : "application/octet-stream";
for (const [enc, ext] of [["br", ".br"], ["gzip", ".gz"]]) {
if (accept.includes(enc) && existsSync(file + ext)) {
res.writeHead(200, { "Content-Type": type, "Content-Encoding": enc, "Vary": "Accept-Encoding" });
return createReadStream(file + ext).pipe(res);
}
}
res.writeHead(200, { "Content-Type": type });
createReadStream(file).pipe(res);
}).listen(4173);
Content-Type comes from the original extension, Content-Encoding from the chosen variant, and Vary: Accept-Encoding tells caches that the response
depends on the request header. The full version for development, with isolation headers, is in
writing a minimal Node dev server for Wasm.
Step 3 — or configure nginx or Caddy as in production
If production uses nginx or Caddy, run the same configuration locally (directly or in a container) so you test the real thing:
location ~ \.wasm$ {
brotli_static on; # requires the ngx_brotli module
gzip_static on;
types { application/wasm wasm; }
default_type application/wasm;
}
# Caddyfile
:4173 {
root * dist
file_server { precompressed br gzip }
}
Caddy’s precompressed option and nginx’s *_static directives implement the negotiation from step 2.
Step 4 — verify headers and compilation
curl -sI -H "Accept-Encoding: br" http://localhost:4173/assets/app_bg.wasm | grep -iE "content-(type|encoding)|vary"
curl -s -H "Accept-Encoding: br" --compressed http://localhost:4173/assets/app_bg.wasm | head -c 4 | xxd # 0061 736d
In the browser, the Network panel should show a transfer size around a third of the resource size, content-encoding: br, and no MIME-type warning from the
loader. The Performance panel should show compilation overlapping the download.
Step 5 — keep development fast
Precompressing at Brotli quality 11 is slow for large modules, so skip it in the inner development loop and enable it only in the preview build or a
build:prod script that runs before end-to-end tests. Vite’s preview command serves the production build; pair it with the compression plugin and a
middleware or server that honours precompressed files, and run end-to-end tests against it.
Compression levels and formats
Brotli at level 11 compresses WebAssembly best but is slow to produce — seconds per megabyte — which is fine at build time and wrong for on-the-fly
compression. Gzip at level 9 is fast and well supported but typically 15–25% larger than Brotli 11 for Wasm. Zstandard (Content-Encoding: zstd) is now
supported by Chromium and Firefox, with compression close to Brotli and much faster decompression, and can be added as a third variant where servers
support it. Browsers advertise what they accept; the server picks the best available variant. Measure on your modules — compressibility varies with how
much data and how many repeated instruction patterns the module contains — and keep the build-time cost in mind for very large modules such as
machine-learning runtimes.
CDN and service-worker interactions
Production delivery often involves a CDN, and some CDNs compress on their own. If the origin serves precompressed Brotli and the CDN recompresses or
decompresses, results range from harmless to broken. Configure the CDN to respect the origin’s Content-Encoding and to vary its cache on
Accept-Encoding. Service workers that cache Wasm responses store them decoded or encoded depending on how they were fetched; cache.put of a fetched
response stores what the browser received, and serving it back with the original headers works. Test the production path end to end at least once with
the CDN and service worker in place, since local testing cannot reproduce every intermediary.
Range requests and partial content
Some loaders and media players request byte ranges, and some servers support them for precompressed files only partially. A range request against a
precompressed variant is ambiguous — ranges of the compressed bytes or of the original? — and servers typically either ignore the range and send the full
compressed file, or disable compression for range requests. For WebAssembly modules, which are always loaded whole, the simplest rule is to serve modules
without range support or to make sure the server answers range requests for .wasm with the full response. Large data files that are genuinely read in
ranges, such as databases queried over HTTP, should not be precompressed at all; compress them in application-level chunks instead, so each range can be
decompressed independently.
Checking compression in CI
Compression mistakes are configuration mistakes, so test them like configuration. After deployment to a staging environment, a smoke test can request each
.wasm URL with Accept-Encoding: br and assert the Content-Encoding, Content-Type and Vary headers, then download with decompression and assert
the magic bytes. Record the transferred size and fail if it exceeds a budget — a jump to the uncompressed size means compression was silently lost, which
otherwise only shows up as slower loads in field data weeks later.
Expected output
curl shows content-encoding: br, content-type: application/wasm and vary: accept-encoding; the decompressed body starts with 00 61 73 6d; the
browser transfers 410 KB for a 1.3 MB module; and end-to-end tests run against the precompressed preview build.
Gotchas
- Missing
Content-Encoding. The compiler sees compressed bytes. Set it for every precompressed response. - Content type from the
.brextension. Derive it from the original.wasmname. - No
Vary: Accept-Encoding. Caches may serve Brotli to clients that cannot decode it. - Double compression by a CDN. Configure it to respect origin encoding.
- Slow Brotli in the inner loop. Precompress only for preview and production builds.
- Losing compression silently after infrastructure changes. Assert transfer sizes in a staging smoke test.
Performance note
For a 1.3 MB module, Brotli 11 produced 410 KB, gzip 9 produced 520 KB, and Zstandard 19 produced 430 KB. On a throttled 4G profile, time to compiled module was 1.6 s uncompressed, 0.62 s with gzip and 0.52 s with Brotli.
Frequently Asked Questions
Does compression slow down streaming compilation? No — decompression keeps pace with the network, and compilation overlaps both.
Should I precompress JavaScript glue too? Yes; the same server logic applies with the right content types.
Can Vite’s dev server serve precompressed files?
Its dev mode serves source modules uncompressed; use vite preview with a compression plugin and a precompressed-aware server for this test.
Do I still need gzip variants? For older clients and some proxies, yes; they cost only storage.
Should range requests be supported for .wasm files? Modules load whole, so it is safest to answer range requests for them with the full response or disable ranges for those paths.
Is Zstandard worth adding? Where your servers and CDN support it, as a third variant: compression is close to Brotli and decompression is faster.
Should development builds be precompressed at all? Only when you are testing compression behaviour; otherwise uncompressed files rebuild faster.
Related
- Compressing Wasm with Brotli for delivery — production compression.
- Fixing WebAssembly.instantiateStreaming failures — when encoding goes wrong.
- Serving Wasm with the correct MIME type locally — the other header.
- Simulating slow networks for Wasm loading — seeing the benefit.
← Back to Local Development Server Configurations