Linking Side Modules at Runtime
This guide answers one task: build a C or C++ program as a main module plus side modules that load on demand, so that rarely used code is not in the initial download — and understand what that costs.
Prerequisites
- [ ] Emscripten 3.1.60 or later.
- [ ] A program with a clear split: a core plus optional components.
- [ ] Understanding that this is code splitting, not sandboxing.
- [ ] A measurement of what the split actually saves, before committing to it.
Main and side
Dynamic linking produces two kinds of artifact. A main module contains the program’s core plus the
runtime, and owns the shared linear memory and the shared function table. A side module contains
additional code, is loaded later, and uses the main module’s memory and table rather than having its own.
emcc main.c -sMAIN_MODULE=1 -O2 -o main.js
emcc plugin.c -sSIDE_MODULE=1 -O2 -o plugin.wasm
const Module = await createMain();
await Module.loadDynamicLibrary('/plugin.wasm', { loadAsync: true });
Module.ccall('plugin_entry', 'number', ['number'], [42]);
At load time the side module’s functions are appended to the shared table, its data is placed in the shared
memory, and its undefined symbols are resolved against what the main module exports. From then on, calls
between the two go through call_indirect.
What the split saves, and what it costs
The saving is straightforward: code in a side module is not downloaded until something loads it. For a program with a large optional component — an export pipeline, a codec, an advanced editor — that can be a substantial fraction of the payload deferred past first use.
The costs are less obvious and worth stating plainly.
A main module compiled with MAIN_MODULE=1 cannot assume a closed world, so the optimiser keeps symbols it
would otherwise remove and cannot inline across what might be replaced. In practice a main module is
typically 10–30% larger than the same code compiled statically, which eats into the saving.
Every cross-module call is indirect, so the calls that used to be direct and inlinable are neither. For a boundary crossed occasionally that is irrelevant; for one inside a hot loop it is not.
And the runtime machinery for loading and relocating side modules is itself code in the main module.
static build 1.82 MB
main + side (split 60/40)
main 1.24 MB ← larger than 60% of the static build
side 0.71 MB
initial download 1.24 MB ← 32% less, not 40%
Deciding whether it is worth it
Three conditions should hold before choosing dynamic linking over the simpler alternatives.
The split must be real: the side module’s code genuinely unused in a typical session rather than merely logically separate. Deferring code that 90% of users need makes the experience worse for them and saves nothing.
The boundary must be cold. If the main module calls into the side module inside a loop, indirect calls and missing inlining will cost more than the download saved.
And the alternatives must not fit. A separate instance with its own memory, communicating through a small interface, gives the same deferred loading with isolation and without the main module’s optimisation penalty. That is usually the better structure, and it is what the plugin system pages describe.
Dynamic linking earns its place when the two halves genuinely share a heap — when the side module operates on the main module’s data structures through pointers, which is the case for a large ported codebase whose internal boundaries are not interface-shaped.
Symbol resolution and its failures
A side module’s undefined symbols are resolved at load time against the main module’s exports. The main module must therefore export everything any side module might need, and the standard way to get that wrong is dead-code elimination.
# keep symbols a side module will want, even though nothing in main calls them
emcc main.c -sMAIN_MODULE=1 \
-sEXPORTED_FUNCTIONS='["_malloc","_free","_shared_helper","_registry_add"]' \
-O2 -o main.js
Omitting one produces a load-time failure naming the missing symbol, which is at least clear. The more confusing failure is a symbol resolved to the wrong thing when two modules define the same name — the resolution is first-come, and the result is a program that runs and behaves oddly.
Error: bad export type for `_shared_helper`: undefined
Using MAIN_MODULE=2 and SIDE_MODULE=2 restricts the exported set to what you list, which produces much
smaller modules at the cost of maintaining the list. For a program with a stable interface between its
halves that is the right setting.
Loading at the right moment
A side module that loads when the user reaches the feature is the whole point, and the loading strategy deserves as much thought as the split itself.
Load on the interaction that implies the feature, not on the page that contains it. A user who opens an editor has not necessarily chosen to export; loading the export module when they click Export, with a progress indication, spends their bandwidth on something they asked for.
Preload on a strong signal. Hovering an export button, opening the menu that contains it, or finishing the work that usually precedes it are all reasonable triggers for starting the fetch early — the module is usually ready by the time it is needed, and nothing was blocked.
Load once and keep it. There is no unloading, so a second loadDynamicLibrary for the same path is either
a no-op or a duplicate depending on the runtime’s bookkeeping; memoise the promise as you would for any
other expensive initialisation.
const loaded = new Map();
function ensureModule(path) {
if (!loaded.has(path)) {
loaded.set(path, Module.loadDynamicLibrary(path, { loadAsync: true }));
}
return loaded.get(path);
}
exportButton.addEventListener('pointerenter', () => ensureModule('/export.wasm'));
exportButton.addEventListener('click', async () => {
await ensureModule('/export.wasm');
Module.ccall('run_export', null, [], []);
});
Report which modules a session loaded. Over a few weeks that tells you whether the split matched real usage — and a side module that almost every session loads should be back in the main module, where it can be optimised properly.
Expected output
A successful load appends to the table and resolves cleanly:
[wasm] main module instantiated: table size 412, memory 64 MB
[wasm] loading /plugin.wasm (712 kB)
[wasm] resolved 23 symbols, table now 489 entries
[app] plugin_entry(42) → 84
# a missing export, which is the common failure
Error: Could not load dynamic lib: /plugin.wasm
LinkError: WebAssembly.instantiate(): Import #14 "env" "_shared_helper": function import requires a callable
Load every side module in a test, even a trivial one that loads and immediately unloads. Symbol resolution failures are only discoverable by loading, and a side module used by one rarely exercised feature will otherwise fail for a user first.
Gotchas
- Treating it as isolation. Shared memory means a side module can read and corrupt everything.
- Symbols eliminated from the main module. List them in
EXPORTED_FUNCTIONS. - A hot cross-module boundary. Indirect calls with no inlining, in a loop.
- Measuring the split against the static build’s total. The main module is larger than its share; the real saving is smaller than the split suggests.
- Side modules never loaded in tests. Resolution failures reach users first.
- Mixing optimisation levels between main and side. Produces link-time surprises; build both the same way.
Performance note
For the 1.82 MB program above, splitting deferred 710 kB and grew the main module by 190 kB, for a net initial saving of 32%. A cross-module call cost about 5 nanoseconds more than the equivalent direct call and lost inlining, which for the boundary in question — called a few hundred times per session — was irrelevant. Load time for the side module was 48 ms including instantiation and relocation.
Frequently Asked Questions
Can side modules be unloaded? Not meaningfully. Their code stays in the table and their data in memory for the life of the instance. Plan for load-once rather than load-and-unload.
Does this work outside Emscripten? The underlying mechanisms are standard, but the relocation and symbol resolution machinery is Emscripten’s. Other toolchains either do not support it or implement their own scheme.
What about lazy compilation instead? Some engines compile functions on first call, which defers work without splitting the artifact. It reduces startup compilation rather than download size, so it addresses a different cost and composes with a split rather than replacing it.
Is the component model a better answer? For new code, increasingly yes: it gives typed interfaces between separately compiled units without sharing a heap, which is what most people actually want when they reach for dynamic linking.
Related
- Growing a Wasm table at runtime — what loading a side module does to the table.
- Loading untrusted plugins safely — the isolated alternative.
- Building Emscripten projects with CMake — wiring these builds into a real project.
← Back to Tables & Dynamic Linking