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
-O3or-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.
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.
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 functiononly in Closure builds. The method was not inEXPORTED_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.
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.
Related
- Emitting ES modules from Emscripten — the output settings used above.
- Calling C functions from JavaScript with ccall and cwrap — runtime methods that must be exported.
- Reducing Wasm bundle size with wasm-opt — the module side of the same goal.
- Inlining small Wasm modules as Base64 — another way small modules reduce request overhead.
← Back to Wasm Optimization Flags & Size Reduction