C/C++ to Wasm with Emscripten
You have a body of working C or C++ — a codec, a physics solver, an image pipeline, a parser — and
you want it running in the browser at near-native speed without rewriting it in JavaScript.
Emscripten is the toolchain that makes that possible: a Clang/LLVM front end plus a Binaryen back end
that compiles native source to a .wasm binary and emits the JavaScript glue that loads it,
allocates its linear memory, and emulates the slice of POSIX your code expects. The challenge is
not getting something to compile — it is getting a small, fast module with a clean JS boundary and
no surprise traps at runtime. This area walks the full pipeline, from emsdk install to a verified,
optimized binary you can ship.
Emscripten earns its place by being a complete, POSIX-shaped environment rather than a bare compiler.
It ships an emulated libc, a virtual filesystem, an setjmp/longjmp and C++ exceptions story, and
the JavaScript runtime that glues all of it to the browser’s event loop. That breadth is exactly what
makes porting an existing codebase tractable — but it also means the defaults favor compatibility over
size and speed, so an unoptimized first build is large and slow by design. The work in this area is
learning which flags to flip, where the JS boundary leaks performance, and how to confirm a build is
correct before it ships. Wasm is not a faster JavaScript; it is a CPU-bound execution target that wins
on deterministic compute and loses time on every uncontrolled crossing of the JS/Wasm boundary, so
deliberate pipeline design matters more here than in any pure-JavaScript build.
Prerequisites
Before you run a single emcc command, confirm your environment. Each item below is a real
checkpoint that prevents a class of failure later.
- [ ] emsdk installed and activated — clone
emscripten-core/emsdk, then./emsdk install latestand./emsdk activate latest. The SDK pins matching LLVM, Binaryen, and Node versions so builds are reproducible. - [ ]
emcc --version≥ 3.1.50 — older versions differ in default flags (notably around-sMODULARIZEoutput andsetjmp/longjmphandling). Pin the exact version in CI. - [ ]
source ./emsdk_env.shin the current shell — this injectsemcc,em++,emcmake, andemmakeontoPATH. Without it you getemcc: command not found. - [ ] CMake ≥ 3.13 if you build with
emcmake cmake— earlier versions mishandle the Emscripten toolchain file. - [ ] A modern browser or Node ≥ 16 to run the output — streaming instantiation and ESM glue assume a current runtime.
Verify the toolchain in one line:
emcc --version && node --version && cmake --version
How the emcc pipeline works
emcc is not a thin wrapper around Clang. It is a driver that takes your source through several
distinct stages and emits two coupled artifacts: the .wasm module and a .js loader that knows how
to instantiate it. Knowing where each transformation happens tells you which flag fixes which problem.
Clang lowers your translation units to LLVM IR; LLVM’s optimizer and the wasm32 back end produce
object files; wasm-ld links them with Emscripten’s libc (a musl fork) and your static archives;
and Binaryen’s wasm-opt runs the Wasm-level size and speed passes. The .js glue is generated in
parallel — it carries the runtime that sets up linear memory, the virtual filesystem, the exported
function table, and the async instantiation handshake the browser requires. The same end-to-end
shape underlies every native-to-Wasm route; the broader lifecycle is mapped in
Compilation Pipelines & Toolchain Setup.
The practical consequence of this two-artifact model is that you cannot treat the .wasm in
isolation. The binary imports functions — env.emscripten_resize_heap, filesystem syscalls, abort
— that only the generated JavaScript provides. Swapping in a hand-written loader means re-implementing
that import surface, which is why most projects keep the glue and configure its behavior through flags
rather than replacing it. The flags you pass to emcc are really instructions to four different
subsystems at once: Clang code generation, the wasm-ld link step, the Binaryen post-pass, and the
JavaScript runtime template. Knowing which stage a flag targets is what turns flag-tuning from
guesswork into a deliberate process — -O3 is an LLVM and Binaryen instruction, -sMODULARIZE only
rewrites the JS template, and -sALLOW_MEMORY_GROWTH changes both the binary’s memory declaration and
the runtime’s grow logic.
Build-system integration
A single-file emcc invocation is fine for a demo, but real libraries ship a build system. Emscripten
provides drop-in wrappers that override the compiler and linker variables so your existing build files
keep working. emmake make runs your Makefile with CC and CXX pointed at emcc/em++;
emcmake cmake -B build configures a CMake project with the Emscripten toolchain file, which sets the
sysroot, the wasm32-unknown-emscripten triple, and the cross-compilation flags so find_package and
target detection behave. The pattern is to wrap your normal configure-and-build commands rather than
rewrite them:
emcmake cmake -B build -DCMAKE_BUILD_TYPE=Release
emmake cmake --build build -j$(nproc)
For CI reproducibility and developer parity, pin the toolchain in a container so every build uses the
exact same LLVM, Binaryen, and Node versions. The official emscripten/emsdk images are tagged by
version:
FROM emscripten/emsdk:3.1.74
WORKDIR /app
COPY . .
RUN emcmake cmake -B build -DCMAKE_BUILD_TYPE=Release && emmake cmake --build build -j$(nproc)
Caching the ~/.emscripten_cache directory between CI runs is the single biggest build-time win —
Emscripten compiles libc and the standard library on first use, and reusing that cache cuts cold-build
time by more than half. The wider automation story — size gates, artifact caching, matrix builds —
lives in cross-platform build automation.
Step-by-step workflow
Each step below is a runnable command. Start from the simplest possible build and add flags only when a measurement or an error justifies them.
-
Bootstrap and activate the SDK. Do this once per machine; re-
sourceper shell.git clone https://github.com/emscripten-core/emsdk.git cd emsdk && ./emsdk install latest && ./emsdk activate latest source ./emsdk_env.sh -
Compile a minimal C file to a self-contained HTML harness. The
.htmltarget generates a page plus glue, which is the fastest way to confirm the toolchain works end to end.emcc hello.c -o hello.html emrun --no_browser --port 8080 hello.html # serve with correct MIME types -
Emit a modular ES module instead of an HTML shell.
-sMODULARIZEwraps the loader in a factory function so it imports cleanly;-sEXPORT_ES6makes it a real ESMexport default.emcc src/core.c -o dist/core.mjs \ -O2 \ -sMODULARIZE=1 \ -sEXPORT_ES6=1 \ -sEXPORT_NAME=createCoreModule \ -sENVIRONMENT=web,worker -
Export the C symbols you call from JavaScript. Names need a leading underscore, and you must keep
ccall/cwrap(and the heap accessors) in the runtime, or they get tree-shaken away.emcc src/core.c -o dist/core.mjs \ -sMODULARIZE=1 -sEXPORT_ES6=1 -sEXPORT_NAME=createCoreModule \ -sEXPORTED_FUNCTIONS=_compute,_malloc,_free \ -sEXPORTED_RUNTIME_METHODS=ccall,cwrap,HEAPU8 -
Optimize and validate the final binary. Build at the optimization level you measured as best, then confirm the module is well-formed before shipping.
emcc src/core.c -o dist/core.mjs -O3 -sMODULARIZE=1 -sEXPORT_ES6=1 wasm-validate dist/core.wasm && wasm-objdump -h dist/core.wasm
Calling C from JavaScript: the binding layer
The module is useless until JavaScript can drive it. Emscripten gives you a synchronous marshaling
layer — cwrap builds a reusable typed wrapper around an exported C function, ccall invokes one
ad hoc — and direct heap access through views like HEAPU8. The integer you pass for a buffer is a
pointer: a byte offset into the module’s linear memory, exactly as described in
JS/Wasm Interop & Memory Management.
import createCoreModule from "./dist/core.mjs";
const Module = await createCoreModule(); // async instantiation handshake
// Wrap a C function: int compute(int a, int b)
const compute = Module.cwrap("compute", "number", ["number", "number"]);
console.log(compute(10, 20)); // -> 30
// Hand a whole buffer across the boundary by pointer, not by argument
const input = new Uint8Array([4, 8, 15, 16, 23, 42]);
const ptr = Module._malloc(input.length); // allocate in linear memory
Module.HEAPU8.set(input, ptr); // copy bytes in at the offset
const checksum = Module.ccall("sum_bytes", "number", ["number", "number"], [ptr, input.length]);
Module._free(ptr); // caller owns the allocation
console.log(checksum);
Three binding strategies coexist, and choosing between them is a real decision. ccall and cwrap
are the lightest: they marshal primitive types (number, string, array, boolean) for a C
function with a flat signature, with cwrap returning a reusable wrapper and ccall doing a one-shot
call. Direct heap access — _malloc, HEAPU8.set, _free — is the lowest-level path and the one you
reach for when moving large buffers, because it lets you write bytes into linear memory once and pass
only a pointer, avoiding per-element boundary crossings. The third strategy is embind, which is the
right tool for C++ rather than C.
A subtle but important rule governs all of them: instantiation is asynchronous. The factory function
returns a promise, and every exported function and heap accessor is undefined until it resolves.
Calling Module._compute before await createCoreModule() finishes aborts with a “called before
runtime initialization” error. For setup that must run at a specific point in the lifecycle, hook
Module.onRuntimeInitialized or Module.preRun rather than racing the promise.
For C++ you usually want richer bindings — classes, value objects, and overloaded functions — which is
what embind (EMSCRIPTEN_BINDINGS) provides. It maps C++ constructors to JavaScript new, methods
to methods, and std::string/std::vector to their JS equivalents, at the cost of a per-call
conversion layer. That tradeoff is covered in depth in
binding C++ libraries with embind.
Once the factory function and .wasm exist, packaging them for an application bundler is the job of
ESM bindings & module generation,
which makes the MODULARIZE factory tree-shakeable and dynamically importable.
To keep the main thread responsive during long computations, instantiate the module inside a Web
Worker. The worker imports the same factory, runs the compute call off the UI thread, and posts the
result back; pairing that with transferable ArrayBuffer objects keeps the hand-off zero-copy. The
shared-memory variant of this — one SharedArrayBuffer visible to every worker — is where Wasm
threading begins, building on the same boundary the interop guide above lays out.
Optimization flags & tradeoffs
The defaults are tuned for safety, not size or speed. These are the flags that actually move the numbers, with the cost each one carries.
-O2vs-O3vs-Oz.-O2is the sane production default — good inlining, modest binary.-O3adds aggressive loop unrolling and inlining that typically buys single-digit-percent runtime on hot numeric loops while inflating the binary 15–30%.-Ozoptimizes purely for size and is the right choice when download weight dominates; expect it to be a few percent slower than-O2.-sMODULARIZE=1. Wraps the glue in a factory so multiple instances and clean imports are possible. Without it the loader pollutes the global scope and fights with bundlers. Almost always on.-sALLOW_MEMORY_GROWTH=1. Letslinear memorygrow past the initial reservation at runtime. The cost: every memory access goes through a bounds-checked indirection, and a grow detaches and replaces the backingArrayBuffer, invalidating any JS typed-array view you held. Enable it only when input sizes are genuinely unbounded; otherwise set a fixed-sINITIAL_MEMORYand leave growth off for predictable performance.--closure 1. Runs Google Closure Compiler over the JS glue, often shrinking it 30–50%. It is picky about external symbols — anything you reference from outside must be quoted (Module["foo"]) or annotated, or Closure renames it and breaks your code at runtime.-msimd128. Enables WebAssembly SIMD. For vectorizable loops (image filters, FFTs, dot products) this is a 2–4× win, but it requires a runtime that supports the SIMD proposal — feature- detect before loading.-sASSERTIONS=0. The production default. Assertions add runtime stack-trace and bounds checks worth roughly 5–10% overhead; keep them at=1or=2only during development.
A representative size progression for a mid-sized C++ library: -O0 ~ 2.1 MB, -O2 ~ 480 KB,
-Oz ~ 310 KB, and -Oz --closure 1 plus wasm-opt -Oz ~ 240 KB. Deeper passes and Binaryen
internals live in Wasm optimization flags & size reduction.
Memory layout and the growth tradeoff
WebAssembly gives your module one contiguous linear memory buffer, addressed from offset zero, and
every load/store is bounds-checked against its current length. Emscripten lays the C stack, the
heap, and the static data segments inside that single buffer, which is why a stack overflow and a heap
overrun both surface as the same memory access out of bounds trap. The initial reservation defaults
to 16 MB; -sINITIAL_MEMORY raises it and -sMAXIMUM_MEMORY caps it.
The decision that most affects runtime behavior is whether to allow growth. With
-sALLOW_MEMORY_GROWTH=0 (the default with a fixed INITIAL_MEMORY), the buffer never moves, every JS
typed-array view over it stays valid for the lifetime of the instance, and there is no per-access
indirection — the fastest, most predictable configuration. With growth enabled, a memory.grow can
allocate a fresh, larger ArrayBuffer and detach the old one, which silently zero-lengths any view you
were holding. The rule of thumb: reserve a fixed memory budget for predictable workloads and only
enable growth when input sizes are genuinely unbounded, re-reading Module.HEAPU8 after any call that
might allocate.
Gotchas & failure modes
These are the errors you will actually hit, with the diagnosis and the fix.
undefined symbol: fork (or socket, mmap). Emscripten’s libc has no process model and no raw
sockets. The fix is to refactor those calls behind #ifdef __EMSCRIPTEN__ and route them to async
JS equivalents — the systematic approach is in
migrating legacy C code to WebAssembly.
RuntimeError: memory access out of bounds. A pointer escaped the bounds of linear memory —
usually a stale pointer after a memory grow, or a write past an allocation. Rebuild with
-sSAFE_HEAP=1 -sASSERTIONS=2, which surfaces the exact offending access with a stack trace instead
of a silent corruption.
Aborted(native function 'compute' called before runtime initialization). You called an export
before the async instantiation finished. Always await createCoreModule() (or hook
Module.onRuntimeInitialized) before touching any _-prefixed function or heap accessor.
function signature mismatch trap when calling through cwrap. The declared argument types do
not match the C signature — for example passing a JS string where the C function expects a char*
pointer. Allocate the string into linear memory and pass the pointer, or use ccall with the
"string" type which does the copy for you.
Closure renames break the build. After --closure 1, runtime TypeError: X is not a function
means a symbol you reference externally got minified. Reference it as Module["X"] and add it to
-sEXPORTED_RUNTIME_METHODS.
Verification
Never trust a build you have not validated. After every compile, run these checks:
wasm-validate dist/core.wasm # structural validity — exits non-zero on malformed binary
wasm-objdump -x dist/core.wasm | head -40 # imports, exports, memory limits at a glance
wasm-objdump -h dist/core.wasm # section sizes — spot a bloated "code" or "data" section
wasm-objdump -x shows the exported function names (confirm your _compute is present), the declared
memory initial/maximum pages, and the import list (anything unexpected there is a syscall you did
not mean to pull in). In the browser, the DevTools Network panel confirms the .wasm is served with
Content-Type: application/wasm so streaming instantiation works; a wrong MIME type silently falls
back to the slower ArrayBuffer path.
Two further checks catch problems that validation alone misses. First, run the module once under
node with -sASSERTIONS=2 -sSAFE_HEAP=1 before disabling those flags for the production build — a
binary that passes wasm-validate can still trap at runtime on a stale pointer, and the assertion
build tells you exactly where. Second, watch the section sizes from wasm-objdump -h: an unexpectedly
large code section usually means an optimization level lower than you intended, while a bloated
data section often points to embedded files (--embed-file) or large static tables you can move out
of the binary. Treating these three artifacts — the validator’s exit code, the assertion run, and the
section breakdown — as a required gate keeps a broken or oversized module from reaching production.
In this guide
- Migrating legacy C code to WebAssembly — porting POSIX-dependent codebases: syscall shims, heap-corruption diagnosis, and link-time fixes.
- Binding C++ libraries with embind —
exposing C++ classes, value objects, and functions to JavaScript with
EMSCRIPTEN_BINDINGS.
Frequently Asked Questions
When should I use Emscripten instead of wasm-pack?
Use Emscripten when your source is C or C++, when you depend on a POSIX-shaped standard library, or
when you are porting an existing native codebase. Reach for wasm-pack and wasm-bindgen when you
are writing Rust greenfield — it automates the boundary marshaling that Emscripten leaves to you. The
target triple difference is real: Emscripten compiles wasm32-unknown-emscripten, which bundles a
JS runtime, while wasm-pack targets wasm32-unknown-unknown with thin generated glue.
Do I need -sMODULARIZE for a bundler?
Yes, in practice. Without it the glue assigns to global Module and expects to run at script
top-level, which breaks under Vite, Webpack, or Rollup. -sMODULARIZE=1 -sEXPORT_ES6=1 gives you a
factory function with a clean export default that bundlers can statically analyze.
Why does my typed-array view suddenly read as empty?
You held a HEAPU8 view across a call that grew linear memory. A grow can detach and replace the
backing ArrayBuffer, leaving your old view zero-length. Re-read Module.HEAPU8 (or rebuild the view
from Module.HEAP8.buffer) after any call that might allocate.
How do I keep a C function from being stripped out?
List it in -sEXPORTED_FUNCTIONS with its leading underscore (_compute), or annotate it in source
with EMSCRIPTEN_KEEPALIVE. The linker performs dead-code elimination, so anything not referenced
from an entry point or an exports list is removed.
Is the .js glue file required, or can I load the .wasm directly?
For C/C++ you almost always need the glue — it provides the libc runtime, the filesystem, and the
memory setup the binary depends on. You can produce a standalone module with -sSTANDALONE_WASM for
pure-computation code with no libc dependencies, but most ported code needs the runtime layer.
Related
- ESM bindings & module generation — packaging the
MODULARIZEfactory for application bundlers. - Wasm optimization flags & size reduction — Binaryen passes and
wasm-optbeyondemcc’s defaults. - Rust to Wasm compilation guide — the
wasm-bindgenalternative for Rust sources. - JS/Wasm Interop & Memory Management — the pointer/length ABI that underlies every
ccalland_malloc.
← Back to Compilation Pipelines & Toolchain Setup