Table of Contents

  1. Emscripten SDK Setup
  2. emcmake/emconfigure Usage
  3. Memory Configuration
  4. JavaScript API Bindings
  5. File System Access
  6. Worker Threads
  7. Asyncify
  8. Side Modules
  9. Browser Deployment
  10. WASI Target
Back to CMake Mastery Series

WebAssembly with Emscripten

June 4, 2026 Wasil Zafar 10 min read

Compile C++ to WebAssembly using CMake and Emscripten — SDK setup, memory management, JavaScript bindings with embind, file system access, worker threads, asyncify for blocking code, and optimized browser deployment.

Emscripten SDK Setup

Emscripten is a complete compiler toolchain that compiles C/C++ to WebAssembly. It provides its own CMake toolchain file, standard library implementation, and JavaScript glue code generation. The SDK includes clang, the LLVM WebAssembly backend, and system libraries.

# Install Emscripten SDK
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk

# Install and activate the latest release
./emsdk install latest
./emsdk activate latest

# Set up environment variables (add to shell profile)
source ./emsdk_env.sh

# Verify installation
emcc --version
# emcc (Emscripten gcc/clang-like replacement) 3.1.x

# Check which CMake toolchain file is provided
echo $EMSDK
# Shows path to toolchain file at:
# $EMSDK/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake
Version Pinning: For reproducible builds, pin a specific Emscripten version: ./emsdk install 3.1.51. Different versions can produce different WASM output sizes and have varying browser compatibility.

emcmake/emconfigure Usage

Emscripten provides wrapper commands that automatically inject the correct toolchain file and environment. emcmake wraps CMake, while emconfigure wraps autoconf-based configure scripts.

# Method 1: Using emcmake wrapper (recommended)
emcmake cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -S . -B build-wasm
cmake --build build-wasm

# Method 2: Explicit toolchain file
cmake -G Ninja \
    -DCMAKE_TOOLCHAIN_FILE=$EMSDK/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake \
    -DCMAKE_BUILD_TYPE=Release \
    -S . -B build-wasm

# Method 3: Using CMake presets (recommended for projects)
# CMakePresets.json with emscripten toolchain configured
cmake --preset wasm-release
cmake --build --preset wasm-release
cmake_minimum_required(VERSION 3.21)
project(WasmApp LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_executable(app src/main.cpp src/engine.cpp)

# Emscripten-specific link flags
if(EMSCRIPTEN)
    set_target_properties(app PROPERTIES
        SUFFIX ".html"  # Generate HTML harness alongside .js and .wasm
    )

    target_link_options(app PRIVATE
        -sEXPORTED_RUNTIME_METHODS=['ccall','cwrap']
        -sEXPORTED_FUNCTIONS=['_main','_process_data']
        -sALLOW_MEMORY_GROWTH=1
        -sINITIAL_MEMORY=33554432
        --preload-file ${CMAKE_SOURCE_DIR}/assets@/assets
    )
endif()

Memory Configuration

WebAssembly linear memory is a contiguous byte array. Emscripten manages this memory with configurable initial size and optional growth. Incorrect memory settings cause out-of-memory crashes or excessive page allocation overhead.

cmake_minimum_required(VERSION 3.21)
project(WasmMemory LANGUAGES CXX)

add_executable(app src/main.cpp)

if(EMSCRIPTEN)
    target_link_options(app PRIVATE
        # Initial memory: 64MB (must be multiple of 64KB = WASM page size)
        -sINITIAL_MEMORY=67108864

        # Allow memory to grow beyond initial allocation
        -sALLOW_MEMORY_GROWTH=1

        # Maximum memory cap (prevents runaway allocation)
        -sMAXIMUM_MEMORY=536870912  # 512MB max

        # Stack size (default 64KB may be too small for recursive code)
        -sSTACK_SIZE=1048576  # 1MB stack

        # Abort on allocation failure (clearer than silent corruption)
        -sABORTING_MALLOC=1
    )
endif()
Memory Growth Caveat: When ALLOW_MEMORY_GROWTH=1 is set, every typed array view (e.g., Module.HEAPU8) can be invalidated after any memory-growing call. JavaScript code holding references to the heap buffer must re-acquire them after calling into WASM. This is the most common source of mysterious crashes in WASM apps.

JavaScript API Bindings

Emscripten's embind system provides type-safe bindings between C++ and JavaScript — exposing classes, functions, and enums as JavaScript objects without manual glue code.

// src/bindings.cpp — Expose C++ API to JavaScript via embind
#include <emscripten/bind.h>
#include <string>
#include <vector>

class ImageProcessor {
public:
    ImageProcessor(int width, int height)
        : width_(width), height_(height),
          pixels_(width * height * 4, 0) {}

    void setPixel(int x, int y, int r, int g, int b, int a) {
        int idx = (y * width_ + x) * 4;
        pixels_[idx] = r;
        pixels_[idx + 1] = g;
        pixels_[idx + 2] = b;
        pixels_[idx + 3] = a;
    }

    void applyGrayscale() {
        for (int i = 0; i < width_ * height_; ++i) {
            int idx = i * 4;
            int gray = (pixels_[idx] * 299 +
                        pixels_[idx+1] * 587 +
                        pixels_[idx+2] * 114) / 1000;
            pixels_[idx] = pixels_[idx+1] = pixels_[idx+2] = gray;
        }
    }

    emscripten::val getPixelData() const {
        return emscripten::val(
            emscripten::typed_memory_view(pixels_.size(), pixels_.data())
        );
    }

private:
    int width_, height_;
    std::vector<uint8_t> pixels_;
};

EMSCRIPTEN_BINDINGS(image_module) {
    emscripten::class_<ImageProcessor>("ImageProcessor")
        .constructor<int, int>()
        .function("setPixel", &ImageProcessor::setPixel)
        .function("applyGrayscale", &ImageProcessor::applyGrayscale)
        .function("getPixelData", &ImageProcessor::getPixelData);
}
cmake_minimum_required(VERSION 3.21)
project(EmbindExample LANGUAGES CXX)

add_executable(imageapp src/bindings.cpp)

if(EMSCRIPTEN)
    target_link_options(imageapp PRIVATE
        --bind                          # Enable embind
        -sALLOW_MEMORY_GROWTH=1
        -sMODULARIZE=1                  # Wrap in factory function
        -sEXPORT_NAME='createModule'    # Module factory name
        -sENVIRONMENT='web'             # Web-only (smaller output)
    )

    set_target_properties(imageapp PROPERTIES
        SUFFIX ".js"
    )
endif()

File System Access

Emscripten provides virtual file systems for WASM modules that need file I/O. MEMFS is in-memory (fast, volatile), while IDBFS persists to IndexedDB in browsers.

cmake_minimum_required(VERSION 3.21)
project(FileSystemApp LANGUAGES CXX)

add_executable(fsapp src/main.cpp)

if(EMSCRIPTEN)
    target_link_options(fsapp PRIVATE
        # Embed files at build time (packaged into .data file)
        --preload-file ${CMAKE_SOURCE_DIR}/data@/data

        # Or embed small files directly in JS (no .data download)
        --embed-file ${CMAKE_SOURCE_DIR}/config.json@/config.json

        # Enable IDBFS for persistent storage
        -lworkerfs.js
        -sFORCE_FILESYSTEM=1

        -sALLOW_MEMORY_GROWTH=1
    )
endif()
// src/main.cpp — File system usage with IDBFS persistence
#include <emscripten.h>
#include <stdio.h>
#include <string.h>

int main() {
    // Mount IDBFS for persistent storage
    EM_ASM(
        FS.mkdir('/save');
        FS.mount(IDBFS, {}, '/save');

        // Sync from IndexedDB to memory
        FS.syncfs(true, function(err) {
            if (err) console.error('IDBFS load error:', err);
            else console.log('Persistent storage loaded');
        });
    );

    // Read preloaded file
    FILE* f = fopen("/data/levels.json", "r");
    if (f) {
        char buf[1024];
        size_t n = fread(buf, 1, sizeof(buf) - 1, f);
        buf[n] = '\0';
        printf("Loaded: %s\n", buf);
        fclose(f);
    }

    return 0;
}

Worker Threads

Emscripten supports pthreads via Web Workers, enabling true multithreading in WASM. This requires SharedArrayBuffer and COOP/COEP headers on the hosting server.

cmake_minimum_required(VERSION 3.21)
project(ThreadedWasm LANGUAGES CXX)

add_executable(threaded src/main.cpp src/worker.cpp)

if(EMSCRIPTEN)
    # Enable pthread support
    target_compile_options(threaded PRIVATE -pthread)
    target_link_options(threaded PRIVATE
        -pthread
        -sPTHREAD_POOL_SIZE=4        # Pre-spawn 4 workers
        -sPTHREAD_POOL_SIZE_STRICT=0 # Allow dynamic growth
        -sALLOW_MEMORY_GROWTH=1
        -sINITIAL_MEMORY=67108864
    )
endif()
Server Headers Required: Pthreads in WASM require Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers. Without these, SharedArrayBuffer is unavailable and your threaded WASM will fail to initialize.

Asyncify

Asyncify transforms synchronous C/C++ code to support yielding — enabling blocking operations (sleep, network calls, file dialogs) without freezing the browser's main thread. It works by instrumenting the call stack to save and restore state.

cmake_minimum_required(VERSION 3.21)
project(AsyncApp LANGUAGES CXX)

add_executable(asyncapp src/main.cpp)

if(EMSCRIPTEN)
    target_link_options(asyncapp PRIVATE
        -sASYNCIFY=1
        # Whitelist functions that may sleep (reduces overhead)
        -sASYNCIFY_ONLY=['main','gameLoop','loadAsset']
        -sALLOW_MEMORY_GROWTH=1
    )
endif()
// src/main.cpp — Using asyncify for browser-friendly sleep
#include <emscripten.h>
#include <stdio.h>

void gameLoop() {
    for (int frame = 0; frame < 1000; ++frame) {
        printf("Frame %d\n", frame);

        // This would block the browser WITHOUT asyncify
        // With asyncify, it yields to the event loop
        emscripten_sleep(16);  // ~60 FPS
    }
}

int main() {
    printf("Starting game loop with asyncify...\n");
    gameLoop();
    printf("Game loop complete.\n");
    return 0;
}
Asyncify Cost: Asyncify adds ~10–20% code size overhead because it instruments every function in the call stack for save/restore. Use ASYNCIFY_ONLY to limit instrumentation to functions that actually need to yield — this can reduce the overhead to under 5%.

Side Modules

Side modules enable dynamic loading of WASM libraries at runtime — useful for plugin architectures or loading large modules on demand.

cmake_minimum_required(VERSION 3.21)
project(PluginSystem LANGUAGES CXX)

# Main module (has the runtime)
add_executable(main_app src/main.cpp)

# Side module (loaded dynamically)
add_library(plugin SHARED src/plugin.cpp)

if(EMSCRIPTEN)
    # Main module links against dlopen support
    target_link_options(main_app PRIVATE
        -sMAIN_MODULE=2          # Dynamic linking support (optimized)
        -sALLOW_MEMORY_GROWTH=1
    )

    # Side module — no runtime, linked at load time
    target_link_options(plugin PRIVATE
        -sSIDE_MODULE=2          # Minimal side module
    )

    set_target_properties(plugin PROPERTIES
        SUFFIX ".wasm"
        PREFIX ""
    )
endif()

Browser Deployment

Optimized deployment requires compression, proper MIME types, and minimal output configuration. Emscripten can target web-only environments, reducing generated code size.

cmake_minimum_required(VERSION 3.21)
project(ProductionWasm LANGUAGES CXX)

add_executable(app src/main.cpp src/engine.cpp)

if(EMSCRIPTEN)
    # Production optimization flags
    target_compile_options(app PRIVATE
        -O3                    # Maximum optimization
        -flto                  # Link-time optimization
        -fno-exceptions        # Saves ~50KB
        -fno-rtti              # Saves ~10KB
    )

    target_link_options(app PRIVATE
        -O3
        -flto
        -sENVIRONMENT='web'           # Web only (no Node.js support code)
        -sMODULARIZE=1                 # ES module compatible
        -sEXPORT_NAME='initApp'
        -sALLOW_MEMORY_GROWTH=1
        -sFILESYSTEM=0                 # Disable FS if not needed (saves ~70KB)
        -sASSERTIONS=0                 # Disable runtime assertions
        -sMINIFY_HTML=0                # We provide our own HTML
        --closure 1                     # Run Closure Compiler on JS glue
    )

    set_target_properties(app PROPERTIES SUFFIX ".js")
endif()
# Build and check output sizes
emcmake cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -S . -B build-prod
cmake --build build-prod

# Check sizes
ls -la build-prod/app.wasm  # Typically 100KB–2MB depending on code
ls -la build-prod/app.js    # JS glue: 10–50KB

# Compress for serving (Brotli is best for WASM)
brotli -9 build-prod/app.wasm -o build-prod/app.wasm.br
brotli -9 build-prod/app.js -o build-prod/app.js.br
WebAssembly Size Reduction Strategy

A typical C++ game engine compiled with Emscripten produces a 4MB .wasm file unoptimized. With -O3 -flto --closure 1 -sFILESYSTEM=0 -sASSERTIONS=0, this drops to ~800KB. After Brotli compression, the download is ~250KB — competitive with equivalent JavaScript bundles. Always serve .wasm with Content-Type: application/wasm for streaming compilation.

Optimization Brotli Streaming Compilation

WASI Target

WASI (WebAssembly System Interface) provides a standardized system call interface for running WASM outside the browser — in runtimes like Wasmtime, Wasmer, and WasmEdge. CMake can target WASI using the wasi-sdk toolchain.

# Install wasi-sdk (provides clang targeting wasm32-wasi)
# Download from https://github.com/WebAssembly/wasi-sdk/releases
export WASI_SDK_PATH=/opt/wasi-sdk

# Configure with wasi-sdk toolchain
cmake -G Ninja \
    -DCMAKE_TOOLCHAIN_FILE=${WASI_SDK_PATH}/share/cmake/wasi-sdk.cmake \
    -DCMAKE_BUILD_TYPE=Release \
    -S . -B build-wasi

cmake --build build-wasi

# Run with Wasmtime
wasmtime build-wasi/app.wasm

# Run with Wasmer
wasmer build-wasi/app.wasm -- --arg1 --arg2
cmake_minimum_required(VERSION 3.21)
project(WasiApp LANGUAGES C CXX)

set(CMAKE_CXX_STANDARD 17)

add_executable(app src/main.cpp)

# WASI doesn't support exceptions or RTTI
target_compile_options(app PRIVATE
    -fno-exceptions
    -fno-rtti
)

# Grant file system access (WASI capability model)
# Runtime must provide --dir=. or --mapdir for file access
Emscripten vs WASI: Use Emscripten for browser targets (provides DOM access, WebGL, audio APIs). Use WASI for server-side, CLI tools, and portable sandboxed execution. The two are not interchangeable — Emscripten WASM depends on JavaScript glue, while WASI WASM runs standalone.