Building Emscripten Projects with CMake
This guide answers one task: take a project that already builds with CMake and produce a WebAssembly build from the same sources, without forking the build system or breaking the native one.
Prerequisites
- [ ] Emscripten 3.1.60+ with
emsdkactivated in the shell. - [ ] CMake 3.20 or later.
- [ ] A project that already builds natively — port the build, not the code, first.
- [ ] Ninja, optionally, which speeds the build considerably.
emcmake does one thing
emcmake is a wrapper that invokes CMake with Emscripten’s toolchain file. That toolchain file sets the
compiler, the archiver, the target triple and a set of platform variables, so CMAKE_SYSTEM_NAME becomes
Emscripten and the usual cross-compilation machinery applies.
mkdir -p build-wasm && cd build-wasm
emcmake cmake .. -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build .
That is the whole integration for a well-behaved project. Everything else in this page is about projects that are not well-behaved, which is most of them.
Settings that are not compiler flags
Emscripten’s -s options are link-time settings, not compile options, and putting them in the wrong
variable produces a build that compiles and then behaves as if the settings were never applied.
if(EMSCRIPTEN)
set(CMAKE_EXECUTABLE_SUFFIX ".js") # produce app.js, not app
target_link_options(app PRIVATE
"-sMODULARIZE=1"
"-sEXPORT_ES6=1"
"-sEXPORT_NAME=createApp"
"-sALLOW_MEMORY_GROWTH=1"
"-sINITIAL_MEMORY=64MB"
"-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','FS']"
"--preload-file=${CMAKE_SOURCE_DIR}/assets@/assets"
)
target_compile_options(app PRIVATE -O3 -msimd128)
endif()
The EMSCRIPTEN variable is set by the toolchain file, so guarding on it keeps the native build clean.
CMAKE_EXECUTABLE_SUFFIX decides what you get: .js produces the glue plus a .wasm, .html adds a
demo page, and leaving it unset produces a bare module with no loader.
Quote each setting as its own list element. Passing them as one space-separated string is the most common cause of settings that silently do nothing.
Build types and what they should mean here
CMake’s build types map onto Emscripten flags in ways that are worth setting explicitly rather than inheriting, because the defaults were chosen for native binaries.
Debug should keep DWARF information, assertions and safe-heap checking. The resulting module is several
times larger and dramatically more informative when something goes wrong, which is the correct trade while
developing.
Release should optimise for speed or size depending on the product, strip debug information, and disable
assertions. MinSizeRel is the natural home for a size-optimised web build.
if(EMSCRIPTEN)
target_compile_options(app PRIVATE
$<$<CONFIG:Debug>:-O1 -g3>
$<$<CONFIG:Release>:-O3>
$<$<CONFIG:MinSizeRel>:-Oz>
)
target_link_options(app PRIVATE
$<$<CONFIG:Debug>:-sASSERTIONS=2 -sSAFE_HEAP=1 -sSTACK_OVERFLOW_CHECK=2 -g3>
$<$<CONFIG:Release>:-sASSERTIONS=0 --closure=1>
$<$<CONFIG:MinSizeRel>:-sASSERTIONS=0 --closure=1 -sFILESYSTEM=0>
)
endif()
SAFE_HEAP deserves particular mention: it instruments every memory access and reports out-of-bounds and
misaligned accesses with a message and a stack, turning silent corruption into an immediate, localised
error. It is far too slow for release and invaluable when a port is misbehaving in ways that make no sense.
--closure=1 runs the Closure compiler over the generated glue, which typically removes 30–50% of it.
It occasionally breaks hand-written JavaScript that the glue includes, so enable it deliberately and test
the result rather than assuming.
Dependencies, and the ones that fight back
Third-party libraries are where a port spends its time. Three situations recur.
A library that builds with CMake and has no platform assumptions builds unchanged — add it with
FetchContent or a subdirectory and it works.
A library using autotools needs emconfigure rather than emcmake, and is usually easier to vendor as a
prebuilt archive than to integrate:
emconfigure ./configure --host=wasm32 --disable-shared
emmake make -j
A library available through Emscripten’s own ports system should use that, because those builds are maintained and cached:
target_compile_options(app PRIVATE "-sUSE_ZLIB=1" "-sUSE_LIBPNG=1")
target_link_options(app PRIVATE "-sUSE_ZLIB=1" "-sUSE_LIBPNG=1")
Note that ports flags are needed at both compile and link time — the compile side supplies headers, the link side supplies the library, and providing only one produces an error about missing symbols that does not mention the flag.
Tools the build runs during the build
A project that compiles a code generator and then runs it hits the classic cross-compilation problem: the generator must be built for the host, not for WebAssembly. CMake’s usual answer applies.
if(CMAKE_CROSSCOMPILING)
find_program(CODEGEN codegen REQUIRED) # supplied by a prior native build
else()
add_executable(codegen tools/codegen.cpp)
set(CODEGEN $<TARGET_FILE:codegen>)
endif()
add_custom_command(
OUTPUT ${CMAKE_BINARY_DIR}/tables.c
COMMAND ${CODEGEN} ${CMAKE_SOURCE_DIR}/spec.txt ${CMAKE_BINARY_DIR}/tables.c
DEPENDS ${CMAKE_SOURCE_DIR}/spec.txt
)
Build natively first, install the tool somewhere on the path, then build for WebAssembly pointing at it. Two build directories side by side is the normal arrangement and keeps both working.
Keeping the native build first-class
The strongest predictor of a smooth port is that the native build keeps working and keeps being used. Two habits make that happen.
Run the native test suite in CI on every change, and the WebAssembly build alongside it. The moment the native build is allowed to rot, debugging becomes a browser-only activity and every investigation gets several times slower.
Keep platform differences behind a small number of guarded blocks rather than scattering #ifdef __EMSCRIPTEN__ through the code. A file that compiles differently in twenty places is one nobody can
reason about; a platform/ directory with two implementations of the same header is one anybody can.
if(EMSCRIPTEN)
target_sources(app PRIVATE src/platform/web.cpp)
else()
target_sources(app PRIVATE src/platform/native.cpp)
endif()
That arrangement also makes the port reviewable: the diff that adds WebAssembly support is one new file and a handful of build lines, rather than a hundred conditionals across the codebase.
Expected output
A successful configure reports the Emscripten compiler and the target, which is the first thing to check when something behaves unexpectedly:
emcmake cmake .. -G Ninja -DCMAKE_BUILD_TYPE=Release
configure: cmake -DCMAKE_TOOLCHAIN_FILE=.../Emscripten.cmake ..
-- The C compiler identification is Clang 19.0.0
-- The CXX compiler identification is Clang 19.0.0
-- Check for working C compiler: .../emsdk/upstream/emscripten/emcc - skipped
-- Configuring done
-- Generating done
cmake --build .
[38/38] Linking CXX executable app.js
ls -l app.js app.wasm app.data
28_104 app.js
1_248_602 app.wasm
4_119_552 app.data
Three files, and all three must be deployed together — a common deployment mistake is shipping the
JavaScript and forgetting the .data, which fails at startup with a fetch error.
Gotchas
-ssettings intarget_compile_options. They are link settings; usetarget_link_options.- Settings passed as one string. Quote each one separately or they are ignored.
CMAKE_EXECUTABLE_SUFFIXunset. You get a bare module with no loader and wonder where the glue went.- Host tools built for the wrong target. Guard with
CMAKE_CROSSCOMPILINGand build natively first. find_packagefinding host libraries. The toolchain file restricts search paths; a library found from/usr/libin a WebAssembly build is a configuration error waiting to fail at link time.- Ports flags on only one side. Needed at both compile and link.
Performance note
For a mid-sized C++ project of roughly 60,000 lines, a cold WebAssembly build with Ninja took 3 minutes
40 seconds against 1 minute 50 seconds natively, with the difference mostly in link-time optimisation.
Incremental builds were comparable. Using ccache with Emscripten works and roughly halves repeat cold
builds, which is worth the two lines of configuration on any project where CI builds from scratch.
Frequently Asked Questions
Why does find_package behave differently here?
The toolchain file sets the find-root path so host libraries are not picked up, which is correct and
occasionally surprising when a package that “was installed” is suddenly not found. Provide the dependency
through a port, a subdirectory or FetchContent instead of installing it on the host.
Can I keep one CMakeLists for both targets?
Yes, and you should. Guard the Emscripten-specific parts with if(EMSCRIPTEN) and the native parts with
else(). A forked build file drifts within a month.
What about CMake presets?
They work well here: define a wasm preset with the toolchain file and the settings, and a native
preset without. Then cmake --preset wasm replaces remembering the emcmake invocation.
Can I use ccache with Emscripten?
Yes, by setting CMAKE_C_COMPILER_LAUNCHER and its C++ counterpart to ccache. Cold rebuilds roughly
halve, which matters most in CI where every build starts from nothing.
How do I run the tests?
Natively, for almost everything. For the tests that must run as WebAssembly, emcmake plus node as the
test runner works through add_test with a NODE command, which keeps them inside ctest.
Related
- Using the Emscripten file system API — what
--preload-filesets up. - Migrating legacy C code to WebAssembly — the code changes a port needs.
- Porting a C game loop to Emscripten — the structural change a real application needs.
A final note on the emsdk itself: pin its version in the repository and activate it from a script rather than relying on whatever is on the machine. Emscripten’s defaults change between releases, and a build that silently switches compiler version produces size and behaviour differences nobody can attribute.
← Back to C/C++ to Wasm with Emscripten