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.

Loading a plugin side module at runtime The main module owns memory and the function table. When the host calls dlopen, the loader fetches the side module, reserves space for its data in shared memory and slots in the table, instantiates it with memory and table bases and resolved symbols, and runs its constructors. dlsym then returns a function pointer the host calls directly. host calls dlopen plugin.wasm reserve data + table slots __memory_base, __table_base resolve symbols malloc, host APIs instantiate + constructors same memory/table dlsym → function pointer direct calls

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.

Dynamically linked plugins versus separate instances Dynamically linked side modules share memory, allocator and table with the host, allowing direct calls and pointer passing but giving no isolation between host and plugin. Separate instances or components each have their own memory, isolating plugins from the host at the cost of copying data across boundaries. dynamic linking (side modules) shared memory + allocator direct calls, pointers no isolation from host trusted plugins separate instances / components own memory per plugin data copied at boundary strong isolation untrusted plugins

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.

Per-call overhead of plugin designs Nanoseconds of overhead per call for a dynamically linked side module called through a function pointer, and for a separate instance called with a short string copied in and out. ns overhead per call side module (call_indirect) 2 ns separate instance + copies 300 ns

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.

← Back to Tables & Dynamic Linking