WebAssembly Tutorial: Call JavaScript from C++ with Emscripten and React
WebAssembly tutorial (part 2): stream matrix data from C++ to JavaScript with Emscripten addFunction, ccall, HEAP32, emscripten_set_interval, and interop patterns, plus throttled updates in a React heatmap.
If part one made WASM feel like a turbo button for heavy loops, part two is where the fun gets very real. In part one we called C++ from JavaScript and got a single result back. Nice and clean. Today we flip that flow around and make C++ talk back to JavaScript repeatedly through callbacks, while passing what looks and behaves like a complex object.
The use case is intentionally simple and very visual. JavaScript passes row and column counts plus a callback into C++. C++ builds an x * y matrix, fills it with random integers from 0 to 100, and schedules work on the Emscripten event loop so a tick runs about every 10 ms. Each tick refills the buffer and invokes the callback with a pointer into Wasm linear memory. On the client we throttle how often we copy that memory into React state so the heatmap stays smooth without pretending the browser repainted a hundred-cell grid one hundred times per second. The grid itself is virtualized with react-window, so even a 100 by 100 view stays usable.
Why this pattern matters
Real apps do not always work as request and response. Sometimes native code keeps computing and your UI needs streaming updates. Think market data heatmaps, simulation dashboards, telemetry panels, game state snapshots, or progressive algorithm output. In these cases, JavaScript should not keep polling in a tight loop and C++ should not block the browser event loop either.
The practical pattern is to stream binary data from WASM memory and pair it with lightweight metadata. Instead of trying to pass a JavaScript object graph, we pass (ptr, len, rows, cols). That is pointer plus shape metadata. It is predictable, fast, and friendly to both sides of the boundary. The heavy integers stay in one contiguous buffer; the crossing cost is a handful of scalars.
C++ side: random fills, timer, and the callback contract
In C++, we keep a single backing buffer (std::vector<int>), dimensions, a handle to the repeating timer, and a C-style function pointer that ultimately points at JavaScript. We pull in <algorithm> so we can call std::generate and refill the whole vector in one expressive pass. We pull in <random> so we are not using legacy rand(): std::mt19937 is a well-studied 32-bit Mersenne Twister, paired with std::uniform_int_distribution<int>(0, 100) so each draw is uniform on the closed range including both endpoints. Seeding uses std::random_device{}(). That is fine for a visual demo. If you need deterministic screenshots or golden tests in CI, swap in a fixed seed for mt19937 so the stream repeats the same sequence.
Nothing magical crosses the Wasm boundary as a bulk byte copy on the C++ side. The callback type matrix_callback_t is void (*)(int *, int, int, int). Inside emit_frame we regenerate values in place, then call gOnFrame(gMatrix.data(), static_cast<int>(gMatrix.size()), gRows, gCols). So native code passes the current pointer to the first element, the element count, then rows and cols. Serialization and JSON never enter the story unless you add them yourself.
matrixStream.cpp
static void emit_frame(void *)
{
if (!gOnFrame || gRows <= 0 || gCols <= 0)
{
return;
}
std::generate(gMatrix.begin(), gMatrix.end(), []() { return gDist(gRng); });
gOnFrame(gMatrix.data(), static_cast<int>(gMatrix.size()), gRows, gCols);
}
Timers use emscripten_set_interval from emscripten/eventloop.h. Think of it as the C side of the house cooperating with the same browser timing infrastructure that backs setInterval: you schedule emit_frame, pass 10 for roughly ten milliseconds between invocations, and keep a returned id so emscripten_clear_interval can stop the loop in stop_matrix_stream. The callback signature is void (*)(void *) because the API supports optional user data; we pass nullptr and keep shared state in file-scope statics so the trampoline stays simple.
For exports, extern "C" keeps names stable for ccall, and EMSCRIPTEN_KEEPALIVE stops the linker from discarding symbols that only JavaScript invokes. A forward declaration of stop_matrix_stream appears before start_matrix_stream so the start path can call stop first and avoid stacked intervals when dimensions change.
matrixStream.cpp
#ifdef __EMSCRIPTEN__
gIntervalId = emscripten_set_interval(emit_frame, 10, nullptr);
#endif
Notice the callback signature at the JavaScript boundary: void (*)(int *, int, int, int). The first parameter is a pointer to matrix data in Wasm memory, then total element count, then rows and cols. That shape metadata is important. Without it, JavaScript receives a flat list of numbers and has to guess dimensions like a detective with no clues.
Build flags you should not skip
We compile with Emscripten and keep the runtime methods needed for callback bridging.
custom-make.sh
emcc ./matrixStream.cpp -O2 -DNDEBUG \
-sWASM=1 \
-sMODULARIZE=1 \
-sEXPORT_ES6=1 \
-sENVIRONMENT=web \
-sALLOW_MEMORY_GROWTH=1 \
-sALLOW_TABLE_GROWTH=1 \
-sEXPORTED_FUNCTIONS=['_start_matrix_stream','_stop_matrix_stream'] \
-sEXPORTED_RUNTIME_METHODS=['cwrap','ccall','addFunction','removeFunction'] \
-o ./matrixStream.js
The stars of this command are addFunction and removeFunction. addFunction inserts a JavaScript function into the Wasm indirect function table and returns an integer index that C++ treats as matrix_callback_t. removeFunction releases that entry on teardown. ALLOW_TABLE_GROWTH avoids surprises when the table needs more slots for dynamic callbacks.
Why prefer std::mt19937 over rand() for this demo?▾
rand() is narrow, implementation-defined, and shares global hidden state. std::mt19937 with std::uniform_int_distribution is the ordinary modern C++ way to get predictable statistical behavior and local state you control. For tests, seed the engine explicitly instead of random_device.
Why static globals and `emit_frame(void *)` instead of a capturing lambda?▾
emscripten_set_interval takes a plain function pointer. A C++ lambda that captures environment variables is not a plain function pointer unless you use indirection. File-scope statics plus a small free function keep the callback shape void (*)(void *). In larger codebases you can pass a context pointer as the third argument to emscripten_set_interval and route through a struct.
When would Embind or `emscripten::val` replace raw function pointers?▾
Embind is attractive when you want classes, std::function, or automatic marshalling. Here we wanted the smallest C ABI and a clear story about pointers and metadata. Embind adds glue bytes and a different mental model. Pick it when the ergonomic win outweighs the cost.
Why not drive `emit_frame` from `requestAnimationFrame` in JavaScript?▾
You absolutely can. It couples visual updates to the display refresh rate and avoids ticking work in hidden tabs the same way rAF does. The interval approach in this article isolates “native producer interval” from “UI sampling,” and the React hook already throttles presentation.
JavaScript side: load the module, register the pointer, read HEAP32
The interactive UI lives in matrixCallbackUI.tsx, but the bridge logic for this demo lives in useMatrixStream.ts. That keeps the heatmap component focused on layout while the hook owns Emscripten glue.
We load /wasm/matrixStream.js with a dynamic import(). The comment webpackIgnore: true tells Next not to rewrite the URL into a hashed chunk name, because the Emscripten bundle sits in public/wasm at stable paths. The default export is an async factory; we call it with locateFile so sibling matrixStream.wasm resolves next to the emitted script. The resulting Module is cached in a ref so starting and stopping streams does not pay for a second network fetch.
Registration uses addFunction(callback, "viiii"): Emscripten signature notation for a void return with four 32-bit integer parameters, matching the C callback. We pass the returned id as the third argument to start_matrix_stream alongside rows and cols. When the C++ timer fires, the runtime invokes our JavaScript function with the same four logical arguments. Inside, ptr is a byte offset into Wasm linear memory (what C calls a pointer, the JS world sees as a number). HEAP32 is a typed array view over that memory where each index is one 32-bit word. Because four bytes fit in one word, the word index is ptr / 4, and when pointers are aligned for int, that division is exact and people usually write ptr >> 2.
We then slice the range [start, start + len) and copy into a fresh Int32Array for React state. Holding only a view into live Wasm memory would be risky: the next emit_frame overwrites the same buffer, and if memory grows, existing typed array views can become detached.
The hook also throttles UI work: C++ may call back every 10 ms, but FRAME_RENDER_THROTTLE_MS gates how often we advance performance.now() and call setMatrix. That is why the status line and the grid do not literally chase 100 Hz updates.
useMatrixStream.ts
const FRAME_RENDER_THROTTLE_MS = 50;
const loadWasm = useCallback(async () => {
if (moduleRef.current) return moduleRef.current;
const mod = await import(/* webpackIgnore: true */ WASM_MODULE);
const createModule = mod.default as WasmFactory;
const instance = await createModule({
locateFile: (path) => `${WASM_BASE}/${path}`,
});
moduleRef.current = instance;
return instance;
}, []);
After await loadWasm() returns, startStream registers the callback once, then calls into C++. The excerpt below omits useCallback wrappers and the guard that skips work when the component is unmounted, but it matches the real control flow.
useMatrixStream.ts
const mod = await loadWasm();
if (!mod || unmountedRef.current) return;
const callbackId = mod.addFunction((ptr, len, frameRows, frameCols) => {
if (unmountedRef.current) return;
const start = ptr >> 2;
const now = performance.now();
if (now - lastRenderAtRef.current < FRAME_RENDER_THROTTLE_MS) return;
lastRenderAtRef.current = now;
const heap32 = mod.HEAP32;
if (!heap32) return;
const frame = heap32.slice(start, start + len);
setMatrix(new Int32Array(frame));
setLastFrameInfo(`${frameRows} x ${frameCols}`);
}, "viiii");
mod.ccall(
"start_matrix_stream",
"number",
["number", "number", "number"],
[rows, cols, callbackId],
);
When the user clicks stop, we only call stop_matrix_stream so the timer and native callback pointer clear; we keep the addFunction slot because another start may follow. On unmount, we stop the stream and removeFunction so the indirect table entry is not leaked across route changes.
What breaks if I skip `ptr >> 2` or use the wrong HEAP view?▾
HEAP32 is indexed in 32-bit words. A byte pointer from Wasm must become a word index. Using the byte value directly as an index reads the wrong cells, and using HEAPF64 or HEAP8 without matching the C type would reinterpret bits incorrectly. Align int data on four-byte boundaries as we do with std::vector<int>.
Why copy twice: `heap32.slice` and then `new Int32Array`?▾
slice already copies out of the Wasm heap view. The extra Int32Array constructor hands React a plain buffer that will not alias the next frame’s mutation. In tight loops some teams skip the second copy; here we favor clarity and stale-render safety.
Does `ALLOW_MEMORY_GROWTH` invalidate my views?▾
Yes, that is the sharp edge. When linear memory grows, old typed array views on Module.HEAP* can detach. If you cache indices into HEAP32 across an allocation that might grow memory, re-read the pointer and heap view after growth. Copying into JS-owned arrays for each frame sidesteps that for this pattern.
Why `removeFunction` on unmount but not on the Stop button?▾
Stopping the stream clears the timer and native-side callback pointer, but the JS function table entry is still useful if the user presses Start again. On unmount there is no component left to receive callbacks, so we release the slot to avoid leaking table indices during navigation.
Could we share memory with `SharedArrayBuffer` and skip copies?▾
Zero-copy sharing is possible in advanced setups with atomics and cross-origin isolation headers, but deployment and security constraints are real. This tutorial stays on the boring, portable path: short copies on a cadence you control.
Live demo: matrix stream with virtualized cells
Use the two dropdowns to choose x and y between 1 and 100. C++ still regenerates the matrix on a 10 ms cadence in this build, while the hook applies its own throttle before updating React state. The UI renders with react-window so large grids stay tractable.
C++ matrix stream to JavaScript callback
JS passes dimensions and a callback to C++. C++ creates a random matrix and pushes fresh frames every 10ms.
What we learned
In this chapter we crossed a major WASM milestone: C++ can now push data back into JavaScript continuously, not just return one result at the end. We also used a production-friendly data contract by passing pointer plus metadata instead of forcing object serialization across the boundary.
All these work on my machine, but how do we get this shipped to our users? In the next part of this series, I will go deeper into the settings up CI/CD pipeline and how to deploy this to a production environment. Stay tuned!
References
- Emscripten: Interacting with code (
ccall,cwrap,EXPORTED_RUNTIME_METHODS) - Emscripten: preamble.js API (
HEAP32,ccall, pointer indexing patterns such as>> 2) - Emscripten:
eventloop.h(sources,emscripten_set_interval) - WebAssembly spec: memories
- MDN:
WebAssembly.Memory - cppreference:
std::mt19937 - cppreference:
std::generate
