Shrinking Emscripten Glue with Closure Compiler

This page answers one task: an Emscripten build’s JavaScript loader is larger than you expected — sometimes larger than the compressed .wasm itself — and you want to shrink it with Closure Compiler without breaking the code that calls into it.

Prerequisites

  • [ ] emsdk 3.1.x; Closure Compiler ships with it and needs Java or the bundled native build.
  • [ ] A release build already using -O3 or -Oz.
  • [ ] A list of the runtime methods and exported functions your JavaScript actually uses.

Why the glue is big

Emscripten’s JavaScript output is a runtime, not just a loader. It contains code for every environment the build might run in, a virtual file system, string and memory helpers, the implementation of every system call and library function the C code imports, and the logic for instantiating and starting the module. Settings like ENVIRONMENT and FILESYSTEM=0 remove whole subsystems, but a typical release build still ships tens of kilobytes of JavaScript, much of it unused by your particular program.

Emscripten minifies that JavaScript with its own lightweight tools at -O2 and above. Closure Compiler goes further: it is a whole-program JavaScript optimizer that renames properties, inlines functions, removes code that cannot be reached and folds constants. Enabled with --closure 1, it often removes a further 20–40% from the glue. The catch is that Closure renames anything it believes is internal — and if your own code refers to something by its original name, that reference breaks.

What is inside Emscripten's JavaScript glue Layers of a typical Emscripten release loader, from environment detection and instantiation through the library implementations and helpers, with an indication of which parts settings remove and which parts Closure shrinks further. environment detection + loading web, worker, node paths — trimmed by ENVIRONMENT instantiation + start-up streaming, fallbacks, run dependencies library implementations syscalls, emscripten_* functions your C imports runtime helpers UTF8ToString, HEAP views, ccall/cwrap — kept only if exported file system MEMFS and friends — removed by FILESYSTEM=0 when unused

Step 1 — get a baseline with the cheap settings first

Closure is the last step, not the first. Remove whole subsystems with settings, then measure:

emcc -Oz src/*.c -o dist/app.mjs \
  -sMODULARIZE -sEXPORT_ES6 -sENVIRONMENT=web,worker \
  -sFILESYSTEM=0 \
  -sEXPORTED_FUNCTIONS=_process,_malloc,_free \
  -sEXPORTED_RUNTIME_METHODS=HEAPU8
ls -la dist/app.mjs dist/app.wasm
brotli -q 11 -c dist/app.mjs | wc -c

FILESYSTEM=0 alone often removes a third of the glue for programs that never touch files. If the build fails with errors about missing file-system functions, something in your C code does use fopen or printf to a file descriptor, and the virtual file system is needed — see using the Emscripten file system API.

Step 2 — turn on Closure

emcc -Oz src/*.c -o dist/app.mjs \
  -sMODULARIZE -sEXPORT_ES6 -sENVIRONMENT=web,worker -sFILESYSTEM=0 \
  -sEXPORTED_FUNCTIONS=_process,_malloc,_free \
  -sEXPORTED_RUNTIME_METHODS=HEAPU8 \
  --closure 1

Build time rises by several seconds — Closure is slow — and the output becomes unreadable. Run your tests against it immediately. Most builds work first time, because Emscripten tells Closure which names must be preserved: the exported functions and runtime methods you listed. Problems come from code Emscripten does not know about.

Step 3 — protect your own JavaScript with externs

Anything in --pre-js, --post-js or EM_JS/EM_ASM blocks is compiled together with the glue, and Closure will rename properties it sees used there. If your pre-js reads a property from an object created outside the bundle — a configuration object on window, an option passed by the page — Closure may rename the read and the property will appear to be missing.

// pre.js — reads options supplied by the page
Module["onProgress"] = (pct) => window.appConfig.progressCallback(pct);

Two techniques keep such code safe. Use quoted property access for names that come from outside: Closure never renames quoted properties, so window["appConfig"]["progressCallback"] survives. Or declare the names in an externs file that tells Closure they are external:

// externs.js
/** @type {Object} */
window.appConfig;
/** @type {function(number)} */
window.appConfig.progressCallback;
emcc ... --closure 1 --closure-args=--externs=externs.js

Externs are the more maintainable choice when there are many names; quoting is quicker for one or two.

Which property accesses survive Closure renaming Annotated JavaScript from a pre-js file showing that dotted access to externally defined properties may be renamed and break, while quoted access and names declared in externs are preserved. Module["onProgress"] = handler; quoted: preserved window.appConfig.progressCallback(p); dotted, unknown: may be renamed window["appConfig"]["progressCallback"](p); quoted: preserved /** @type {Object} */ window.appConfig; in externs: preserved everywhere

Step 4 — keep the calling code’s names stable

The code that calls into the module from outside — your application — is not compiled by Closure, so it uses original names. Those names must survive. Emscripten preserves everything in EXPORTED_FUNCTIONS and EXPORTED_RUNTIME_METHODS, so the rule is simple: if the application touches it, list it.

// app.js — not processed by Closure
import createApp from "./dist/app.mjs";
const m = await createApp();
m._process(ptr, len);        // listed in EXPORTED_FUNCTIONS: safe
m.HEAPU8.set(bytes, ptr);    // listed in EXPORTED_RUNTIME_METHODS: safe
m.UTF8ToString(ptr);         // NOT listed: undefined after Closure

The last line is the classic failure: it works without Closure because the helper exists under its original name, and breaks with Closure because the helper was renamed or removed. Building with -sASSERTIONS=1 alongside Closure during testing makes Emscripten add explicit errors for accesses to runtime methods that were not exported, which turns the silent undefined into a clear message.

Step 5 — measure the result

for f in dist-noclosure/app.mjs dist/app.mjs; do
  printf "%-28s raw %7d  brotli %6d\n" "$f" "$(wc -c < $f)" "$(brotli -q 11 -c $f | wc -c)"
done

Compare against the .wasm file’s compressed size to keep perspective: if the module is 400 KB compressed, saving 6 KB of JavaScript is not where your effort should go. For small modules, though, the glue can be most of the download, and Closure is the single biggest lever on it.

How Closure fits into a larger build

Many projects do not ship Emscripten’s output directly; they import it from an application that a bundler then processes. In that setup it is tempting to skip Closure and rely on the bundler’s minifier. That works, but leaves a lot behind: a general-purpose minifier shortens local variable names and removes whitespace, while Closure’s advanced mode understands the whole runtime and removes functions that nothing reaches, which is where most of the saving comes from. Running Closure in the Emscripten link and then letting the bundler minify the result as usual is safe and gets the benefit of both.

The order of operations matters in one respect: Closure must see the glue before anything else rewrites it. If a bundler plugin injects code into the Emscripten output, or a post-processing step patches the loader, those changes should happen after Closure, and should use quoted property names for anything they touch inside the module object.

It is also worth keeping a non-Closure build around for debugging. When something misbehaves only in production, being able to switch the same code to readable glue — same flags, minus --closure 1 — tells you within minutes whether the problem is a renaming hazard or something real. Many teams build both in CI and ship only the Closure output.

Expected output

For a small image-processing module:

dist-noclosure/app.mjs       raw   58213  brotli  14120
dist/app.mjs                 raw   31470  brotli   9204
dist/app.wasm                raw   87342  brotli  38866

Closure cut the compressed glue by about 35%, which for this module was a 9% reduction in the total download.

Gotchas

  • TypeError: m.UTF8ToString is not a function only in Closure builds. The method was not in EXPORTED_RUNTIME_METHODS. Add it.
  • A pre-js option is silently ignored. Closure renamed a property read on an external object. Quote the access or add an extern.
  • Closure warnings fail the build. Some Emscripten versions treat Closure warnings as errors. Read them — they usually point at a real renaming hazard — and pass --closure-args=--jscomp_off=... only for warnings you have understood.
  • Source maps no longer line up. Closure rewrites the glue completely. Generate its source map with --closure-args=--create_source_map=... if you need to debug the loader in production, or debug with the non-Closure build.
  • Builds become slow. Closure adds seconds per link. Use it in release builds only.

Performance note

Besides the size win, the Closure build parsed and executed its startup code faster: on a mid-range phone the glue’s evaluation time fell from 9 ms to 6 ms, because there was less of it and property access was through shorter, monomorphic names. That is small next to Wasm compilation, but it sits directly on the startup path.

Emscripten glue size by setting Compressed size of the same program's JavaScript loader with default output, with environment and file-system settings, and with Closure Compiler added on top. Brotli-compressed glue (KB) default -Oz output 21.6 KB + ENVIRONMENT=web + FILESYSTEM=0 14.1 KB + --closure 1 9.2 KB

Frequently Asked Questions

Is --closure 2 worth it? Level 2 also runs Closure on the asm.js or Wasm2JS fallback code when present. For pure Wasm builds it adds little over level 1.

Does Closure touch the .wasm file? No. It only optimizes JavaScript. The module is shrunk by Binaryen during the link.

Can I use a different minifier instead? Terser or esbuild can minify the output afterwards, but without Closure’s whole-program property renaming the savings are smaller. Running a general minifier over Closure output gains almost nothing.

Should EM_JS functions use quoted names too? Inside EM_JS bodies, any property that refers to something defined outside the generated code should be quoted, for the same reason as in pre-js files. Names defined within the same block are safe to leave unquoted.

Does this matter for Rust and wasm-bindgen? wasm-bindgen’s glue is much smaller and targeted to the exports you defined. Your bundler’s normal minification is enough there.

← Back to Wasm Optimization Flags & Size Reduction