WebAssembly as a module, not a monolith

WebAssembly is often discussed as a performance play or as a migration path for existing C++ apps. But there's a third framing that matters more in practice: wasm as a dependency you pull into an existing web project, like any other module. That framing carries a big question: what does the integration workflow look like when your app is already JavaScript, CSS, and a build system?

The answer, using C/C++ and Emscripten as an example, is surprisingly clean. The build artifacts — a .js glue file and a .wasm binary — can slot into your normal npm-based toolchain as just another module and another asset.

A reproducible Emscripten environment with Docker

C/C++ libraries are often written against the OS they run on, which makes environment consistency critical when cross-compiling to wasm. Docker gives you a virtualized Linux environment with Emscripten and its dependencies pre-installed. If something's missing, you install it in the container without touching your machine's global state. If the container breaks, you discard it and rebuild. If it works once, it will keep working identically.

The trzeci/emscripten image from the Docker Registry covers this case well.

Fitting Emscripten into the npm lifecycle

Most web projects kick off with npm install && npm run build. The Emscripten step slots in naturally before that: you produce the wasm artifacts first, then let bundlers like webpack or rollup treat the .js glue as normal JavaScript and the .wasm file as a heavier binary asset.

Adding an npm run build:emscripten task that invokes Docker keeps the environment stable:

docker run --rm -v $(pwd):/src trzeci/emscripten ./build.sh

Here, --rm removes the container after completion so stale images don't accumulate, and -v $(pwd):/src bind-mounts your current directory into /src inside the container, with file changes mirrored both ways.

Inside build.sh, a few things matter. set -e makes the script abort on the first error, so the last output is either success or the failing cause. The export statements define CFLAGS, CXXFLAGS, and LDFLAGS, giving the C compiler, C++ compiler, and linker the same optimization settings through a single OPTIMIZE variable.

Optimization levels:

  • -O0: No optimization, no dead-code elimination, unminified JS output — best for debugging.
  • -O3: Heavy optimization for pure performance.
  • -Os: Performance first, size second.
  • -Oz: Aggressive size reduction, performance sacrificed if needed.

For web use, -Os is usually the right call.

Key Emscripten flags

emcc acts as a drop-in replacement for GCC or clang, so most familiar compiler flags work. The -s flag requires special attention — it sets Emscripten-specific options defined in settings.js. The most relevant ones for web work:

  • --bind — enables Embind for binding C++ to JS.
  • -s STRICT=1 — drops deprecated build options, keeping builds forward-compatible.
  • -s ALLOW_MEMORY_GROWTH=1 — allows the wasm module's 16MB default memory to grow on allocation failure; without it, exhausted memory crashes the module instead.
  • -s MALLOC=... — picks the malloc() implementation. emmalloc is small and fast for Emscripten-specific use; dlmalloc is the full-featured implementation needed mainly for many small allocations or threading.
  • -s EXPORT_ES6=1 (requires -s MODULARIZE=1) — emits an ES6 module with a default export that any bundler can consume.

Two debug-adjacent flags worth knowing:

  • -s FILESYSTEM=0 — Emscripten's filesystem emulation adds about 70kB of glue code when the compiler's analysis thinks it's needed. If that analysis is wrong, force it off.
  • -g4 — include debug info in the .wasm file and emit a source map. Useful when you need to step through C++ code in DevTools.

A minimal working example

To verify the setup, write a small my-module.cpp that calls EM_JS to bridge into JavaScript, then load the resulting module from a simple index.html. Running npm run build and serving the directory shows the wasm module's output in the DevTools console.

Getting C/C++ libraries into node_modules

Real projects often depend on third-party C/C++ code that isn't published on npm and has no package.json. Managing that source as an npm dependency still works — via napa, which installs any git repository into your node_modules folder.

First, install napa and register it as an install script; then add the library's repo URL to your package.json so npm install clones it into node_modules. Take libvpx as a concrete example: it's a C++ library for encoding VP8 images, the codec behind .webm files.

Building libraries with emconfigure/emmake

libvpx builds via configure and make. Emscripten's emconfigure and emmake wrappers force those tools to use Emscripten's compiler, so the entire autotools-style build chain works without intervention.

To actually link the library into your code, you need to do two things. First, tell the compiler where the headers live with -I flags, since the #include "vpxenc.h" directive won't resolve on its own. Second, give the compiler the library artifact itself. So your build script's CFLAGS and LDFLAGS grow to include -I../node_modules/libvpx and the path to the compiled .a file, respectively.

Build throughput is a real concern with this approach. libvpx compiles both encoder and decoder for VP8 and VP9 on every build, even when the module hasn't changed. One fix: use an environment variable to skip rebuilding the libvpx library after it's been built once.

./build.sh [SKIP_LIBVPX=1]

The eval command here lets build parameters set environment variables, and the test command checks if $SKIP_LIBVPX is set — if it is, the libvpx rebuild is skipped while the main module still compiles. This approach keeps build times sane during iterative development on your own module.

Extending the Build Image

Real-world libraries often pull in additional tools during their build. The base Docker image won’t have everything you need. For instance, generating libvpx’s documentation requires doxygen, which isn’t pre-installed. While you could install it with apt inside your build.sh, that would re-download the package on every single build and make offline work impossible.

The cleaner approach is to create your own Docker image with a Dockerfile. You only need a handful of commands—FROM, RUN, and sometimes ADD—to get started:

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen

Here, FROM sets your starting point as the trzeci/emscripten image you’ve been using, and RUN executes shell commands inside the container, baking the results into a new image. To ensure this custom image is built before your build.sh runs, adjust your package.json scripts:

{
    // ...
    "scripts": {
    "build:dockerimage": "docker image inspect -f '.' mydockerimage || docker build -t mydockerimage .",
    "build:emscripten": "docker run --rm -v $(pwd):/src mydockerimage ./build.sh",
    "build": "npm run build:dockerimage && npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

(A complete set of files is available in this gist.)

This builds your custom Docker image only when it doesn’t already exist, then proceeds as before. Now, with doxygen available in the container, your builds will also produce the libvpx documentation.

Leveraging Docker’s Cache

You can push this pattern further by relying on Docker’s layer-caching mechanism. Docker executes each command in a Dockerfile step by step, storing the result of every step as an intermediate image, or “layer.” If a step’s command is unchanged, Docker reuses the cached layer instead of re-running it.

This is valuable for expensive steps like compiling libvpx. Instead of manually ensuring it isn’t rebuilt by your build.sh, you can move the build instructions into the Dockerfile. Docker will then skip the compilation if the associated layer is still valid:

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen git && \
    mkdir -p /opt/libvpx/build && \
    git clone https://github.com/webmproject/libvpx /opt/libvpx/src
RUN cd /opt/libvpx/build && \
    emconfigure ../src/configure --target=generic-gnu && \
    emmake make

(A complete set of files is available in this gist.)

Note that since docker build doesn’t have bind mounts, you’ll need to manually install git and clone libvpx inside the Dockerfile. An added benefit is that this approach removes the need for napa altogether.

C and C++ don’t fit npm’s model naturally, but with Docker for isolation and these build-time tricks, you can integrate them comfortably. This workflow won’t solve every project’s problems, but it provides a solid, adaptable starting point.