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.

Two helpers over the same export ccall takes the function name, return type and argument types on every call, which suits a one-off invocation. cwrap performs that setup once and returns a JavaScript function, which is what you want for anything called repeatedly. Module.ccall ccall("sum", "number", ["number"], [n]) signature supplied on every call no setup, slightly more work per call for a one-off or a rarely used export Module.cwrap const sum = cwrap("sum", "number", ["number"]) signature resolved once returns a plain JavaScript function for anything called more than once Both end at the same numeric export. Neither converts anything richer than a number or a string — buffers cross by pointer.

Prerequisites

  • [ ] An Emscripten build with the helpers exported (-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap'])
  • [ ] The C functions themselves kept alive with EMSCRIPTEN_KEEPALIVE or -sEXPORTED_FUNCTIONS
  • [ ] The module factory awaited before any call
  • [ ] wasm-objdump -x to 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.

Where the cost is Calling a wrapped function once per element pays marshalling on every crossing. Copying the whole buffer into linear memory once and passing a pointer pays a single copy, and the loop runs entirely inside the module. cwrap called per element n crossings, n conversions work one call with a pointer copy in the loop runs inside the module The copy is not free, but it happens once — and for a buffer you can reuse across calls it happens once in total.

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.

Three gates between the C function and the call The function must survive dead-code elimination, its name must appear in the export list, and the runtime helper used to call it must itself have been exported. A failure at any of the three produces a different error message. 1 · kept by the linker EMSCRIPTEN_KEEPALIVE 2 · in the export list _name in EXPORTED_FUNCTIONS 3 · helper available ccall in EXPORTED_RUNTIME_METHODS Gate 1 fails silently — the function simply is not there. Gate 2 fails with a missing export. Gate 3 fails with "ccall is not a function". Reading the error against this list identifies which flag to change in seconds.

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.

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