WebAssembly Tutorial: Call JavaScript functions 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. Learn how to call JavaScript functions from C++ with Emscripten and React.

Posted On: Saturday, 09-May-2026
WebAssembly Tutorial: Call JavaScript functions from C++ with Emscripten and React

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 import <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;
  }

  // Generate random numbers in the range [0, 100] for each element in the gMatrix vector.
  std::generate(gMatrix.begin(), gMatrix.end(), []() { return gDist(gRng); });
  // gOnFrame is the callback function pointer that points at JavaScript.
  gOnFrame(gMatrix.data(), static_cast<int>(gMatrix.size()), gRows, gCols); 
}

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. So the C++ side passes the current pointer to the first element, the element count, then rows and cols.

matrixStream.cpp
#ifdef __EMSCRIPTEN__
    // Schedule the emit_frame function to be called every 10 milliseconds.
    // Similar to JavaScript's "setInterval".
    gIntervalId = emscripten_set_interval(emit_frame, 10, nullptr);
#endif

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.

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','HEAP32'] \
  -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 hook. 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.js 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.

loadWasm@useMatrixStream.ts

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++.

startStream@useMatrixStream.ts
const FRAME_RENDER_THROTTLE_MS = 50;

const startStream = useCallback(async () => {
    const mod = await loadWasm();
    if (!mod || unmountedRef.current) return;

    mod.ccall("stop_matrix_stream", null, [], []);

    // Register the callback if it is not already registered.
    if (callbackIdRef.current == null) {
      try {
        // Register the callback function.
        const callbackId = mod.addFunction((ptr: number, len: number, frameRows: number, frameCols: number) => {
          // If the component is unmounted, do not register the callback.
          if (unmountedRef.current) return;
          // Calculate the start index of the frame in the Wasm heap. This is the byte offset of the frame.
          const start = ptr >> 2; // Same as `ptr / 4` because each int is 4 bytes.
          // Throttle the frame rendering.
          const now = performance.now();
          if ((now - lastRenderAtRef.current) < FRAME_RENDER_THROTTLE_MS) return;
          lastRenderAtRef.current = now;
          // Get the HEAP32 view of the Wasm heap.
          const heap32 = mod.HEAP32;
          if (!heap32) return;
          // Slice the frame from the Wasm heap. This is the frame data.
          const frame = heap32.slice(start, start + len);
          // Set the matrix state with the new frame.
          setMatrix(new Int32Array(frame));
          setFrameCount((count) => count + 1);
          setLastFrameInfo(`${frameRows} x ${frameCols}`);
        }, "viiii");
        callbackIdRef.current = callbackId;
      } catch (error) {
        console.error(error);
        setStatus("Callback registration failed.");
        return;
      }
    }

    // Start the matrix stream.
    const started = mod.ccall(
      "start_matrix_stream", // Remember, this is the name of the function in the C++ code.
      "number", // The return type of the function.
      ["number", "number", "number"], // The argument types of the function.
      [rows, cols, callbackIdRef.current], // The argument values of the function.
    ) as number;
    // If the matrix stream started successfully, set the running state and frame count.
    if (started === 1) {
      setIsRunning(true);
      setFrameCount(0);
      setStatus("Streaming random matrix updates every 10ms.");
      return;
    }

    // If the matrix stream did not start successfully, set the running state and status. 
    setIsRunning(false);
    setStatus(`Could not start stream (rows=${rows}, cols=${cols}, callbackId=${String(callbackIdRef.current)}).`);
  }, [cols, loadWasm, rows]);

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.

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&lt;int&gt;.

A 4 x 4 example: where do `ptr`, `ptr >> 2`, and `len` land in memory?

1. Logical 4 × 4 layout (what std::vector&lt;int&gt; exposes). With four rows and four columns there are sixteen int cells. C++ stores them row-major in one flat backing array (gMatrix.data()), so you read left to right then top to bottom exactly like indexing a small spreadsheet. Emscripten calls your JavaScript with ptr as the byte address of element a0, and len == 16 because there are sixteen elements, not sixteen bytes.

cols index:    0    1    2    3
          +----+----+----+----+
row 0      | a0 | a1 | a2 | a3 |
          +----+----+----+----+
row 1      | a4 | a5 | a6 | a7 |
          +----+----+----+----+
row 2      | a8 | a9 |a10 |a11 |
          +----+----+----+----+
row 3      |a12 |a13 |a14 |a15 |
          +----+----+----+----+

Flat buffer order:
a0 a1 a2 a3 | a4 a5 a6 a7 | a8 a9 a10 a11 | a12 a13 a14 a15

len = rows * cols = 4 * 4 = 16 (element count)

2. The same sixteen cells as bytes in Wasm linear memory. Each matrix entry is four bytes. Suppose the chunk begins at byte 5024; that is what ptr would be after gMatrix.data() is converted to an integer offset in JS. Sixteen ints occupy sixty-four contiguous bytes (16 * 4), with each value packed as four adjacent bytes starting on a multiple-of-four boundary, which keeps ptr >> 2 morally identical to dividing by four.

Matrix element:    a0     a1     a2     a3       a4  ...               a15
Byte address:      5024   5028   5032   5036     5040 ...               5084
                     |<- 4 bytes per int ->|

ptr ---> 5024

Bytes covered: 5024 inclusive through 5087 inclusive (64 bytes total)

3. The same backing store viewed as HEAP32 indices. HEAP32 is layered on the Wasm ArrayBuffer but each index covers four bytes at once. The word index therefore equals the byte pointer divided by four. With ptr = 5024 you compute start = ptr >> 2, which evaluates to 1256, meaning HEAP32[1256] is a0, HEAP32[1257] is a1, stepping forward until HEAP32[1271] holds a15. Shifting two bits right is simply the fast way disciplined C++ heaps already guarantee alignment for typed reads.

ptr   = 5024                 (byte offset from base of Wasm memory)
start = ptr >> 2 = 1256       (same as ptr / 4 for aligned int data)

HEAP32 index   1256    1257    1258    1259   | 1260 .. .. |    1271
           +-------+-------+-------+--------+-----+--+-----------+
values       |  a0   |  a1   |  a2   |  a3    | ... a4 ..        | a15 |
           +-------+-------+-------+--------+-----+--+-----------+
                ^
             start (= first int32 lane over the matrix)

4. How heap32.slice(start, start + len) lines up. The callback wants a snapshot of sixteen consecutive words ending where the matrix ends while excluding the seventeenth HEAP slot. slice takes a half-open range of typed-array indices [start .. start + len), so boundaries line up cleanly with sixteen matrix cells copied out as one buffer for React regardless of throttle timing.

heap32.slice(start, start + len)   where len === 16

HEAP32:  ... | 1256 | 1257 | 1258 | ... | 1270 | 1271 | 1272 ...
              ^                              ^
            start                         last copied index
                                           (1256 + 16 - 1)

Range typed as [1256 .. 1272) sixteen int32 indexes, not sixteen bytes.

5. Mini numeric example (still 4 × 4). If ptr = 4096:

Assume ptr = 4096:

start = ptr >> 2 = 1024
HEAP32[1024] = matrix[0][0]
HEAP32[1025] = matrix[0][1]
HEAP32[1026] = matrix[0][2]
HEAP32[1027] = matrix[0][3]
HEAP32[1028] = matrix[1][0]
...
HEAP32[1039] = matrix[3][3]
slice = HEAP32.subarray/slice(1024, 1040)  // 1039 + 1 = 1040
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.

Status: Loading wasm module on first interaction... | Running: no | Last frame: 10 x 10 | Frames rendered: 0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0

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

ANAbhilash Nayak
Last Updated on: 09-05-2026