Implementing a Plugin Loader with Dynamic Linking
This page answers one task: a C or C++ application compiled to WebAssembly has a plugin architecture natively — shared libraries loaded with dlopen — and you
want the same in the browser: a host module that loads plugin modules at runtime, calls their functions directly, and lets them use the host’s memory, allocator
and APIs.
Prerequisites
- [ ] An Emscripten toolchain (its dynamic linking support is the most complete for browsers).
- [ ] A host program and plugins written in C or C++.
- [ ] A defined plugin ABI: the functions a plugin exports and the host functions it may call.
How dynamic linking works in Wasm
WebAssembly modules are self-contained by default: each has its own memory, table and functions. Dynamic linking makes several modules behave like one program,
the way shared libraries do natively. The main module owns the linear memory and the function table and exports its symbols. Side modules are compiled as
position-independent code (PIC): they do not assume fixed addresses for their data or functions. When a side module is loaded, the loader reserves space for its
data in the shared memory and slots for its functions in the shared table, then instantiates it with imports that tell it where those are (__memory_base,
__table_base) and that resolve the symbols it needs from the main module or other side modules.
The result: a plugin can call malloc from the host, pass pointers back and forth, and the host can call plugin functions through function pointers obtained
with dlsym — all within one address space.
Step 1 — build the main module and side modules
# host (main module): exports its symbols for plugins, supports dlopen
emcc host.c -O2 -sMAIN_MODULE=2 -sEXPORTED_FUNCTIONS=_main,_host_log,_malloc,_free -o host.js
# plugin (side module): position-independent, no libc of its own
emcc plugin_upper.c -O2 -sSIDE_MODULE=2 -o plugin_upper.wasm
MAIN_MODULE=2 and SIDE_MODULE=2 export only symbols explicitly requested (rather than everything, as =1 does), keeping modules smaller; list every host symbol
plugins may use. Side modules link against the main module’s libc and allocator, so plugins must not bring their own.
Step 2 — define a plugin ABI
Give each plugin one well-known entry point that registers its capabilities with the host:
// plugin_api.h — shared by host and plugins
typedef struct {
int api_version;
const char* name;
int (*transform)(const char* in, char* out, int out_len);
} plugin_info;
typedef const plugin_info* (*plugin_entry_fn)(void);
#define PLUGIN_API_VERSION 2
// plugin_upper.c
#include "plugin_api.h"
#include <ctype.h>
static int upper(const char* in, char* out, int n) { int i = 0; for (; in[i] && i < n - 1; i++) out[i] = toupper(in[i]); out[i] = 0; return i; }
static const plugin_info INFO = { PLUGIN_API_VERSION, "upper", upper };
const plugin_info* plugin_entry(void) { return &INFO; }
Versioning the ABI in the info struct lets the host reject plugins built for an incompatible version instead of crashing.
Step 3 — load plugins with dlopen and dlsym
In the browser, the plugin file must be available in Emscripten’s virtual file system (or loaded asynchronously) before dlopen:
#include <dlfcn.h>
#include "plugin_api.h"
const plugin_info* load_plugin(const char* path) {
void* h = dlopen(path, RTLD_NOW);
if (!h) { host_log(dlerror()); return NULL; }
plugin_entry_fn entry = (plugin_entry_fn)dlsym(h, "plugin_entry");
if (!entry) { host_log("missing plugin_entry"); return NULL; }
const plugin_info* info = entry();
if (info->api_version != PLUGIN_API_VERSION) { host_log("incompatible plugin"); return NULL; }
return info;
}
Fetch plugin files from JavaScript and write them into the file system (FS.writeFile) or use Emscripten’s async loading APIs, since synchronous loading of
large modules is not allowed on the browser main thread.
Step 4 — understand the trust model
A dynamically linked plugin runs inside the host’s address space: it can read and write all of the host’s memory, call any exported host function, and corrupt the heap. Dynamic linking is therefore for trusted plugins — your own modules, vetted partners — not for user-supplied code. For untrusted plugins, use separate instances (each with its own memory) or the Component Model, and accept the cost of copying data across the boundary.
Step 5 — manage growth and unloading
Loading a plugin grows the shared table and may grow memory for its data. dlclose is supported but cannot shrink memory or remove functions from the table in
ways that free resources fully; plan for plugins to stay loaded for the session, and recycle the whole application instance if many plugins are loaded and
unloaded over time.
Performance characteristics
Calls from host to plugin through function pointers are call_indirect calls — slightly more expensive than direct calls but far cheaper than calls across
instance boundaries with data copying. Position-independent code adds a little overhead for accessing globals and data (an addition to a base address), usually
negligible. Loading a side module costs fetching, compiling and relocation — tens of milliseconds for small plugins.
Diagnosing load failures
Dynamic linking fails in a few recognisable ways. Undefined symbol errors at dlopen mean the plugin references a host function the main module did not export —
add it to the main module’s export list (with MAIN_MODULE=2, nothing is exported unless listed). Duplicate symbol or unexpected behaviour can mean two modules
define the same global, for example both linking a static copy of a library; make sure shared libraries are linked once, into the main module. Signature mismatch
traps on the first call mean host and plugin were compiled against different versions of a header, so a function’s type changed; the API version check catches
most of these, if every ABI change bumps the version. Emscripten’s -sASSERTIONS and -sDYLINK_DEBUG (in versions that support it) print detailed loader
information, which is the quickest way to see which symbol failed to resolve.
Loading plugins asynchronously in the browser
Browsers do not allow synchronous compilation of large modules on the main thread, so a C-level dlopen of a plugin that is not yet compiled cannot block until it
arrives. Emscripten provides asynchronous loading (emscripten_dlopen with callbacks) and preloading options: fetch plugin files in JavaScript, compile them
ahead of time, and register them with the loader before C code calls dlopen, which then completes immediately. Running the host in a worker relaxes some
restrictions, since workers may block. Design the host’s plugin discovery as an asynchronous step at startup — read a manifest, fetch and preload plugins — so
the C code that later calls dlopen never waits on the network.
Building plugins outside the host’s repository
Third-party plugins need the host’s headers, the exact Emscripten version used to build the host, and the list of exported symbols. Publish a small SDK containing these and a template project; mismatched toolchain versions between host and plugins are a common source of subtle ABI breakage.
Expected output
The host loads plugin_upper.wasm with dlopen after fetching it into the virtual file system, obtains plugin_entry with dlsym, checks the API version, and
calls transform directly with pointers into shared memory; plugins use the host’s malloc and host_log; incompatible plugins are rejected with a message; and
untrusted user scripts use a separate sandbox instead.
Gotchas
- Plugins bringing their own libc. Two allocators corrupt memory. Link side modules against the host.
- Unversioned plugin ABIs. Old plugins crash new hosts. Version the info struct.
- Synchronous loading on the main thread. Not allowed for large modules. Load asynchronously.
- Dynamic linking for untrusted code. No isolation. Use separate instances.
- Expecting dlclose to free everything. Tables and memory do not shrink. Plan for long-lived plugins.
- Different Emscripten versions for host and plugins. Subtle ABI breaks. Ship an SDK pinning the toolchain.
Performance note
Calling a plugin’s transform through a function pointer cost about 2 ns per call overhead; the same plugin as a separate instance with string copying across the
boundary cost about 300 ns per call for short strings.
Frequently Asked Questions
Does Rust support Wasm dynamic linking? Support is limited and evolving; Emscripten’s C/C++ path is the most mature. Rust plugins more commonly use separate instances or components.
Can plugins call each other? Yes, if they resolve each other’s symbols; keep dependencies explicit.
Do side modules work with threads? Emscripten supports dynamic linking with pthreads, with extra constraints; test carefully.
What is MAIN_MODULE=1?
It exports all symbols, making the main module much larger; prefer =2 with an explicit list.
Why does dlopen fail with an undefined symbol? The main module did not export that function; with MAIN_MODULE=2, add it to the export list.
How should third-party plugin authors build? With an SDK containing the host’s headers, the exact Emscripten version and the exported symbol list.
Can dlopen wait for a plugin download? Not on the browser main thread; preload and compile plugins asynchronously before C code calls dlopen.
Related
- Building shared libraries for Wasm dynamic linking — linker details.
- Linking side modules at runtime — the mechanism.
- Designing a Wasm plugin interface — interface design.
- Loading untrusted plugins safely — the isolated alternative.
← Back to Tables & Dynamic Linking