Building Shared Libraries for Wasm Dynamic Linking
This page answers one task: instead of one statically linked module, you need a main module plus separately loaded side modules — plugins, optional features, a large library shared by several programs — and you need to build them so a loader can link them together at runtime.
Prerequisites
- [ ] clang/wasi-sdk or Emscripten, with
wasm-ld. - [ ] C or C++ code that can be split into a main program and libraries with a clear interface.
- [ ] A loader: Emscripten’s dynamic linking runtime, or a custom one that implements the WebAssembly dynamic-linking conventions.
How Wasm dynamic linking works
WebAssembly has no native shared-library mechanism; dynamic linking is a convention built on standard features, documented in the tool conventions as the
dylink.0 custom section. All modules in a linked program share one linear memory and one function table, both imported from the host. Code is compiled
position-independent (-fPIC), because a side module does not know where its data will be placed in memory or where its functions will sit in the table
until load time. Instead of absolute addresses, it reads two imported globals — __memory_base and __table_base — and reaches other modules’ symbols
through a global offset table (GOT) of imported globals.
At load time, the loader reads each side module’s dylink.0 section to learn how much memory and how many table slots it needs, allocates them, sets the
base globals, resolves the module’s imports against the exports of already-loaded modules, and instantiates it. The main module is linked as a
position-independent executable (-pie) so it can participate in the same scheme.
Step 1 — compile position-independent code
Every object that goes into a side module or a PIE main module must be compiled with -fPIC:
clang --target=wasm32-unknown-emscripten -fPIC -O2 -c plugin.c -o plugin.o
With Emscripten, -sSIDE_MODULE and -sMAIN_MODULE set the right flags automatically. Without -fPIC, the compiler emits absolute addresses, and the
linker refuses to produce a shared library from those objects.
Step 2 — link a side module and a main module
With Emscripten:
emcc -O2 -sSIDE_MODULE=2 plugin.c -o plugin.wasm # side module, exports only what it must
emcc -O2 -sMAIN_MODULE=2 main.c -o app.js # main module with dynamic linking runtime
SIDE_MODULE=2 and MAIN_MODULE=2 export only symbols that are actually needed (listed or referenced), keeping modules small; levels =1 export
everything, which is convenient and much larger. With raw clang and wasm-ld, the equivalents are -shared for side modules and -pie for the main
module, together with --experimental-pic in some toolchain versions and --import-memory --import-table.
Step 3 — load side modules at runtime
Emscripten’s runtime loads side modules with dlopen, from C:
#include <dlfcn.h>
void *h = dlopen("plugin.wasm", RTLD_NOW);
int (*run)(int) = (int (*)(int))dlsym(h, "plugin_run");
int result = run(42);
The file must be available in Emscripten’s virtual file system (preloaded or fetched), or loaded asynchronously with emscripten_dlopen from a URL. For
custom loaders, the steps are: parse dylink.0, allocate memory and table space, create the base globals, satisfy imports, instantiate — the approach
outlined in
linking side modules at runtime.
Step 4 — inspect the dylink section
wasm-objdump -x plugin.wasm (or wasm-tools print) shows the dylink.0 section’s memory size, alignment, table size and needed libraries, and the
imports of __memory_base, __table_base and GOT.mem/GOT.func entries. When loading fails with unresolved symbols, compare the side module’s GOT
imports with the main module’s exports: every symbol the side module uses from the main program must be exported by it, which is why MAIN_MODULE=1 exports
everything and MAIN_MODULE=2 requires you to list what side modules need.
Step 5 — weigh the costs
Dynamic linking has real costs: position-independent code is slightly larger and slower (extra indirection through bases and the GOT), whole-program
optimisation across modules is impossible, and every module shares one memory and one allocator, so a bug in a plugin can corrupt the host’s data. For
plugin systems with untrusted code, separate instances with their own memories — as in the plugin guides — are safer. Dynamic linking pays off when a large
library is shared by several modules loaded together, or when optional features must be loaded later into the same address space for compatibility with
existing C code that uses dlopen.
Alternatives to dynamic linking
Before committing to side modules, consider the alternatives, which are usually simpler. Several independent modules, each with its own memory, loaded on
demand and communicating through JavaScript, give isolation and lazy loading without position-independent code; the cost is copying data between them. The
Component Model composes components with typed interfaces and separate memories, with tooling that checks compatibility at composition time, as described
in
composing two Wasm components.
Module splitting with wasm-split defers rarely used functions of one program into a second file loaded on first call, keeping a single memory and
static linking semantics. Dynamic linking is the right tool mainly for ports of existing native software whose architecture already depends on
dlopen — scripting-language interpreters loading extension modules, applications with native plugin APIs.
Threads and dynamic linking
Combining dynamic linking with threads adds constraints: every thread must see the same loaded modules, so dlopen in one thread must be reflected in all
workers. Emscripten supports this with additional runtime machinery that synchronises loaded libraries across workers, at a cost in startup and complexity.
Load side modules before starting threads where possible, and test thoroughly, since timing-dependent loading bugs are hard to reproduce.
Versioning the interface between modules
Dynamically linked modules depend on each other’s symbols by name, with no type checking beyond the function signature in the import. Changing a
function’s behaviour, a struct’s layout or a global’s meaning in the main module silently breaks side modules built against the old version — the
WebAssembly equivalent of an ABI break in native shared libraries. Treat the set of symbols side modules use as a versioned ABI: keep struct layouts
stable or versioned, add new functions rather than changing existing ones, and include a version check at load time — an exported abi_version() in the
main module that side modules call during initialisation, refusing to run against an incompatible host. Build side modules in the same CI as the main
module and test them together, so incompatibilities surface as failing tests. Native projects that already have a stable plugin ABI can usually carry it
over unchanged; projects without one should define it before shipping side modules to third parties.
Debugging dynamically linked programs
Debugging spans several modules, each with its own debug information. DevTools shows every instantiated module separately in the Sources panel, and DWARF
debugging works per module when each was built with -g. Stack traces cross module boundaries and include each module’s name, which helps attribute
crashes. Memory corruption is the hardest class of bug, because any module can write anywhere in the shared memory; AddressSanitizer works with Emscripten’s
dynamic linking when every module is built with it, and should be the first tool when a crash depends on which plugins are loaded.
Expected output
plugin.wasm contains a dylink.0 section requesting 16 KB of memory and 12 table slots; the main module loads it with dlopen, resolves plugin_run
with dlsym, and calls it; and the build with MAIN_MODULE=2 exports only the five symbols the plugin needs.
Gotchas
- Objects without
-fPIC. The linker refuses to build a shared library. Compile everything position-independent. MAIN_MODULE=1in production. It exports everything and is large. Use level 2 with an explicit list.- Unresolved symbols at load. The main module does not export what side modules need. Export them.
- Shared memory with untrusted plugins. A plugin can corrupt the host. Use separate instances for isolation.
- Loading after threads start. Extra synchronisation is needed. Load early.
Performance note
The position-independent main module was 7% larger and ran a compute benchmark 3% slower than the statically linked version. Loading a 200 KB side module
with emscripten_dlopen took about 15 ms including fetch from cache and compilation.
Frequently Asked Questions
Can Rust produce Wasm side modules?
Not conveniently today; Rust’s wasm targets do not support -shared output in the same way. Use separate modules or components.
Is dylink.0 a standard? It is a tool convention shared by LLVM, Emscripten and other loaders, not part of the core specification.
Do browsers cache side modules?
Yes — they are ordinary .wasm files and benefit from HTTP and code caching.
Can side modules have their own memory? Not in this scheme; the point is a shared address space. Separate memories mean separate, independently linked modules.
How do I prevent ABI breaks between main and side modules? Treat the shared symbols as a versioned ABI, export a version function, and test side modules against each main-module build in CI.
Related
- Tables & dynamic linking — tables and indirect calls underneath.
- Implementing a plugin loader with dynamic linking — a loader in practice.
- Importing memory from the host with import-memory — shared memory setup.
- Splitting a Wasm module for lazy loading — the static alternative.
← Back to Linking Wasm Objects with wasm-ld