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 print or wasm-objdump -x to 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.

Why a function is kept or dropped A function is kept if it is exported, is the entry point, is referenced from kept code or data, or is marked used. Otherwise section garbage collection drops it. Functions needed only by JavaScript must therefore be exported, and unwanted code stays only because something still reaches it. Is the function reachable from a root? exported or entry point kept as a root referenced from kept code/data kept transitively no references dropped by --gc-sections

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.

Exporting a curated list versus exporting everything A curated export list makes only the API functions roots, so section garbage collection removes everything else unreachable. Export-dynamic makes every visible symbol a root, keeping far more code and a larger export section. curated --export list only API functions are roots unreachable code removed small export section release builds --export-dynamic every visible symbol is a root little can be removed large export section experiments only

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-dynamic in release builds. Keeps far too much. Export a curated list.
  • Missing --no-entry for 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.

Linked size by export strategy Kilobytes of linked WebAssembly for the same C library with export-dynamic, with an explicit list of twelve exports, and with the explicit list followed by wasm-opt. KB of .wasm --export-dynamic 480 KB explicit export list 305 KB explicit list + wasm-opt -O3 241 KB

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.

← Back to Linking Wasm Objects with wasm-ld