../

C/C++ & Emscripten

Compiling C and C++ to WebAssembly with Emscripten, from the TypeScript side: emcc basics, output modes and the settings that matter, calling C from JS (ccall, cwrap, heap views), embind with generated .d.ts, JS from C, file systems, main loops, SDL/WebGL, pthreads, CMake, debugging and optimization, plus wasi-sdk for builds without JS. Background is in Fundamentals.

Install emsdk

git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest     # LLVM, Binaryen, Node, emscripten
./emsdk activate latest
source ./emsdk_env.sh      # per shell (.ps1 on Windows)
emcc --version             # 6.0.x as of September 2026
ToolRole
emcc / em++clang drivers: compile and link to .wasm + JS glue; link C++ with em++ (since 6.0.6)
emcmake, emmake, emconfigurerun CMake / make / autoconf with the Emscripten toolchain
emrunserve and open an .html output with the right headers
emar, emranlib, emnm, emstripLLVM binutils equivalents
embuilderprebuild system libraries and ports (embuilder build sdl2)

Pin a version in CI (./emsdk install 6.0.10): there is no ABI guarantee between releases, so rebuild all object files and libraries after upgrading.

emcc basics

emcc hello.c -o hello.html     # page + JS + wasm (demo)
emcc hello.c -o hello.js       # JS glue + hello.wasm
emcc hello.c -o hello.mjs      # ES module glue
emcc lib.c -o lib.wasm --no-entry   # standalone Wasm
em++ -std=c++20 -O2 app.cpp -o app.js
emcc -O2 -c a.c -o a.o && emcc a.o b.o -o app.js
OutputWhat you getUse
-o x.htmlHTML shell + x.js + x.wasmquick demos (--shell-file for your own template)
-o x.jsglue that loads x.wasm, sets up memory, FS, runtimemost apps; load with a <script> or import
-o x.mjssame, as an ES module (implies EXPORT_ES6, MODULARIZE)bundlers and TS projects
-o x.wasmstandalone Wasm (STANDALONE_WASM), WASI-style importsnon-JS hosts; minimal JS
-sSINGLE_FILE.wasm embedded in the .jsone-file distribution (larger; CSP caveats)

Warnings and C semantics are clang's: -Wall -Wextra, -std=c17 / -std=c++20 (C++17 is the default), -fno-exceptions, -D, -I all work as usual. __EMSCRIPTEN__ is defined.

Key settings

Settings are passed as -sNAME=value (-sNAME alone means 1; lists as -sX=a,b or JSON).

SettingDefaultUse
EXPORTED_FUNCTIONS_mainC functions to keep and export, with a leading underscore: -sEXPORTED_FUNCTIONS=_add,_malloc,_free
EXPORTED_RUNTIME_METHODSnoneruntime helpers on Module: ccall,cwrap,UTF8ToString,FS,HEAPU8,HEAPF32
MODULARIZEoffwrap output in a factory returning Promise<Module>; =instance (experimental) gives ESM named exports plus init
EXPORT_ES6offES module output (implied by .mjs)
EXPORT_NAMEModulename of the factory / global (createMyLib)
ENVIRONMENTweb,webview,worker,nodetrim runtime checks: web, web,worker, node
ALLOW_MEMORY_GROWTHofflet malloc grow memory; otherwise out-of-memory aborts
INITIAL_MEMORYcomputedstarting heap, e.g. 64MB
MAXIMUM_MEMORY2GBcap for growth; up to 4GB on 32-bit
STACK_SIZE64KBfixed shadow stack; raise it for deep recursion (formerly TOTAL_STACK)
ASYNCIFYoffbinaryen transform so C can "block" on async JS; costs size and speed
JSPIoffsame goal using JS Promise Integration; no code transform; no longer experimental (6.0.8)
SINGLE_FILEoffinline the .wasm as base64 / binary in the JS
FILESYSTEMauto0 removes FS support when nothing needs it
FORCE_FILESYSTEMoffkeep FS even if C does not use it (JS does)
EXIT_RUNTIMEoffrun atexit, flush stdio after main returns
INVOKE_RUNon0: do not call main automatically (callMain() later)
STANDALONE_WASMoffWASI-style imports, runnable in Wasmtime
ASSERTIONSon at -O0runtime checks with readable errors (=2 for more)

Exporting C functions

mathlib.c
#include <emscripten/emscripten.h>
#include <stdlib.h>
#include <string.h>
 
EMSCRIPTEN_KEEPALIVE
int add(int a, int b) { return a + b; }
 
EMSCRIPTEN_KEEPALIVE
double sum(const float *xs, int n) {
  double s = 0;
  for (int i = 0; i < n; i++) s += xs[i];
  return s;
}
 
EMSCRIPTEN_KEEPALIVE
char *greet(const char *name) {  // caller frees
  char *out = malloc(strlen(name) + 8);
  strcpy(out, "Hello, ");
  strcat(out, name);
  return out;
}
#include <emscripten/emscripten.h>
// C++: disable name mangling for exported symbols
extern "C" {
EMSCRIPTEN_KEEPALIVE int add(int a, int b);
}
emcc mathlib.c -O2 -o mathlib.mjs \
  -sMODULARIZE -sEXPORT_ES6 -sEXPORT_NAME=createMath \
  -sENVIRONMENT=web,worker \
  -sEXPORTED_FUNCTIONS=_malloc,_free \
  -sEXPORTED_RUNTIME_METHODS=ccall,cwrap,UTF8ToString,HEAPF32

EMSCRIPTEN_KEEPALIVE both keeps the symbol alive and exports it (as _add on the module); list extra C functions such as _malloc in EXPORTED_FUNCTIONS. In C++ without extern "C" the export name is mangled, so either use extern "C" or embind.

Calling C from JS

import createMath from "./mathlib.mjs";
 
const M = await createMath({
  print: (s: string) => console.log(s),     // stdout
  printErr: (s: string) => console.error(s),
  locateFile: (p: string) => `/wasm/${p}`,  // .wasm URL
});
 
// direct: numbers only
M._add(2, 3);
 
// ccall / cwrap convert strings and arrays for you
const greet = M.cwrap("greet", "number", ["string"]);
const p = greet("Ada");
console.log(M.UTF8ToString(p));
M._free(p);
 
M.ccall("add", "number", ["number", "number"], [1, 2]);
ccall / cwrap typeJS valueC side
"number"number (or bigint for int64_t)int, float, double, pointers
"string" argstringconst char *, copied to the stack for the call only
"string" returnstringdecoded with UTF8ToString; the C buffer is not freed
"array" argUint8Array / number[]const uint8_t *, copied to the stack
"boolean"booleanbool
"pointer"numberwith memory64 or 2 GB+ heaps: unsigned / Number conversion
null returnundefinedvoid

Pass { async: true } as the fifth ccall argument (or fourth for cwrap) when the function may suspend with ASYNCIFY / JSPI; the call then returns a Promise.

Heap views and malloc

// JS allocates, fills, calls, frees
const xs = new Float32Array([1, 2, 3, 4]);
const ptr = M._malloc(xs.byteLength);
M.HEAPF32.set(xs, ptr >> 2);   // index = byte offset / 4
const total = M._sum(ptr, xs.length);
M._free(ptr);
ViewElementIndex from a byte pointer
HEAP8 / HEAPU8int8_t / uint8_tptr
HEAP16 / HEAPU16int16_t / uint16_tptr >> 1
HEAP32 / HEAPU32int32_t / uint32_tptr >> 2
HEAPF32 / HEAPF64float / doubleptr >> 2 / ptr >> 3
HEAP64 / HEAPU64int64_t / uint64_t (BigInt64Array)ptr >> 3
Helper (export via EXPORTED_RUNTIME_METHODS)Does
UTF8ToString(ptr, maxBytes?)read a NUL-terminated C string
stringToUTF8(str, ptr, maxBytes)write a string into allocated memory
lengthBytesUTF8(str)bytes needed (add 1 for NUL)
stringToNewUTF8(str)malloc + write; caller _frees
getValue(ptr, "i32") / setValue(ptr, v, "double")single typed load / store
stackAlloc, stackSave, stackRestorecheap temporary allocations

With ALLOW_MEMORY_GROWTH, the runtime replaces the HEAP* views after growth: always read M.HEAPU8 fresh instead of caching the view in your own variable.

Embind

shapes.cpp
#include <emscripten/bind.h>
#include <string>
#include <vector>
using namespace emscripten;
 
struct Point { float x, y; };
 
class Path {
 public:
  explicit Path(std::string name) : name_(name) {}
  void add(Point p) { pts_.push_back(p); }
  size_t size() const { return pts_.size(); }
  std::string name() const { return name_; }
  void setName(std::string n) { name_ = n; }
  static Path unit() { return Path("unit"); }
 private:
  std::string name_;
  std::vector<Point> pts_;
};
 
float lerp(float a, float b, float t) {
  return a + (b - a) * t;
}
 
EMSCRIPTEN_BINDINGS(shapes) {
  function("lerp", &lerp);
  value_object<Point>("Point")
      .field("x", &Point::x)
      .field("y", &Point::y);
  class_<Path>("Path")
      .constructor<std::string>()
      .function("add", &Path::add)
      .function("size", &Path::size)
      .property("name", &Path::name, &Path::setName)
      .class_function("unit", &Path::unit);
  register_vector<float>("FloatVector");
}
em++ -O2 -lembind shapes.cpp -o shapes.mjs \
  -sMODULARIZE -sEXPORT_ES6 \
  --emit-tsd shapes.d.ts
import createShapes from "./shapes.mjs";
 
const S = await createShapes();
S.lerp(0, 10, 0.25);             // 2.5
const path = new S.Path("route");
path.add({ x: 1, y: 2 });        // value_object: plain JS
path.name = "renamed";           // property()
console.log(path.size());
path.delete();                   // C++ destructor
Embind APIMaps to
function("name", &fn)a module-level JS function
class_<T>("T")JS class; .constructor<Args...>(), .function, .property, .class_function
class_<D, base<B>>inheritance; allow_subclass + wrapper<T> for JS overriding virtuals
value_object<T> / value_array<T>copied to / from plain JS objects / arrays
register_vector<T>("V")std::vector<T> class with push_back, get, size; iterable in 5.0+
register_map<K, V>std::map wrapper
enum_<E>("E").value("A", E::A)enum object
emscripten::valany JS value from C++: val::global("document"), .call<void>("x")
smart_ptr / std::shared_ptrmanaged handles
  • Link with -lembind (the old --bind flag also works). Embind needs C++17 or later.
  • Every C++ object you receive in JS must be freed with .delete(); FinalizationRegistry cleanup is not guaranteed. Bound classes also implement [Symbol.dispose], so TypeScript's using works.
  • --emit-tsd out.d.ts generates TypeScript declarations for the bindings and the module factory (replaced the older --embind-emit-tsd).

Calling JS from C

#include <emscripten/emscripten.h>
 
// A JS function compiled into the output
EM_JS(void, show_title, (const char *s), {
  document.title = UTF8ToString(s);
});
 
EM_JS(int, screen_width, (void), {
  return window.innerWidth;
});
 
void demo(void) {
  show_title("Loaded");
  // inline JS; $0, $1 are the arguments
  EM_ASM({ console.log("x =", $0, "y =", $1); }, 3, 4.5);
  int w = EM_ASM_INT({ return window.innerWidth; });
  double d = EM_ASM_DOUBLE({ return performance.now(); });
  emscripten_run_script("console.log('eval-based')");
  (void)w; (void)d;
}
MechanismNotes
EM_JS(ret, name, (args), { js })named JS function; the cleanest option
EM_ASM, EM_ASM_INT, EM_ASM_DOUBLE, EM_ASM_PTRinline JS with numbered placeholders for arguments
--js-library lib.jsaddToLibrary({ fn: ... }) functions callable from C as extern
--pre-js / --post-jsJS inserted before / after the glue (hooks, Module config)
emscripten_run_scripteval: slow, CSP-hostile; avoid
emscripten::val (embind)C++ object API over any JS value

Only numbers cross directly: pass strings as const char * and decode with UTF8ToString.

File systems

FSBackingEnable
MEMFSin memory, lost on reloaddefault
Preloaded filesx.data package fetched at startup, mounted into MEMFS--preload-file assets@/assets
Embedded filesbytes inside the .wasm / JS--embed-file config.json (small files)
IDBFSMEMFS synced to IndexedDB by FS.syncfs-lidbfs.js
NODEFSa real host directory (Node only)-lnodefs.js, FS.mount(NODEFS, { root }, "/mnt")
NODERAWFSthe whole host FS, no mounting (Node only)-sNODERAWFS
WORKERFSread-only File / Blob in a worker-lworkerfs.js
WasmFSnew C++ FS implementation with OPFS and other backends-sWASMFS (still marked experimental)
// -sEXPORTED_RUNTIME_METHODS=FS,IDBFS -lidbfs.js
M.FS.mkdir("/persist");
M.FS.mount(M.IDBFS, { autoPersist: true }, "/persist");
await new Promise<void>((ok, err) =>
  M.FS.syncfs(true, (e) => (e ? err(e) : ok())), // load
);
M.FS.writeFile("/persist/save.json", JSON.stringify(s));
const text = M.FS.readFile("/persist/save.json", {
  encoding: "utf8",
});

C code sees a normal POSIX file system (fopen, read, stat); JS drives the same tree through FS. Synchronous file I/O that must hit the network or IndexedDB needs ASYNCIFY / JSPI or a worker.

Main loop

#include <emscripten/emscripten.h>
 
static int frame = 0;
 
static void tick(void) {
  frame++;
  // update and render one frame; never block here
  if (frame == 600) emscripten_cancel_main_loop();
}
 
int main(void) {
  // fps 0: use requestAnimationFrame
  // 1: simulate an infinite loop (main does not return)
  emscripten_set_main_loop(tick, 0, 1);
  return 0;
}

A native while (running) { ... } loop freezes the tab: the browser only paints and dispatches events when control returns to the event loop. Restructure into a per-frame callback, or keep the loop and build with -sASYNCIFY / -sJSPI and call emscripten_sleep(0) inside it.

SDL2, WebGL and ports

em++ game.cpp -O2 -o game.html \
  -sUSE_SDL=2 -sUSE_SDL_IMAGE=2 -sSDL2_IMAGE_FORMATS=png \
  -sMAX_WEBGL_VERSION=2 -sMIN_WEBGL_VERSION=2 \
  -sALLOW_MEMORY_GROWTH \
  --preload-file assets@/assets
FlagEffect
-sUSE_SDL=2 / --use-port=sdl2SDL2 port (built on first use); --use-port=sdl3 for SDL3 (no longer experimental)
-sMAX_WEBGL_VERSION=2allow WebGL 2 (OpenGL ES 3.0); -sUSE_WEBGL2 is the deprecated spelling
-sMIN_WEBGL_VERSION=2drop WebGL 1 support code
-sFULL_ES3emulate client-side arrays etc. for GLES3 code that needs them
-sLEGACY_GL_EMULATIONpartial desktop fixed-function GL emulation
--use-port=contrib.glfw3GLFW port
--use-port=emdawnwebgpuWebGPU via Dawn's webgpu.h (replaces deprecated -sUSE_WEBGPU)
--show-portslist available ports

The canvas is Module.canvas (or #canvas in the default shell). Audio needs a user gesture before the AudioContext starts, exactly as in JS.

Pthreads

emcc -pthread -O2 app.c -o app.js \
  -sPTHREAD_POOL_SIZE=navigator.hardwareConcurrency \
  -sPROXY_TO_PTHREAD \
  -sALLOW_MEMORY_GROWTH
SettingUse
-pthreadcompile and link flag: atomics, shared memory, Worker-based threads (-sUSE_PTHREADS is deprecated)
PTHREAD_POOL_SIZE=Npre-spawn workers; creating a thread with an empty pool needs the main thread to yield first
PTHREAD_POOL_SIZE_STRICTfail (or warn) when the pool is exhausted
PROXY_TO_PTHREADrun main on a worker so it may block; the browser thread stays free
-sWASM_WORKERSlighter, non-POSIX worker API (emscripten_malloc_wasm_worker)

Threads require SharedArrayBuffer, so the page must be cross-origin isolated:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Blocking (pthread_join, mutex waits, Atomics.wait) on the browser main thread is disallowed or busy-waits: use PROXY_TO_PTHREAD. Growth with shared memory makes JS heap access slower because the views must be revalidated; emcc suggests -sGROWABLE_ARRAYBUFFERS=2 for that combination.

CMake and existing builds

emcmake cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
emmake make -C legacy/            # plain Makefiles
emconfigure ./configure && emmake make
CMakeLists.txt
add_executable(app main.cpp)
if(EMSCRIPTEN)
  set_target_properties(app PROPERTIES SUFFIX ".mjs")
  target_link_options(app PRIVATE
    -sMODULARIZE -sEXPORT_ES6
    -sALLOW_MEMORY_GROWTH
    -lembind --emit-tsd app.d.ts)
endif()

emcmake passes the Emscripten toolchain file (cmake/Modules/Platform/Emscripten.cmake) so EMSCRIPTEN is defined in CMake. Static libraries (.a) are LLVM bitcode or Wasm object archives: build every dependency with the same Emscripten version.

Exceptions and setjmp

FlagMode
defaultC++ throw works but catching is disabled: a throw aborts
-fexceptionsJS-based exceptions (invoke_* wrappers); works everywhere, slower and larger
-fwasm-exceptionsnative Wasm exception handling; fast; all current browsers
-sEXCEPTION_STACK_TRACESstack traces on thrown CppException objects (with either mode above)
-sSUPPORT_LONGJMP=wasmsetjmp / longjmp on Wasm EH

Pass the exception flag at both compile and link time.

Debugging

FlagGives
-gDWARF debug info in the .wasm (use the Chrome C/C++ DevTools Support (DWARF) extension)
-gsource-mapa source map: lines only, no variables; -gsource-map=inline embeds sources
-g2readable function names (name section) without full DWARF
-sASSERTIONS=2extra runtime checks and clearer errors
-sSAFE_HEAPtraps on null and misaligned accesses
-sSTACK_OVERFLOW_CHECK=2detects shadow stack overflow on every call
-fsanitize=addressAddressSanitizer: out-of-bounds, use-after-free, leaks
-fsanitize=undefinedUBSan: overflow, bad shifts, null dereference
--profiling / --profiling-funcskeep function names in optimized builds for profilers
EMCC_DEBUG=1log and keep the compiler's intermediate files

Debug builds (-O0) enable ASSERTIONS by default, which catches most mistakes (missing exports, calling before the runtime is ready, ccall type mismatches).

Optimization

FlagEffect
-O0no optimization, fastest compile, assertions on
-O1, -O2normal release; -O2 runs the full Binaryen pipeline
-O3more inlining and speed, bigger and slower to build
-Os, -Ozsize-focused; -Oz smallest
-fltolink-time optimization across translation units
--closure 1Google Closure Compiler minifies the JS glue (needs Java on some platforms)
-sEVAL_CTORSrun static constructors at compile time
-sMINIMAL_RUNTIMEtiny glue without most runtime features
-sFILESYSTEM=0, -sENVIRONMENT=webdrop unused runtime code
-msimd128enable Wasm SIMD (auto-vectorization, wasm_simd128.h); SSE / NEON intrinsics headers are partly emulated

Use the same -O level at compile and link time: the link step runs wasm-opt, and settings such as -flto or -pthread must match across all objects.

wasi-sdk and plain clang

Not every C library needs Emscripten's JS runtime. For a Wasm module used by your own JS or by a WASI runtime, a plain LLVM toolchain is smaller and simpler.

# freestanding: no libc, exports marked in source
clang --target=wasm32 -O3 -nostdlib \
  -Wl,--no-entry -o add.wasm add.c
 
# wasi-sdk: clang + wasi-libc sysroot
$WASI_SDK_PATH/bin/clang --target=wasm32-wasip1 \
  -O2 main.c -o main.wasm
wasmtime run main.wasm
add.c
__attribute__((export_name("add")))
int add(int a, int b) { return a + b; }
 
__attribute__((import_module("env"),
               import_name("log")))
void js_log(int x);
ToolchainlibcJS glueGood for
Emscriptenmusl + JS syscallsyesports of full apps, SDL / GL, file systems, pthreads in browsers
wasi-sdkwasi-libcnoCLI tools and libraries for Wasmtime, Node node:wasi, components
clang --target=wasm32nonenotiny numeric kernels loaded by hand-written TS

Recipes

Typed ES module library

Use to ship a C library to TypeScript with an async factory.

emcc lib.c -O3 -o dist/lib.mjs \
  -sMODULARIZE -sEXPORT_ES6 -sEXPORT_NAME=createLib \
  -sENVIRONMENT=web,worker,node \
  -sALLOW_MEMORY_GROWTH -sFILESYSTEM=0 \
  -sEXPORTED_FUNCTIONS=_malloc,_free \
  -sEXPORTED_RUNTIME_METHODS=cwrap,HEAPU8
lib.ts
import createLib from "./dist/lib.mjs";
 
export async function openLib() {
  const M = await createLib();
  type Crc = (p: number, n: number) => number;
  const crc = M.cwrap("crc32", "number",
    ["number", "number"]) as Crc;
  return {
    crc32(bytes: Uint8Array): number {
      const p = M._malloc(bytes.length);
      M.HEAPU8.set(bytes, p);
      try { return crc(p, bytes.length) >>> 0; }
      finally { M._free(p); }
    },
  };
}

Port a CLI to Node

Use to run an existing command-line tool on Node with host files.

emcc tool.c -O2 -o tool.mjs \
  -sENVIRONMENT=node -sNODERAWFS -sEXIT_RUNTIME \
  -sEXPORT_ES6 -sMODULARIZE -sINVOKE_RUN=0 \
  -sEXPORTED_RUNTIME_METHODS=callMain
import createTool from "./tool.mjs";
 
const M = await createTool();
// argv[0] is supplied by the runtime; returns main's code
const code = M.callMain(["--input", "data.csv"]);
console.log("exit code", code);

NODERAWFS gives the program the real file system relative to the current directory, so relative paths in arguments work as they would natively.

Blocking C API over async JS

Use when C code calls a function that must await (fetch, IndexedDB) and cannot be restructured.

#include <emscripten/emscripten.h>
 
EM_ASYNC_JS(int, fetch_len, (const char *url), {
  const r = await fetch(UTF8ToString(url));
  return (await r.arrayBuffer()).byteLength;
});
 
EMSCRIPTEN_KEEPALIVE
int load(void) {
  return fetch_len("/data.bin"); // suspends here
}
emcc async.c -O2 -o async.mjs -sJSPI \
  -sMODULARIZE -sEXPORT_ES6 \
  -sEXPORTED_RUNTIME_METHODS=ccall
# fallback for engines without JSPI: -sASYNCIFY
const n = await M.ccall("load", "number", [], [], {
  async: true,
});

Persist saves with IDBFS

Use for game saves and editor documents that must survive reloads.

#include <emscripten/emscripten.h>
#include <stdio.h>
 
EMSCRIPTEN_KEEPALIVE
void save_game(const char *json) {
  FILE *f = fopen("/persist/save.json", "w");
  fputs(json, f);
  fclose(f);
  EM_ASM(FS.syncfs(false, (e) => e && console.error(e)));
}
 
int main(void) {
  EM_ASM(
    FS.mkdir("/persist");
    FS.mount(IDBFS, {}, "/persist");
    FS.syncfs(true, (e) => e && console.error(e));
  );
  return 0;
}
emcc save.c -lidbfs.js -o save.js -sEXIT_RUNTIME=0

Threaded build served locally

Use to test a pthreads build; emrun sets the isolation headers.

emcc -pthread -O2 -sPTHREAD_POOL_SIZE=4 \
  -sPROXY_TO_PTHREAD work.c -o work.html
emrun --no-browser --port 8080 work.html
# or any server that sends COOP: same-origin and
# COEP: require-corp on the HTML, JS and Wasm

References