Calling C Functions from JavaScript with ccall and cwrap
This guide covers the two marshalling helpers Emscripten generates — ccall for a one-off invocation
and cwrap for a reusable typed wrapper — and the rules that decide whether the function you want to
call is still in the binary at all.
Both helpers exist because a compiled C function takes and returns numbers. ccall and cwrap convert
JavaScript values into those numbers according to a signature you supply, call the export, and convert
the result back. Understanding what they do and do not convert is most of what makes this boundary
predictable.
Prerequisites
- [ ] An Emscripten build with the helpers exported (
-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap']) - [ ] The C functions themselves kept alive with
EMSCRIPTEN_KEEPALIVEor-sEXPORTED_FUNCTIONS - [ ] The module factory awaited before any call
- [ ]
wasm-objdump -xto confirm what actually got exported
Step-by-step procedure
1. Keep the C functions alive
#include <emscripten.h>
EMSCRIPTEN_KEEPALIVE
int sum_bytes(const unsigned char *data, int len) {
int total = 0;
for (int i = 0; i < len; i++) total += data[i];
return total;
}
Without EMSCRIPTEN_KEEPALIVE — or an explicit -sEXPORTED_FUNCTIONS=['_sum_bytes'] — dead-code
elimination removes any function JavaScript is the only caller of. This is the single most common
cause of “the function is undefined” reports.
2. Export the runtime helpers
emcc sum.c -O2 \
-sMODULARIZE -sEXPORT_ES6 \
-sEXPORTED_RUNTIME_METHODS='["ccall","cwrap"]' \
-sEXPORTED_FUNCTIONS='["_sum_bytes","_malloc","_free"]' \
-o dist/sum.mjs
_malloc and _free need exporting explicitly too if JavaScript will allocate — they are ordinary
functions from the linker’s point of view.
3. Await the factory before calling anything
import createModule from "./dist/sum.mjs";
const Module = await createModule();
Every export and heap view is undefined until this resolves. Calling earlier produces a “called before runtime initialization” abort rather than a useful error.
4. Wrap a scalar function
const add = Module.cwrap("add", "number", ["number", "number"]);
console.log(add(2, 3)); // 5
The type strings are limited: "number", "string", "array", "boolean" and null for void.
Anything else crosses as a pointer.
5. Pass a buffer by pointer
const input = new Uint8Array([4, 8, 15, 16, 23, 42]);
const ptr = Module._malloc(input.length);
try {
Module.HEAPU8.set(input, ptr);
const total = Module.ccall("sum_bytes", "number", ["number", "number"], [ptr, input.length]);
console.log(total); // 108
} finally {
Module._free(ptr);
}
This is the pattern for anything larger than a few scalars: allocate, copy in, pass the offset and the
length, free in a finally. The "array" type string exists and does the copy for you, but it hides
the allocation and gives you no chance to reuse the buffer.
6. Handle a function that returns a string
const describe = Module.cwrap("describe", "string", ["number"]);
console.log(describe(42));
Emscripten reads the returned pointer as a NUL-terminated C string and copies it into a JavaScript string. If the C side allocated that buffer, it still has to be freed — returning a pointer to a static buffer avoids the question and is a common convention.
Expected output
$ node run.mjs
add(2, 3) = 5
sum_bytes([4,8,15,16,23,42]) = 108
describe(42) = "answer"
$ wasm-objdump -x dist/sum.wasm | grep -A6 '^Export'
Export[5]:
- func[12] -> "sum_bytes"
- func[13] -> "add"
- func[41] -> "malloc"
- func[42] -> "free"
- memory[0] -> "memory"
Checking the export list is the quickest way to distinguish “my call is wrong” from “the function is not
there”. If a name is absent from that list, no amount of adjusting the ccall signature will help.
Gotchas
TypeError: Module.ccall is not a function. The helper was not exported. Add it to
EXPORTED_RUNTIME_METHODS; the flag list is not cumulative across builds, so adding one name replaces
the previous set.
Assertion failed: native function 'foo' called before runtime initialization. The factory promise
was not awaited, or a top-level call ran during module evaluation.
The function is missing from the export list. Dead-code elimination removed it. EMSCRIPTEN_KEEPALIVE
in the C source, or the name with a leading underscore in EXPORTED_FUNCTIONS.
A HEAPU8 view stops working after a call. The module grew its memory and detached the old buffer.
Re-read Module.HEAPU8 after any call that can allocate.
A returned string is garbled. The C function returned a pointer to a stack buffer that has already been reused. Return a pointer to static or heap memory, and document who frees it.
Performance note
cwrap resolves the export and builds the conversion closure once, so a wrapped function called in a
loop is measurably faster than the equivalent ccall — the difference is small per call and real at
volume. That said, both are dominated by the crossing itself, so the meaningful optimisation is always
to cross less often rather than to choose between them.
For buffers, the allocation is usually the larger cost. Allocating once at startup and reusing the same
region across calls removes a malloc/free pair per invocation and keeps the pointer stable, which is
worth doing for any per-frame or per-request workload. The direct heap path — _malloc, HEAPU8.set,
a pointer argument — exists precisely so this is possible.
Reusing a buffer across calls
For any function called repeatedly with data of a similar size, allocating and freeing per call is wasted work — and on a per-frame path it is the dominant cost. Allocating once and reusing the region removes both the allocator traffic and the risk of a leak on an error path:
class Pipeline {
constructor(Module, capacity) {
this.Module = Module;
this.capacity = capacity;
this.ptr = Module._malloc(capacity);
this.process = Module.cwrap("process", "number", ["number", "number"]);
}
run(bytes) {
if (bytes.length > this.capacity) throw new RangeError("input exceeds buffer");
this.Module.HEAPU8.set(bytes, this.ptr); // re-read HEAPU8 each time
return this.process(this.ptr, bytes.length);
}
dispose() {
this.Module._free(this.ptr);
this.ptr = 0;
}
}
Two details in that class matter. HEAPU8 is read fresh on every call rather than cached in the
constructor, because any call that allocates may have grown memory and detached the previous view.
And the capacity check is explicit: writing past the allocation would corrupt whatever the allocator
placed after it, and unlike an out-of-bounds access it would not trap, because the write is still
inside linear memory.
Sizing the buffer for the largest input you accept, once, is almost always better than growing it on
demand. linear memory never shrinks, so a buffer that grows to accommodate one unusually large input
holds that space for the rest of the session anyway.
Strings, and the underscore rule
The "string" type in a cwrap signature does real work: on the way in it encodes to UTF-8, allocates,
copies, and frees afterwards; on the way out it reads a NUL-terminated buffer and decodes. That is
convenient and it is a full round trip of allocation per call, so a function called in a loop with a
string argument is paying far more than the crossing itself.
Where the same string is passed repeatedly — a configuration value, a format name — convert it once to an integer identifier and pass that instead. Where a large string is genuinely the payload, allocate once and pass a pointer with an explicit length, exactly as for any other buffer, and skip the helper’s convenience entirely.
Finally, the naming inconsistency that trips almost everyone once: EXPORTED_FUNCTIONS takes names with
a leading underscore (_sum_bytes), because that is the symbol the linker sees, while ccall and
cwrap take the plain name (sum_bytes). Getting it backwards produces either a function that is
missing from the binary or one that cannot be found at run time, and the two errors look similar enough
that it is worth checking both spellings before investigating anything else.
Frequently Asked Questions
Should I use embind instead?
For C++ with classes and objects, yes — it generates a much nicer API. For plain C with a handful of
functions, cwrap is lighter and adds no binding registry. See
binding C++ libraries with embind.
Can I call an async C function?
Only with Asyncify, which instruments the call stack so a C function can suspend. It works and it
inflates the binary — scope ASYNCIFY_IMPORTS narrowly.
Do I need the underscore prefix?
In EXPORTED_FUNCTIONS, yes — _sum_bytes. In ccall and cwrap, no — they take the plain name.
That inconsistency accounts for a surprising share of the confusion around this API.
Does cwrap work with functions returning a struct?
Not directly — C structs are not among the types the helpers marshal. Return a pointer to a struct and
read its fields with a DataView, or write the fields into an out-parameter the caller allocated. Both
require the two sides to agree on the layout, which is the same contract any struct crossing this
boundary needs.
Related
- C/C++ to Wasm with Emscripten — the pipeline that produced the module.
- Binding C++ libraries with embind — the richer alternative for C++.
- Reading Wasm linear memory with typed arrays — the heap views these calls use.
- Encoding strings across the Wasm boundary — what the “string” type does under the surface.
← Back to C/C++ to Wasm with Emscripten