Skip to content
C++
Domain Track
Difficulty 3/5

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.

bash
# 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 Embind

Hello World

cpp
// hello.cpp
#include <iostream>
#include <emscripten/emscripten.h>

int main() {
    std::cout << "Hello from WebAssembly!\n";
    return 0;
}
bash
em++ hello.cpp -o hello.html -s WASM=1
# Generates hello.html, hello.js, hello.wasm
# Open hello.html in browser

Exposing C++ to JavaScript with Embind

cpp
// 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)
        ;
}
bash
em++ math_lib.cpp -o math.js \
    -s WASM=1 \
    -s MODULARIZE=1 \
    -s EXPORT_NAME="MathLib" \
    --bind
javascript
// 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

cpp
// 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;
    }
}

}
javascript
// 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++

cpp
// 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);
}
bash
em++ app.cpp -o app.js \
    -s ASYNCIFY \
    -s ASYNCIFY_IMPORTS='["fetch_sync"]' \
    --bind

File System

cpp
#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>());
}
bash
# 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@/assets

CMake with Emscripten

cmake
# 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()
bash
emcmake cmake -B build-wasm -DCMAKE_BUILD_TYPE=Release
cmake --build build-wasm

Optimization Flags

bash
# 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=1

Calling C++ from JavaScript (without Embind)

cpp
// Use extern "C" to avoid name mangling
extern "C" {
    EMSCRIPTEN_KEEPALIVE
    int add(int a, int b) { return a + b; }
}
javascript
// 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);  // 7

Performance Tips

  • Use WASM_BIGINT for 64-bit integer interop
  • Prefer Int32Array/Float32Array typed arrays for bulk data exchange
  • Use --closure 1 for JS minification
  • Enable FILESYSTEM=0 if not using file I/O (-70KB)
  • Use MALLOC=emmalloc for 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.

bash
# 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.50

emcmake 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.

bash
# 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.

cpp
#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:

cpp
#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.

ViewC++ typeElement size
HEAP8 / HEAPU8int8_t / uint8_t1 byte
HEAP16 / HEAPU16int16_t / uint16_t2 bytes
HEAP32 / HEAPU32int32_t / uint32_t4 bytes
HEAPF32float4 bytes
HEAPF64double8 bytes
cpp
// 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
// 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 needed

When 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.

bash
# 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
cpp
// 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):

cpp
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.

javascript
// 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, [], []);
    });
});
cpp
// 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
}
bash
# NODEFS: map a host directory into the virtual FS (Node.js target)
em++ app.cpp -o app.js -s ENVIRONMENT=node
javascript
// 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.

bash
# 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
cpp
// 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:

cpp
// 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.

bash
# 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
TechniqueTypical saving
-Os / -Oz20–30 % vs -O2
--closure 130–50 % of JS glue
FILESYSTEM=0~70 KB
MALLOC=emmalloc~10 KB
MINIMAL_RUNTIME=1~20 KB of JS glue
wasm-opt -Oz5–15 % additional
Strip debug infolarge 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.

bash
# 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
cpp
// 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;
}
bash
# Grant access to a host directory
wasmtime --dir=. app.wasm myfile.txt
wasmer run --dir=. app.wasm -- myfile.txt

WASI 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.