Controlling Dead-Code Elimination in wasm-ld
This page answers one task: after linking, a function you need is missing from the WebAssembly module — or a function you do not need stubbornly
remains — and you need to understand how wasm-ld decides what to keep, and how to steer it.
Prerequisites
- [ ] A C, C++ or Rust build that links with
wasm-ld. - [ ] Access to the link flags and the list of functions JavaScript calls.
- [ ]
wasm-tools printorwasm-objdump -xto inspect exports.
Reachability from roots
wasm-ld performs section garbage collection by default. The compiler places each function and each piece of data in its own section; the linker then
starts from a set of roots and keeps only sections reachable from them, following references — calls, address-taken function pointers, data that
references other data. Everything unreachable is dropped. The roots are: the entry point (_start for commands), explicitly exported symbols,
symbols marked as used or kept in the source, and a few runtime-required symbols such as the stack pointer and heap base.
That model explains both failure modes. A function called only from JavaScript is not reachable from any root unless it is exported, so the linker removes
it and JavaScript later finds instance.exports.compute undefined. Conversely, a large function that is reachable — through an exported function’s rarely
used error path, a function-pointer table, or an --export-dynamic flag that exports every visible symbol — cannot be removed, however unused it is in
practice.
Step 1 — export what JavaScript calls
Make every function JavaScript needs a root. In C, export by attribute or flag:
__attribute__((export_name("checksum")))
uint32_t checksum(const uint8_t *p, size_t n) { /* … */ }
clang --target=wasm32-unknown-unknown -nostdlib -O2 -Wl,--no-entry -Wl,--export=checksum src.c -o out.wasm
Emscripten uses EMSCRIPTEN_KEEPALIVE or -sEXPORTED_FUNCTIONS=_checksum; Rust uses #[no_mangle] pub extern "C" fn (wasm-bindgen’s #[wasm_bindgen] exports
for you). Library builds need --no-entry so the linker does not require _start. The export flags are covered in
exporting symbols with wasm-ld flags.
Step 2 — avoid exporting everything
--export-dynamic exports every symbol with default visibility — convenient for quick experiments, and costly: every exported function is a root, so
nothing reachable from any of them can be removed, and the export section itself grows. In C and C++, compile with -fvisibility=hidden and export
explicitly, or export a curated list with --export=name per function or --export-if-defined. Measure: switching from --export-dynamic to an explicit
list of a dozen exports commonly removes 20–40% of a library’s code.
Step 3 — keep symbols the linker cannot see being used
Some references are invisible to the linker: a function looked up by name from JavaScript at runtime, a function referenced only from a hand-written assembly stub, a symbol that a dynamically linked side module will import. Keep them explicitly:
__attribute__((used, export_name("on_message")))
void on_message(int id) { /* called from JS by name */ }
-Wl,--export= and Emscripten’s export settings do the same from the command line. -Wl,--undefined=sym forces the linker to treat a symbol as
referenced, which pulls in its archive member without exporting it.
Step 4 — find what keeps unwanted code alive
When a function you consider dead remains, find its referrer. wasm-ld --why-extract explains archive extraction; for code inside your own objects,
search the disassembly for calls and for its index in element segments (function tables), since address-taken functions are kept because a call_indirect
might reach them. twiggy paths <function> on the linked binary shows the call paths from roots to the function, often revealing a debug-only path, a
Display implementation used by a panic message, or a registration table that references every handler. Detailed map-file techniques are in
reading wasm-ld map files.
Step 5 — combine with wasm-opt
The linker removes unreachable sections; it does not analyse inside functions. wasm-opt with -O2 or higher performs further dead-code elimination,
inlining and constant propagation that can make more functions unreachable, and removes them. Run wasm-opt after linking for release builds. If an export
is unused by JavaScript, removing it from the list lets both the linker and wasm-opt drop everything only it needed, as discussed in
removing unused exports to shrink Wasm.
When to turn garbage collection off
--no-gc-sections disables section garbage collection, keeping everything in the inputs. It is occasionally useful for debugging — to check whether a
mysterious behaviour depends on code being removed — and for builds that must contain every function for later dynamic loading or introspection, such as
a main module that side modules will link against by name. For ordinary builds it only makes binaries larger. If you find yourself needing it to make a
build work, the real fix is almost always an export or a used attribute on the specific symbols that disappeared.
Function tables and indirect calls
Function pointers deserve special attention. Taking a function’s address places it in the indirect function table, and everything in the table is kept,
because any call_indirect might reach it. Codebases that register handlers in large static tables — command dispatchers, plugin registries, virtual
method tables in C++ — keep every registered function even when an application uses one. Splitting registration so that only the needed handlers are
referenced, or making optional features opt-in at compile time, allows the linker to drop the rest. In C++, virtual functions of classes that are never
instantiated may still be kept through vtables; -fwhole-program-vtables with LTO helps the compiler remove them. Indirect calls themselves are described
in
calling function pointers with call_indirect.
Compile-time choices that help the linker
The linker can only remove what is in separate sections and unreferenced. Compilers place each function in its own section by default for wasm targets
(-ffunction-sections and -fdata-sections behaviour), but source-level choices still decide how much is reachable. Optional features guarded by runtime
flags keep their code, because the linker cannot know the flag’s value; guarding them with compile-time features — Cargo features, C preprocessor macros —
removes them entirely from builds that do not enable them. Logging macros that format messages keep formatting code alive even when logging is disabled at
runtime; compile-time log levels remove it. Error types with rich Display implementations keep string formatting reachable from every fallible API;
returning error codes and formatting messages in JavaScript avoids that. These choices often matter more than any linker flag.
Debug versus release export lists
Debug builds sometimes need extra exports — inspection helpers, test hooks, memory statistics — that release builds should not ship. Keep two export lists, or guard debug exports with a compile-time feature, so release builds do not keep the helpers and everything they reference alive. A CI check that compares the release module’s export list with the documented API catches debug exports that leak into releases.
Expected output
instance.exports.checksum exists because it is exported explicitly; replacing --export-dynamic with a list of 12 exports shrinks the linked module from
480 KB to 305 KB; twiggy paths shows that the remaining large formatting function is reached only through a debug logging path, which is then compiled
out of release builds.
Gotchas
- Functions called only from JavaScript disappear. Export them or mark them used.
--export-dynamicin release builds. Keeps far too much. Export a curated list.- Missing
--no-entryfor libraries. The linker demands_start. Add the flag. - Function tables keeping everything. Address-taken functions stay. Reduce registrations.
- Disabling GC to fix a missing symbol. Export the symbol instead.
- Runtime feature flags. The linker keeps both branches. Use compile-time features.
Performance note
For a C library, the linked module was 480 KB with --export-dynamic, 305 KB with an explicit export list, and 241 KB after wasm-opt -O3. Link time was
unchanged by the switch.
Frequently Asked Questions
Does Rust’s linker invocation use --gc-sections?
Yes — rustc passes it by default for wasm targets, and wasm-bindgen then removes unused glue-related exports.
Why is my exported function empty or missing in Emscripten builds?
Emscripten exports need the leading underscore in EXPORTED_FUNCTIONS (_compute), or EMSCRIPTEN_KEEPALIVE in source.
Does dead-code elimination affect data?
Yes — unreferenced data sections are removed too, unless kept by used or referenced from kept code.
Can I see what the linker removed?
--print-gc-sections lists every removed section, which is useful when something you needed went missing.
Why does disabling a feature at runtime not shrink the binary? The linker cannot know runtime flag values. Use compile-time features to exclude code.
Should test hooks be exported in release builds? No — guard them with a compile-time feature so they and everything they reference are removed.
Related
- Linking C and Rust objects into one module — roots across languages.
- Reducing Wasm bundle size with wasm-opt — the next stage.
- Analyzing Wasm size with twiggy — paths and dominators.
- Linking Wasm objects with LTO — elimination across objects.
← Back to Linking Wasm Objects with wasm-ld