Running Emscripten Output in Node.js
This page answers one task: a C or C++ program or library compiled with Emscripten must run in Node.js — as a command-line tool that reads and writes real files, as a library imported by other Node code, or both — with correct exit codes, arguments and file access.
Prerequisites
- [ ] Emscripten (emsdk) and Node.js 18 or newer.
- [ ] A C/C++ program with a
main(for a CLI) or exported functions (for a library). - [ ] An idea of which files the program needs to access.
What changes when the host is Node
Emscripten’s output is a .wasm binary plus JavaScript glue that implements the C runtime: the file system, standard I/O, time, exit, and memory
growth. By default the glue supports several environments at once — web, workers and Node — detecting where it runs. Targeting Node specifically lets
Emscripten drop browser code paths and use Node APIs directly: fs for files, process.argv for arguments, process.exit for exit codes, and stdout and
stderr as real streams.
The most important difference is the file system. In the browser, Emscripten provides an in-memory file system (MEMFS) that the program sees as its
disk. In Node, you can give the program direct access to the real file system with NODERAWFS, so a ported command-line tool reads and writes files
exactly as it did natively — no preloading, no copying.
Step 1 — build a command-line tool
For a tool with a main that reads and writes files:
emcc src/*.c -O2 -o dist/convert.js \
-sENVIRONMENT=node -sNODERAWFS=1 -sEXIT_RUNTIME=1 \
-sALLOW_MEMORY_GROWTH -sSTACK_SIZE=1mb
node dist/convert.js input.csv output.parquet
echo $? # the program's exit code
-sENVIRONMENT=node removes browser code from the glue. -sNODERAWFS=1 routes fopen and friends straight to Node’s fs, so relative paths resolve against
the working directory as users expect. -sEXIT_RUNTIME=1 makes returning from main or calling exit() terminate the process with that code, flushing
stdio — without it, the runtime stays alive and buffered output may be lost. Make the file executable with a shebang wrapper if you distribute it as a CLI.
Step 2 — build a library for other Node code
For a library, produce an ES module factory and export functions instead of running main:
emcc src/lib.c -O2 -o dist/lib.mjs \
-sENVIRONMENT=node -sMODULARIZE -sEXPORT_ES6 \
-sEXPORTED_FUNCTIONS=_compress,_malloc,_free -sEXPORTED_RUNTIME_METHODS=HEAPU8
import createModule from "./dist/lib.mjs";
const m = await createModule();
const out = compress(m, inputBytes); // a wrapper that copies in, calls _compress, copies out
The factory locates the .wasm file next to the .mjs using import.meta.url, so it works from any working directory. For a CommonJS consumer, omit
-sEXPORT_ES6 to get a require-able factory. Wrapping exports in idiomatic JavaScript is covered in
calling C functions from JavaScript with ccall and cwrap.
Step 3 — pass arguments, environment and stdin
With -sENVIRONMENT=node, main receives process.argv after the script name as argv. Environment variables are visible to getenv when the glue copies
them in — Emscripten exposes Node’s environment by default in Node builds. Standard input works for reading piped data, but reads are synchronous; very
large inputs are better read from files with NODERAWFS. Output written with printf is line-buffered; call fflush(stdout) before long computations
if progress output must appear immediately.
Step 4 — consider a standalone WASI build instead
If the program uses only standard C I/O, you can avoid Emscripten’s JavaScript glue entirely: build with -sSTANDALONE_WASM (or with wasi-sdk) to get a
WASI module, then run it with node:wasi, wasmtime, or any WASI runtime. That makes the same binary usable outside Node, at the cost of Emscripten’s
richer JavaScript integration. The WASI route is described in
running WASI modules in Node.js.
Step 5 — package and publish
For an npm package, ship the .mjs (or .js) glue and the .wasm file together, list both in the package’s files, and add a bin entry for CLIs
pointing at a small launcher script. Test the packed tarball in a clean directory, since paths and missing files are the usual failures. Native-equivalent
behaviour matters to users: check exit codes, error messages on stderr, and that the tool handles relative paths, spaces in file names and Windows paths.
Performance in Node versus native
The same program usually runs 1.1–1.8× slower under Node than as a native binary, depending on how much it relies on SIMD, 64-bit arithmetic or system
calls. Startup adds compilation time — tens of milliseconds for a medium-sized module — which matters for tools invoked many times in a loop, such as from
a build system. Node’s compile cache does not persist WebAssembly code by default, so each invocation recompiles; for frequently invoked tools, consider a
long-running worker process that compiles once, or keep the tool’s binary small. File I/O through NODERAWFS performs close to native because it maps to
Node’s synchronous fs calls; I/O through MEMFS adds copying.
Using the same build in the browser too
A build targeting only Node will not run in browsers, and vice versa. If both are needed, either build twice with different ENVIRONMENT settings — and
different file-system choices, since NODERAWFS is Node-only — or build once with -sENVIRONMENT=web,node and use MEMFS everywhere, copying files in and
out in each host. Two builds are usually cleaner: the Node CLI gets real file access and minimal glue; the browser library gets a small web-only glue. Keep
the C code identical and the differences in the build scripts. Publishing both builds from one package with conditional exports is described in
targeting Node and browsers from one Wasm package.
Debugging under Node
Node’s inspector supports WebAssembly debugging: run node --inspect-brk dist/convert.js input.csv and attach Chrome DevTools to step through the C source
when the build includes DWARF information (-g). For crashes, -sASSERTIONS=2 and -sSAFE_HEAP give clearer errors, and AddressSanitizer works under Node
as in the browser. Because Node runs the same V8 as Chrome, behaviour and stack traces match the browser closely, which makes Node a convenient place to
reproduce browser bugs from the command line.
Signals, timeouts and long-running tools
Command-line tools are often interrupted: users press Ctrl-C, build systems kill slow steps, CI jobs time out. Native programs receive signals and can clean
up; an Emscripten program running in Node does not see POSIX signals, because the glue does not implement them. Node itself receives SIGINT, and its
default behaviour terminates the process immediately — which is usually acceptable for tools that write output atomically (to a temporary file, renamed at
the end), and harmful for tools that write in place. Handle the interruption in JavaScript if cleanup matters: install a process.on("SIGINT", …)
handler in the launcher that removes partial output, then exits with code 130 as shells expect. Long computations block Node’s event loop, so the handler
only runs between calls into the module; split very long work into steps if the tool must stay responsive to interruption, or run it in a worker that the
main thread can terminate.
Expected output
node dist/convert.js input.csv output.parquet converts the file, writes real files relative to the working directory, prints errors to stderr and exits
with the same codes as the native build; the library build imports cleanly from ES modules with await createModule().
Gotchas
- Missing
EXIT_RUNTIME. Exit codes are lost and output may not flush. Enable it for CLIs. - MEMFS in a CLI. Files are not on disk. Use
NODERAWFSfor real file access. - Running a Node-only build in a browser.
ENVIRONMENT=noderemoves browser support. Build separately. - Large stdin reads. Synchronous stdin is slow for big inputs. Read files instead.
- Forgetting the
.wasmin the package. List it infilesand test the packed tarball.
Performance note
A CSV-to-Parquet converter took 2.4 s natively, 3.1 s under Node with NODERAWFS, and 3.6 s with MEMFS and explicit copies, on a 200 MB input. Compilation
added 45 ms per invocation.
Frequently Asked Questions
Can the program spawn child processes?
No — Emscripten does not implement fork/exec. Run subprocesses from JavaScript instead.
Does NODERAWFS work on Windows?
Yes; paths follow Node’s handling of Windows paths.
Can I use threads in Node?
Yes, with -pthread; Node does not need isolation headers for SharedArrayBuffer.
How do I read binary files from the library build?
Read them with Node’s fs in JavaScript and copy the bytes into the module’s memory, or mount a directory with NODEFS.
Does Ctrl-C reach the C program?
No — handle SIGINT in the JavaScript launcher, clean up partial output, and exit with code 130.
Related
- Using the Emscripten file system API — MEMFS, NODEFS and IDBFS.
- Emitting ES modules from Emscripten — the factory output.
- Replacing a native Node addon with Wasm — libraries for Node.
- Debugging Wasm in Node.js with the inspector — stepping through C in Node.
← Back to C/C++ to Wasm with Emscripten