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 .wasm files.
  • [ ] brotli and gzip command-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.

Serving a precompressed module The browser requests app.wasm and advertises br and gzip. The server finds app.wasm.br, sends its bytes with Content-Encoding br and Content-Type application/wasm, and the browser decompresses while streaming the bytes into the compiler. GET app.wasm Accept-Encoding: br, gzip server finds .br precompressed at build headers br + application/wasm browser decompresses as bytes stream streaming compile original bytes

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.

Response headers and what the compiler receives Brotli bytes with Content-Encoding br are decoded by the browser and compile correctly. Brotli bytes without the header reach the compiler compressed and fail. A Content-Type derived from the .br extension breaks streaming compilation. Uncompressed bytes always work but transfer three times more. what the server sends compiler sees result .br bytes + br + application/wasm original module streaming compile .br bytes, no Content-Encoding compressed bytes CompileError .br bytes + wrong Content-Type — TypeError (MIME) uncompressed + application/wasm original module works, 3× larger

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 .br extension. Derive it from the original .wasm name.
  • 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.

Transfer size of a 1.3 MB module by encoding Kilobytes transferred for a 1.3 MB WebAssembly module served uncompressed, with gzip level 9, with Zstandard level 19 and with Brotli level 11. KB transferred uncompressed 1,300 KB gzip -9 520 KB zstd -19 430 KB brotli -q 11 410 KB

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.

← Back to Local Development Server Configurations