WebAssembly with C++ (Emscripten)
Compiling C++ to WebAssembly with Emscripten — toolchain setup, JS bindings with Embind, file system, async/Asyncify, and optimization flags.
TL;DR
Emscripten compiles C++ to WebAssembly. Use Embind to expose C++ classes and functions to JavaScript. Use -O3 -flto for release builds and ASYNCIFY for async interop.
# Install Emscripten SDK
git clone https://github.com/emscripten-core/emsdk.git
./emsdk install latest && ./emsdk activate latest
source ./emsdk_env.sh
# Compile
em++ -O2 -std=c++20 main.cpp -o app.js \
-s WASM=1 \
--bind # enable EmbindHello World
// hello.cpp
#include <iostream>
#include <emscripten/emscripten.h>
int main() {
std::cout << "Hello from WebAssembly!\n";
return 0;
}em++ hello.cpp -o hello.html -s WASM=1
# Generates hello.html, hello.js, hello.wasm
# Open hello.html in browserExposing C++ to JavaScript with Embind
// math_lib.cpp
#include <emscripten/bind.h>
#include <cmath>
float lerp(float a, float b, float t) {
return a + t * (b - a);
}
class Vector2 {
public:
float x, y;
Vector2(float x = 0, float y = 0) : x{x}, y{y} {}
float length() const { return std::sqrt(x*x + y*y); }
Vector2 normalized() const {
float l = length();
return {x/l, y/l};
}
Vector2 operator+(const Vector2& r) const { return {x+r.x, y+r.y}; }
};
EMSCRIPTEN_BINDINGS(math_module) {
emscripten::function("lerp", &lerp);
emscripten::class_<Vector2>("Vector2")
.constructor<float, float>()
.property("x", &Vector2::x)
.property("y", &Vector2::y)
.function("length", &Vector2::length)
.function("normalized", &Vector2::normalized)
;
}em++ math_lib.cpp -o math.js \
-s WASM=1 \
-s MODULARIZE=1 \
-s EXPORT_NAME="MathLib" \
--bind// JavaScript usage
const MathLib = await require('./math.js')();
const v = new MathLib.Vector2(3, 4);
console.log(v.length()); // 5
const n = v.normalized();
console.log(n.x, n.y); // 0.6, 0.8
v.delete(); // free C++ object
n.delete();Memory Management
// Allocate in C++ heap, share with JS
#include <emscripten/emscripten.h>
extern "C" {
EMSCRIPTEN_KEEPALIVE
uint8_t* allocate_buffer(int size) {
return new uint8_t[size];
}
EMSCRIPTEN_KEEPALIVE
void free_buffer(uint8_t* buf) {
delete[] buf;
}
EMSCRIPTEN_KEEPALIVE
void process_image(uint8_t* pixels, int width, int height) {
for (int i = 0; i < width * height * 4; i += 4) {
// Convert to grayscale
uint8_t gray = (pixels[i] + pixels[i+1] + pixels[i+2]) / 3;
pixels[i] = pixels[i+1] = pixels[i+2] = gray;
}
}
}// Access C++ memory from JS
const ptr = Module._allocate_buffer(width * height * 4);
const view = new Uint8ClampedArray(Module.HEAPU8.buffer, ptr, width * height * 4);
view.set(imageData.data); // copy in
Module._process_image(ptr, width, height);
imageData.data.set(view); // copy out
Module._free_buffer(ptr);Asyncify — Async/Await in C++
// Calling async JS functions from C++
#include <emscripten/emscripten.h>
// Declare JS function
EM_JS(void, js_fetch_url, (const char* url), {
// This JS runs asynchronously
fetch(UTF8ToString(url))
.then(r => r.text())
.then(text => {
// Store result for C++ to read
Module.fetchResult = text;
});
});
EM_ASYNC_JS(char*, fetch_sync, (const char* url), {
const response = await fetch(UTF8ToString(url));
const text = await response.text();
const bytes = lengthBytesUTF8(text) + 1;
const ptr = _malloc(bytes);
stringToUTF8(text, ptr, bytes);
return ptr;
});
// With ASYNCIFY, C++ can "block" on JS promises
#include <emscripten/fiber.h>
void download_and_process(const char* url) {
char* data = fetch_sync(url); // suspends C++ until JS resolves
process(data);
free(data);
}em++ app.cpp -o app.js \
-s ASYNCIFY \
-s ASYNCIFY_IMPORTS='["fetch_sync"]' \
--bindFile System
#include <fstream>
#include <emscripten/emscripten.h>
// Emscripten provides an in-memory filesystem
void read_file_example() {
std::ifstream f("/data/config.json"); // virtual filesystem
std::string content((std::istreambuf_iterator<char>(f)),
std::istreambuf_iterator<char>());
}# Embed files at compile time
em++ app.cpp -o app.js \
--embed-file data/config.json@/data/config.json
# Or preload (lazy load from network)
em++ app.cpp -o app.js \
--preload-file assets@/assetsCMake with Emscripten
# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(MyWasmApp)
set(CMAKE_CXX_STANDARD 20)
add_executable(app src/main.cpp src/lib.cpp)
if(EMSCRIPTEN)
target_link_options(app PRIVATE
-sWASM=1
-sMODULARIZE=1
-sEXPORT_NAME="MyApp"
--bind
-sALLOW_MEMORY_GROWTH=1
)
set_target_properties(app PROPERTIES
SUFFIX ".js"
)
endif()emcmake cmake -B build-wasm -DCMAKE_BUILD_TYPE=Release
cmake --build build-wasmOptimization Flags
# Debug
em++ -O0 -g -s ASSERTIONS=2 app.cpp -o app.js
# Release
em++ -O3 -flto --closure 1 app.cpp -o app.js \
-s WASM=1 \
-s ENVIRONMENT=web \
-s FILESYSTEM=0 # disable if not needed (saves ~70KB)
# Size optimized
em++ -Oz --closure 1 app.cpp -o app.js \
-s WASM=1 \
-s MINIMAL_RUNTIME=1Calling C++ from JavaScript (without Embind)
// Use extern "C" to avoid name mangling
extern "C" {
EMSCRIPTEN_KEEPALIVE
int add(int a, int b) { return a + b; }
}// Direct call via ccall/cwrap
const result = Module.ccall('add', 'number', ['number', 'number'], [3, 4]);
const addFn = Module.cwrap('add', 'number', ['number', 'number']);
addFn(3, 4); // 7Performance Tips
- Use
WASM_BIGINTfor 64-bit integer interop - Prefer
Int32Array/Float32Arraytyped arrays for bulk data exchange - Use
--closure 1for JS minification - Enable
FILESYSTEM=0if not using file I/O (-70KB) - Use
MALLOC=emmallocfor smaller binary when performance isn't critical - Profile with browser DevTools — WASM shows in the Performance panel
Emscripten Toolchain Setup (emsdk)
The emsdk manages versioned Emscripten toolchains and keeps them isolated from your system compiler. Install it once, activate a version, and source the environment script to put em++, emcmake, and emmake on your PATH.
# Clone and install the latest stable toolchain
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh # adds em++, emcc, emcmake, emmake to PATH
# Verify
em++ --version # emcc 3.x.y
# Pin a specific version for reproducible builds
./emsdk install 3.1.50
./emsdk activate 3.1.50emcmake is a wrapper that injects the Emscripten toolchain into a CMake configure step. emmake similarly wraps make so legacy Makefiles pick up the right compiler.
# CMake project
emcmake cmake -B build-wasm -DCMAKE_BUILD_TYPE=Release
emmake cmake --build build-wasm
# Plain Makefile project
emcmake ./configure --host=wasm32-unknown-emscripten
emmake make -j$(nproc)Calling JavaScript from C++ (EM_JS and EM_ASM)
EM_JS lets you embed a named JavaScript function directly in your C++ source file; EM_ASM runs an inline JS snippet inline at the call site. Both are compiled away — the JS goes into the generated .js glue file.
#include <emscripten/emscripten.h>
// EM_JS: define a named JS function callable from C++
EM_JS(void, show_alert, (const char* msg), {
alert(UTF8ToString(msg));
});
EM_JS(int, get_window_width, (), {
return window.innerWidth;
});
// EM_ASM: inline JS, no function name
void log_value(int v) {
EM_ASM({
console.log("Value from C++:", $0);
}, v);
}
// EM_ASM_ returns a value
int browser_rand() {
return EM_ASM_INT({
return Math.floor(Math.random() * 1000);
});
}For richer interop, emscripten::val wraps any JavaScript object:
#include <emscripten/val.h>
using emscripten::val;
void interact_with_dom() {
val document = val::global("document");
val canvas = document.call<val>("getElementById", std::string("myCanvas"));
val ctx = canvas.call<val>("getContext", std::string("2d"));
ctx.set("fillStyle", std::string("red"));
ctx.call<void>("fillRect", 0, 0, 200, 100);
// Read a JS property back into C++
int width = canvas["width"].as<int>();
}Memory Layout — HEAP Views and Typed Arrays
WebAssembly memory is a flat ArrayBuffer; Emscripten exposes it through named typed-array views so JavaScript can read and write C++ memory without copying. Passing a pointer from C++ to JS is just passing an integer byte offset into that buffer.
| View | C++ type | Element size |
|---|---|---|
HEAP8 / HEAPU8 | int8_t / uint8_t | 1 byte |
HEAP16 / HEAPU16 | int16_t / uint16_t | 2 bytes |
HEAP32 / HEAPU32 | int32_t / uint32_t | 4 bytes |
HEAPF32 | float | 4 bytes |
HEAPF64 | double | 8 bytes |
// C++: expose a struct by pointer
extern "C" {
struct AudioFrame { float left; float right; };
EMSCRIPTEN_KEEPALIVE
AudioFrame* get_frame_ptr() {
static AudioFrame frame;
return &frame;
}
}// JavaScript: read struct fields from WASM memory
const ptr = Module._get_frame_ptr();
// Each float is 4 bytes; HEAPF32 index = ptr / 4
const left = Module.HEAPF32[ptr >> 2];
const right = Module.HEAPF32[(ptr >> 2) + 1];
// Zero-copy view over a C++ array
const arrayPtr = Module._get_sample_buffer(); // returns uint8_t*
const len = Module._get_buffer_length();
const view = new Float32Array(Module.HEAPF32.buffer, arrayPtr, len / 4);
// `view` shares the underlying ArrayBuffer — no copy neededWhen ALLOW_MEMORY_GROWTH=1 is set, the ArrayBuffer can be replaced on growth, invalidating any previously taken views. Re-acquire views after any C++ allocation that might grow the heap.
WebAssembly Threads
WASM threads require SharedArrayBuffer, the -pthread compile flag, and a server that sends the Cross-Origin-Opener-Policy / Cross-Origin-Embedder-Policy headers. Without those headers browsers refuse to enable SharedArrayBuffer.
# Compile with pthreads support
em++ -O2 -std=c++20 -pthread app.cpp -o app.js \
-s USE_PTHREADS=1 \
-s PTHREAD_POOL_SIZE=4 \
-s WASM=1
# Or let the pool grow on demand
em++ -pthread app.cpp -o app.js \
-s USE_PTHREADS=1 \
-s PTHREAD_POOL_SIZE_STRICT=0// C++ pthreads API works unchanged
#include <pthread.h>
#include <atomic>
std::atomic<int> counter{0};
void* worker(void*) {
for (int i = 0; i < 1'000'000; ++i)
counter.fetch_add(1, std::memory_order_relaxed);
return nullptr;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, nullptr, worker, nullptr);
pthread_create(&t2, nullptr, worker, nullptr);
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
// counter == 2'000'000
}Required HTTP headers (nginx example):
add_header Cross-Origin-Opener-Policy "same-origin";
add_header Cross-Origin-Embedder-Policy "require-corp";File System Emulation (MEMFS, NODEFS, IDBFS)
Emscripten provides several virtual file system backends; the default MEMFS is in-memory and ephemeral — data is lost when the page reloads. Switch to IDBFS for IndexedDB-backed persistence or NODEFS when running under Node.js.
// Mount IDBFS for persistent storage (browser)
EM_ASM({
FS.mkdir('/persist');
FS.mount(IDBFS, {}, '/persist');
// Sync from IndexedDB into MEMFS before C++ runs
FS.syncfs(true, function(err) { // true = populate from IDB
if (!err) Module.ccall('run_app', null, [], []);
});
});// C++: write a file to /persist — flush back to IDB when done
#include <fstream>
#include <emscripten/emscripten.h>
extern "C" EMSCRIPTEN_KEEPALIVE
void save_state(const char* data) {
std::ofstream f("/persist/state.bin");
f << data;
f.close();
// Trigger IDB sync (JS side)
EM_ASM({ FS.syncfs(false, function(err) {}); }); // false = write to IDB
}# NODEFS: map a host directory into the virtual FS (Node.js target)
em++ app.cpp -o app.js -s ENVIRONMENT=node// Node.js: mount the real filesystem
FS.mkdir('/host');
FS.mount(NODEFS, { root: '/tmp/myapp' }, '/host');SIMD in WebAssembly
The wasm-simd128 target feature enables 128-bit SIMD instructions (equivalent to SSE2/NEON) inside WebAssembly; Emscripten can auto-vectorize loops or let you use GCC/Clang intrinsics directly. All modern browsers and Node.js 16+ support WASM SIMD.
# Enable SIMD auto-vectorization
em++ -O3 -msimd128 app.cpp -o app.js
# Also enable relaxed SIMD (wider browser support than strict SIMD2)
em++ -O3 -msimd128 -mrelaxed-simd app.cpp -o app.js// Use wasm_simd128.h intrinsics for explicit SIMD
#include <wasm_simd128.h>
// Add two arrays of floats, 4 at a time
void vec_add(const float* a, const float* b, float* out, int n) {
int i = 0;
for (; i + 4 <= n; i += 4) {
v128_t va = wasm_v128_load(a + i);
v128_t vb = wasm_v128_load(b + i);
v128_t vc = wasm_f32x4_add(va, vb);
wasm_v128_store(out + i, vc);
}
for (; i < n; ++i) out[i] = a[i] + b[i]; // tail
}Auto-vectorization works for simple loops when the compiler can prove no aliasing:
// Compiler auto-vectorizes this with -O3 -msimd128
void scale(float* buf, float gain, int n) {
for (int i = 0; i < n; ++i)
buf[i] *= gain;
}Optimizing WASM Binary Size
Wasm binary size directly affects page load time; combining -Os/-Oz, Closure Compiler, wasm-opt, and stripping debug info can shrink output by 60–80 %. Each flag targets a different layer of the output.
# Full size-optimized pipeline
em++ -Oz \
--closure 1 \
-s WASM=1 \
-s MINIMAL_RUNTIME=1 \
-s FILESYSTEM=0 \
-s MALLOC=emmalloc \
-s ENVIRONMENT=web \
-flto \
app.cpp -o app.js
# Post-process with Binaryen's wasm-opt (installed separately)
wasm-opt -Oz app.wasm -o app.wasm
# Strip DWARF debug info from a debug build for a "profile" build
llvm-strip --strip-debug app.wasm| Technique | Typical saving |
|---|---|
-Os / -Oz | 20–30 % vs -O2 |
--closure 1 | 30–50 % of JS glue |
FILESYSTEM=0 | ~70 KB |
MALLOC=emmalloc | ~10 KB |
MINIMAL_RUNTIME=1 | ~20 KB of JS glue |
wasm-opt -Oz | 5–15 % additional |
| Strip debug info | large for debug builds |
WASI — WebAssembly System Interface
WASI is a standardized ABI that lets WebAssembly modules run outside the browser — on servers, edge functions, or embedded runtimes — by providing POSIX-like syscalls without a JavaScript host. Emscripten can target WASI via -s WASI=1 or through the dedicated wasi-sdk.
# Option A: Emscripten WASI target
em++ -O2 app.cpp -o app.wasm \
-s WASI=1
# Option B: wasi-sdk (independent LLVM-based toolchain)
/opt/wasi-sdk/bin/clang++ -O2 \
--sysroot=/opt/wasi-sdk/share/wasi-sysroot \
app.cpp -o app.wasm
# Run with wasmtime
wasmtime app.wasm
# Run with wasmer
wasmer run app.wasm// Standard C++ I/O works with WASI runtimes
#include <iostream>
#include <fstream>
int main(int argc, char* argv[]) {
std::cout << "Hello from WASI!\n";
if (argc > 1) {
std::ifstream f(argv[1]); // host file via --dir flag
std::string line;
while (std::getline(f, line))
std::cout << line << '\n';
}
return 0;
}# Grant access to a host directory
wasmtime --dir=. app.wasm myfile.txt
wasmer run --dir=. app.wasm -- myfile.txtWASI capabilities follow a capability-security model: the module can only access host resources (files, sockets) that the runtime explicitly grants. This makes WASI modules strongly sandboxed by default.