Removing Unused Exports to Shrink Wasm

This page answers one task: a WebAssembly module exports far more functions than the application calls — a library built for every use case, a debug API left in, a binding generator exporting everything public — and you want to remove those exports so the optimiser can delete the code behind them.

Prerequisites

  • [ ] The module and the JavaScript that uses it.
  • [ ] wasm-objdump or wasm-tools to list exports, and twiggy for retained sizes.
  • [ ] Control over the export list: #[wasm_bindgen] attributes, Emscripten flags or linker flags.

Why exports matter for size

Dead-code elimination — in the linker, in wasm-opt, in LLVM — works by reachability. Starting from roots, it marks every function, global, table entry and data segment reachable through calls and references, and removes the rest. Exports are roots: the toolchain must assume that anything exported can be called from outside, so everything an export reaches stays in the module, however unlikely the call.

A Rust library annotated with #[wasm_bindgen] on every public function, an Emscripten build with a long EXPORTED_FUNCTIONS list, or a C build with --export-all therefore keeps code for features the application never uses. Removing exports does not shrink the module by itself; it lets the existing dead-code elimination find more dead code. The effect can be large — a single unused export that reaches a JSON serializer or a regex engine keeps the whole dependency.

How an unused export keeps code alive Dead-code elimination starts from the exports. An unused export such as debug_dump reaches the formatting code and a serializer, so they remain in the module. Removing the export makes them unreachable, and the next optimisation pass deletes them. exports = roots assumed callable debug_dump export never called by JS reaches fmt + serde kept alive remove the export no longer a root DCE deletes them smaller module

Step 1 — list exports and their retained sizes

wasm-objdump -x -j Export app_bg.wasm | head -40
twiggy top --retained app_bg.wasm | grep -i export | head -20

Exports with large retained sizes are the interesting ones. For each, ask: does the JavaScript side call it? Grep the application and its dependencies for the export name, or instrument the glue in a test build to log calls during an end-to-end run.

Step 2 — trim wasm-bindgen exports

In Rust with wasm-bindgen, every item annotated #[wasm_bindgen] becomes an export. Restrict the annotation to the API the application uses, and gate optional APIs behind Cargo features so applications opt in:

#[wasm_bindgen]
pub fn render(scene: &Scene) -> Vec<u8> { /* used by the app */ }

#[cfg(feature = "debug-api")]
#[wasm_bindgen(js_name = debugDump)]
pub fn debug_dump(scene: &Scene) -> String { format!("{scene:#?}") }

Public Rust functions without #[wasm_bindgen] are not exported from a cdylib built by wasm-bindgen, so a library crate can keep a broad Rust API for native users while the Wasm wrapper exports only what JavaScript needs. Structs exported as classes export every method marked in the impl block — move rarely used methods into a separate, feature-gated impl block.

Step 3 — trim Emscripten and C exports

For Emscripten, the EXPORTED_FUNCTIONS list and EMSCRIPTEN_KEEPALIVE annotations determine the roots:

emcc src/*.c -O3 \
  -sEXPORTED_FUNCTIONS=_decode,_encode,_malloc,_free \
  -sEXPORTED_RUNTIME_METHODS=ccall,HEAPU8 \
  -o codec.js

Remove functions the JavaScript does not call, and avoid -sLINKABLE=1 or -sEXPORT_ALL=1 outside of dynamic-linking builds — both export everything. For plain wasm-ld builds, prefer explicit --export=name flags or __attribute__((export_name("name"))) over --export-all, and check that --export-dynamic is not set unintentionally, as described in controlling dead-code elimination in wasm-ld.

Export-everything versus explicit exports Exporting everything makes every function a root, so dead-code elimination removes almost nothing and the module carries unused features. Exporting only what JavaScript calls lets dead-code elimination remove all code unreachable from those exports. export everything every function is a root DCE removes almost nothing unused features shipped biggest module explicit export list only called functions are roots unreachable code removed features opt-in by flag smallest module

Step 4 — prune across the JavaScript boundary with metadce

Some exports are used only by JavaScript glue that the application itself does not use. Binaryen’s wasm-metadce analyses the module together with a description of the JavaScript side’s reachability and removes exports and imports that are dead on both sides. Emscripten runs this automatically at -O2 and above, which is why Emscripten builds shed unused runtime helpers. For other toolchains, wasm-bindgen’s glue is already generated per export, so removing an export removes its glue too; for hand-written loaders, keep the JavaScript wrapper small and explicit so unused exports are easy to spot.

Step 5 — re-optimise and measure

After trimming, rebuild and re-run the optimiser so dead code is actually removed, then compare:

wasm-opt -Oz -o out.wasm app_bg.wasm
twiggy diff before.wasm out.wasm | head -20
brotli -c out.wasm | wc -c

twiggy diff lists what disappeared — usually more than the exported functions themselves. If little disappeared, the removed exports shared their code with exports that remain; look at the next candidate.

Exports needed by tooling

Some exports exist for tools rather than application code: __wasm_call_ctors and _initialize for reactor modules, __indirect_function_table for dynamic linking, memory for JavaScript access, and allocation functions such as malloc, free or __wbindgen_malloc that glue code uses to pass data. Removing them breaks loading in non-obvious ways. Keep tool-required exports, and when unsure, check what the glue or loader references before removing an export.

Keeping the export list from growing back

Exports accumulate: a debugging helper added during an incident, a test-only function, a feature that was removed from the UI but not from the module. Make the export list visible in review by checking it into the repository — a text file generated from wasm-objdump or wasm-tools print output that CI regenerates and compares. A change that adds an export then shows up as a diff, with its retained size, and someone has to decide it is worth shipping. For libraries published to npm, the list doubles as documentation of the public API.

Finding out which exports are actually called

Grepping for export names catches direct calls but misses dynamic ones — exports looked up by string, called through a generic dispatcher, or used by a plugin loaded at runtime. A more reliable inventory comes from instrumentation. In a test build, wrap instance.exports in a Proxy that records every property access, run the end-to-end suite and a session of manual exploration, and write the set of accessed names to a file:

const used = new Set();
const exports = new Proxy(instance.exports, {
  get(target, name) { used.add(String(name)); return target[name]; },
});
// ... run the app against `exports` ..., then:
console.log([...used].sort().join("\n"));

Exports never accessed during thorough testing are strong candidates, though not proof — rarely used features may not have run. Cross-check the candidates with the code owners, then remove them behind a feature flag first, so a missed use shows up as a clear error in a staging build rather than in production.

Splitting rarely used features into a second module

When a feature is used rarely but must remain available — an export to PDF, an advanced filter, a migration tool — removing its export from the main module and building it as a separate module loaded on demand gives most of the size benefit without losing the feature. The main module then exports only the common path, and the rare path’s code, including dependencies only it needs, downloads when first used. This is the export-level view of splitting a Wasm module for lazy loading: the export lists of the two modules define the split.

Expected output

The export list shrinks from 46 to 11 functions; twiggy diff shows a JSON serializer, debug formatting and two unused codecs removed; the compressed module drops from 212 KB to 137 KB; the application’s end-to-end tests pass unchanged; and CI fails when an unreviewed export appears.

Gotchas

  • Removing tool-required exports. memory, allocators and initialisers are needed by glue. Keep them.
  • --export-all or EXPORT_ALL in release builds. Everything stays. Use explicit lists.
  • Expecting savings without re-optimising. Run wasm-opt after trimming.
  • Exports used only in tests. Gate them behind a feature used only by test builds.
  • Shared code. Removing an export saves nothing if its code is reachable from others.
  • Relying on grep alone. Exports looked up by name at runtime are missed. Instrument a test build to record real usage.

Performance note

Trimming exports removed 35% of the compressed module in this example; instantiation became 18% faster because fewer functions had to be compiled.

Compressed size after trimming exports Brotli-compressed kilobytes of a module exporting every public function compared with the same module exporting only the eleven functions the application calls. KB compressed 46 exports 212 KB 11 exports 137 KB

Frequently Asked Questions

Do unused imports cost size? A little, and they may require JavaScript glue to supply them; metadce removes dead imports along with exports.

Will removing an export break callers? Only if something calls it; search the JavaScript and dependencies before removing.

Can I export a function only in development? Yes — gate it behind a feature or cfg(debug_assertions).

Does tree-shaking in the bundler remove Wasm exports? No — bundlers do not modify the Wasm binary; only the toolchain can.

How do I find exports called dynamically by name? Wrap instance.exports in a Proxy in a test build and record accessed names during end-to-end runs.

Should a published library export everything for flexibility? Export a focused default API and offer extras behind Cargo features or separate entry points, so applications pay only for what they import.

Does removing exports speed up loading too? Yes, slightly: less code to download, validate and compile, so instantiation is faster in proportion to the removed code.

← Back to Wasm Optimization Flags & Size Reduction