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.

Emscripten file systems in Node MEMFS keeps files in memory, so data must be copied in and out by the host. NODEFS mounts specific host directories into the virtual file system. NODERAWFS bypasses the virtual file system and gives the program direct access to the real file system, which suits command-line tools. MEMFS (default) in-memory virtual files host copies data in and out portable to browsers libraries, browser parity NODEFS mount mount chosen host directories virtual paths map to real ones explicit and limited controlled access NODERAWFS direct real file access paths behave like native Node only command-line tools

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.

A ported CLI tool running under Node Node starts the Emscripten glue, which instantiates the module and calls main with process.argv. File operations go directly to the real file system through NODERAWFS. When main returns, EXIT_RUNTIME flushes output and exits the process with the program's exit code. node convert.js args process starts glue instantiates argv from process.argv main runs fopen → real fs return / exit(code) EXIT_RUNTIME flushes process exit code same as native

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 NODERAWFS for real file access.
  • Running a Node-only build in a browser. ENVIRONMENT=node removes browser support. Build separately.
  • Large stdin reads. Synchronous stdin is slow for big inputs. Read files instead.
  • Forgetting the .wasm in the package. List it in files and 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.

Converting a 200 MB CSV file Seconds to convert a two-hundred-megabyte CSV file with the native binary, the Emscripten build under Node with NODERAWFS, and the Emscripten build with MEMFS and explicit copies. seconds per conversion native binary 2.4 s Node + NODERAWFS 3.1 s Node + MEMFS + copies 3.6 s

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.

← Back to C/C++ to Wasm with Emscripten