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.

A dynamically linked Wasm program One imported linear memory and one imported function table are shared by the main module and its side modules. Each module is position-independent and is given memory and table base offsets at load time. Symbols across modules are resolved through GOT globals by the loader. loader (JS or host) reads dylink.0, allocates, resolves main module (-pie) imports memory, table, GOT entries side modules (-shared) own data at __memory_base shared function table slots from __table_base shared linear memory one heap for every module

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.

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.

Loading a side module The main module calls dlopen. The loader fetches the side module, reads its dylink.0 section for memory and table sizes, allocates space in the shared memory and table, resolves its imports against loaded modules, instantiates it, and returns a handle from which dlsym retrieves function pointers. main module loader side module dlopen("plugin.wasm") read dylink.0 sizes allocate memory + table slots instantiate with bases + GOT dlsym("plugin_run") → pointer

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=1 in 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.

Static versus dynamic linking costs Relative size and benchmark time of a main module linked statically versus as a position-independent main module with dynamic linking enabled. relative to the static build static: size 1 × dynamic (-pie): size 1.1 × static: run time 1 × dynamic (-pie): run time 1.0 ×

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.

← Back to Linking Wasm Objects with wasm-ld