Registering JavaScript Callbacks with addFunction

This page answers one task: a C or C++ library compiled with Emscripten takes a callback — a comparison function, a progress handler, a log sink — and you want to pass it a JavaScript function.

Prerequisites

  • [ ] emsdk 3.1.x.
  • [ ] A C API that takes a function pointer, such as void set_progress_handler(void (*cb)(int pct));.
  • [ ] Glue built with -sEXPORTED_RUNTIME_METHODS that includes addFunction and removeFunction.

Why a JavaScript function needs a table slot

In compiled C, a function pointer is an integer: an index into the module’s function table. call_indirect looks up that index, checks the function’s signature against the expected type, and calls it. For C code to call a JavaScript function through a pointer, that JavaScript function must therefore be in the table, with a signature that matches.

The table only holds WebAssembly functions, so the JavaScript function first needs a WebAssembly wrapper: a tiny function that imports the JavaScript one and has the right signature. Emscripten’s addFunction does exactly that — it builds the wrapper, finds or creates a free table slot, stores the wrapper there, and returns the slot index, which C can use as an ordinary function pointer. The mechanics of the table itself are covered in exporting a table to JavaScript.

From a JavaScript function to a C function pointer addFunction wraps the JavaScript function in a small WebAssembly function with the requested signature, stores it in a free table slot, and returns the slot index. C stores that index as a function pointer and calls it with call_indirect, which reaches the JavaScript code through the wrapper. JS function (pct) => … addFunction(fn, 'vi') builds a Wasm wrapper table slot 57 wrapper stored C: cb = 57 a function pointer call_indirect reaches the JS code

Step 1 — build with table growth allowed

addFunction usually needs to grow the table to make room, which is off by default:

emcc -O2 src/progress.c -o dist/app.mjs \
  -sMODULARIZE -sEXPORT_ES6 \
  -sALLOW_TABLE_GROWTH=1 \
  -sEXPORTED_FUNCTIONS=_set_progress_handler,_process_file,_malloc,_free \
  -sEXPORTED_RUNTIME_METHODS=addFunction,removeFunction,ccall

Without ALLOW_TABLE_GROWTH, addFunction fails with an error that the table is full, unless you reserved slots in advance with -sRESERVED_FUNCTION_POINTERS=N in older Emscripten versions.

Step 2 — register the callback with the right signature

The second argument to addFunction is a signature string: the return type followed by parameter types, using v for void, i for 32-bit integers and pointers, j for 64-bit integers, f for float and d for double:

// progress.c
typedef void (*progress_cb)(int pct);
static progress_cb handler;

void set_progress_handler(progress_cb cb) { handler = cb; }

void process_file(const unsigned char *data, int len) {
  for (int i = 0; i < len; i += 4096) {
    /* ... work ... */
    if (handler) handler((int)((long long)i * 100 / len));
  }
  if (handler) handler(100);
}
import createApp from "./dist/app.mjs";
const app = await createApp();

const onProgress = (pct) => { progressBar.value = pct; };
const ptr = app.addFunction(onProgress, "vi");      // void(int)
app._set_progress_handler(ptr);

The signature must match the C type exactly. A mismatch is not detected by addFunction; it shows up later as call_indirect trapping with function signature mismatch, at the moment C calls the pointer.

Step 3 — call it from C and watch it reach JavaScript

const bytes = new Uint8Array(await (await fetch("/large.bin")).arrayBuffer());
const p = app._malloc(bytes.length);
app.HEAPU8.set(bytes, p);
app._process_file(p, bytes.length);       // progress bar updates through the callback
app._free(p);

Each handler(pct) in C becomes a call_indirect into the wrapper, which converts the argument and calls onProgress. Calls into JavaScript are relatively cheap, but they are boundary crossings; a callback invoked per byte would dominate the run time. Design C APIs to call back at a sensible granularity — per chunk, per row, per percent — as discussed in measuring JS-to-Wasm call overhead.

Step 4 — release the slot when done

Table slots are a finite resource, and a callback registered per operation that is never released leaks one slot each time. Release with removeFunction once C will no longer call the pointer:

app._set_progress_handler(0);             // tell C to stop using it first
app.removeFunction(ptr);                  // slot becomes free for reuse

The order matters: clear the C side’s reference before removing the function, or a later call through the stale pointer reaches whatever function next occupies that slot — possibly a different callback with the same signature, which is a confusing bug.

Callback lifetimes and how to manage them Common callback patterns, how long the C side holds the pointer, and when to call removeFunction. pattern C holds pointer release global log or panic handler for the program's life never per-operation progress callback during one call after the call returns registered event listener until unregistered after C unregisters it comparison function for qsort during one call after the call returns

Step 5 — consider the alternatives

addFunction is the general solution, and for many APIs a simpler one exists. If you control the C side and the set of callbacks is fixed, have C call an imported JavaScript function directly instead of a pointer — EM_JS or a --js-library import — which needs no table slot at all. If you use Embind, it accepts JavaScript functions for std::function parameters and manages the wrapping for you; see binding C++ libraries with Embind. And in Rust with wasm-bindgen, closures cross the boundary with Closure, a different mechanism with its own lifetime rules, covered in passing closures between Rust and JavaScript.

Errors thrown inside the callback

A JavaScript callback can throw, and what happens next depends on the C code above it. The exception propagates out of the wrapper, through the call_indirect, and up through every C frame back to whichever JavaScript code called into the module — C has no chance to run cleanup, release locks or free memory on the way. In a long-running library that leaves the module in an inconsistent state: a mutex held forever, a buffer half written, an internal counter never decremented.

Catch errors inside the callback and turn them into something C understands — a return code, a flag the C side checks:

const ptr = app.addFunction((pct) => {
  try {
    progressBar.value = pct;
    return 0;                         // continue
  } catch (err) {
    console.error("progress callback failed", err);
    return 1;                         // ask C to stop cleanly
  }
}, "ii");                             // int(int): the C side checks the return value

That small change makes the callback a well-behaved participant in the C library’s control flow rather than an exit hatch through it. The broader rules for exceptions crossing the boundary are in catching Wasm traps in JavaScript.

How addFunction builds its wrapper

It helps to know what happens inside addFunction, because it explains both its cost and its failure modes. In engines that support the type reflection proposal, Emscripten can create a WebAssembly function from the JavaScript one directly with new WebAssembly.Function, given the signature. Elsewhere, it generates a tiny WebAssembly module on the fly — a few dozen bytes containing one import and one exported function that forwards to it — compiles and instantiates it, and stores the exported function in the table. That is why addFunction is slower than an ordinary call and should not be used per operation in a hot loop: each call may compile a module. It is also why the signature string matters so much — it is the type of the generated wrapper, and the wrapper’s type is what call_indirect checks.

Expected output

The progress bar advances from 0 to 100 while process_file runs, and inspecting the table shows the wrapper in the returned slot:

app.wasmTable.get(ptr);          // ƒ 57() { [native code] } — the wrapper

After removeFunction(ptr), that slot reads null until reused.

Gotchas

  • Unable to grow wasm table. Build with -sALLOW_TABLE_GROWTH=1.
  • function signature mismatch when C calls the pointer. The signature string does not match the C type. void(int) is "vi", int(int, int) is "iii".
  • Leaked slots. Registering a callback per call without removing it. Track pointers and release them.
  • Registering inside a hot loop. Each registration may compile a tiny wrapper module. Register once and reuse the pointer.
  • addFunction is not a function. It was not exported from the runtime. Add it to EXPORTED_RUNTIME_METHODS.

Performance note

Registering a callback took about 40 µs in Chrome when a wrapper module had to be compiled and about 3 µs where WebAssembly.Function was available; calling it from C cost about 25 ns per call. For a callback registered once and called a hundred times per operation, both costs are negligible. For a design that registers a fresh callback per item processed, registration dominates.

Cost of addFunction and of each callback call Time to register a JavaScript callback with addFunction when a wrapper module must be compiled and when type reflection is available, and the cost of one call through the resulting pointer. microseconds addFunction (wrapper module compiled) 40 µs addFunction (WebAssembly.Function) 3 µs one call from C into JS 0.0 µs

Frequently Asked Questions

Can the callback return a value? Yes — use a signature with a non-v return type, such as "iii" for int(int, int). The JavaScript return value is converted to the declared type.

How many callbacks can I register? As many as the table can hold, which is limited only by memory and any declared maximum. In practice the limit is leaked slots, not capacity — release what you no longer need.

Can the callback receive strings? It receives pointers. Convert with UTF8ToString(ptr) inside the JavaScript function, and export that runtime method too.

Is the function pointer valid in another worker? Each worker has its own instance and table in non-threaded builds. In pthreads builds the table is synchronized, but registering from one thread and calling from another needs care; register on the thread that will call it.

What about 64-bit integer parameters? Use j in the signature; with -sWASM_BIGINT (the default in recent releases) they arrive as BigInt values.

← Back to Tables & Dynamic Linking