Calling Async Browser APIs from C with Asyncify

This page answers one task: make a C function that was written to block — read a file, sleep, wait for a network response — work in the browser, where every one of those operations is asynchronous.

Prerequisites

  • [ ] emsdk 3.1.40 or newer.
  • [ ] C code with a blocking call you cannot easily restructure into callbacks.
  • [ ] A rough idea of which functions sit on the call path to the blocking call (Asyncify will need to know).

Why C cannot simply await

WebAssembly functions run to completion. A JavaScript Promise resolves later, on a future turn of the event loop, and a running Wasm function has no way to say “pause here and resume me when it resolves”. Returning to the event loop means unwinding every Wasm frame on the stack — and with them, every local variable the C code is relying on.

Asyncify is a Binaryen transform that solves this by instrumenting the module. Functions that might be on the stack during an async call get extra code that can save their locals to a buffer in linear memory, unwind, and later rewind back into the exact same position. The C source does not change; the compiled module does.

Unwinding and rewinding around a Promise C code calls an async import. Asyncify saves each frame's locals into a buffer and unwinds to JavaScript, which awaits the Promise. When it resolves, the runtime calls back into Wasm, which rewinds the frames from the buffer and continues as if the call had returned normally. C code (Wasm) Asyncify runtime JavaScript call fetch_text(url) — an async import start unwind; Promise pending save locals of every frame to buffer Wasm stack empty; event loop runs Promise resolves → rewind frames restored; call returns the text

Step 1 — write the async import with EM_ASYNC_JS

EM_ASYNC_JS declares a JavaScript function that C can call as if it were synchronous. Inside it you can await anything.

#include <emscripten.h>
#include <stdio.h>
#include <stdlib.h>

EM_ASYNC_JS(char *, fetch_text, (const char *url), {
  const res = await fetch(UTF8ToString(url));
  const text = await res.text();
  const len = lengthBytesUTF8(text) + 1;
  const ptr = _malloc(len);
  stringToUTF8(text, ptr, len);
  return ptr;
});

int main(void) {
  char *body = fetch_text("/config.json");   // looks blocking; is not
  printf("got %zu bytes\n", strlen(body));
  free(body);
  return 0;
}

The returned pointer is memory the JavaScript side allocated with _malloc, which the C side then owns and frees. That ownership hand-off is the same discipline described in encoding strings across the Wasm boundary, just with an await in the middle.

Step 2 — build with ASYNCIFY

emcc -O2 main.c -sASYNCIFY \
  -sEXPORTED_FUNCTIONS=_main,_malloc,_free \
  -sEXPORTED_RUNTIME_METHODS=UTF8ToString,stringToUTF8,lengthBytesUTF8 \
  -o app.js

EM_ASYNC_JS imports are registered with Asyncify automatically. For the simple case of sleeping, the built-in emscripten_sleep(ms) works the same way and needs nothing else:

for (int frame = 0; frame < 600; frame++) {
  step_simulation();
  draw();
  emscripten_sleep(16);   // yields to the browser for ~one frame
}

That loop, which would freeze a tab without Asyncify, now lets the browser paint between iterations.

Step 3 — narrow the instrumentation

By default Asyncify assumes any function that can reach an import might be on the stack during an unwind, and instruments it. With indirect calls in the program, that is often almost every function — and the instrumentation is not free.

Size and speed cost of Asyncify instrumentation A 640 KB module built three ways. Default Asyncify instruments most functions and grows the binary and slows a CPU benchmark; an explicit ASYNCIFY_ONLY list of the six functions on the async path recovers nearly all of it. binary size (KB, uncompressed) no Asyncify 640 KB ASYNCIFY default 1,012 KB ASYNCIFY_IGNORE_INDIRECT 784 KB ASYNCIFY_ONLY six functions 662 KB The CPU benchmark slowed by 38% under default instrumentation and by 3% with the explicit list — the extra code sits inside hot loops.

Two settings tell Asyncify what it can skip:

# assume indirect calls never lead to an async import
emcc -O2 main.c -sASYNCIFY -sASYNCIFY_IGNORE_INDIRECT -o app.js

# or name the exact functions that can be on the stack during an unwind
emcc -O2 main.c -sASYNCIFY \
  -sASYNCIFY_ONLY='["main","load_config","fetch_text","parse_config","run_frame","step_simulation"]' \
  -o app.js

ASYNCIFY_ONLY is the most effective and the most dangerous: if a function that is on the stack during an unwind is missing from the list, the rewind restores garbage and the program fails in confusing ways. Generate the list rather than writing it by hand — -sASYNCIFY_ADVISE prints which functions the transform believes need instrumentation:

emcc -O2 main.c -sASYNCIFY -sASYNCIFY_ADVISE -o app.js 2>&1 | grep 'can change the state'

Step 4 — call exported functions asynchronously from JavaScript

Once a function may unwind, its JavaScript caller must treat it as async. Exported functions that reach an async import return a Promise when called through ccall with {async: true}:

const Module = await createModule();
const result = await Module.ccall(
  "load_and_process",    // C function name
  "number",              // return type
  ["string"],            // argument types
  ["/data/input.bin"],
  { async: true }        // required: the call may unwind
);

Calling it synchronously instead returns before the work completes and leaves Asyncify in an inconsistent state for the next call.

Step 5 — read IndexedDB or the file picker the same way

Network requests are the obvious case, but the pattern generalises to every Promise-returning browser API. A C library that expects a synchronous read_blob(key) against a storage backend can be backed by IndexedDB with no change to the library itself:

EM_ASYNC_JS(int, idb_get, (const char *key, unsigned char *out, int cap), {
  const db = await new Promise((ok, fail) => {
    const req = indexedDB.open("assets", 1);
    req.onupgradeneeded = () => req.result.createObjectStore("blobs");
    req.onsuccess = () => ok(req.result);
    req.onerror = () => fail(req.error);
  });
  const value = await new Promise((ok, fail) => {
    const tx = db.transaction("blobs").objectStore("blobs").get(UTF8ToString(key));
    tx.onsuccess = () => ok(tx.result);
    tx.onerror = () => fail(tx.error);
  });
  if (!value) return -1;
  const bytes = new Uint8Array(value);
  if (bytes.length > cap) return -2;
  HEAPU8.set(bytes, out);
  return bytes.length;
});

Two details in that snippet carry over to any async import. First, the result is written into a buffer the C caller supplied, with its capacity checked, rather than allocated on the JavaScript side — that keeps ownership simple and avoids a malloc per call. Second, HEAPU8 is read after the await, not captured before it. If memory grew while the call was suspended, a view captured earlier would point at a detached buffer; reading the global after resuming always sees the current one. The same rule is explained in why memory.grow invalidates pointers.

Errors deserve the same care. A rejected Promise inside EM_ASYNC_JS propagates as a JavaScript exception out of the rewound Wasm call, which C cannot catch. Convert failures into return codes inside the JavaScript body — wrap the awaits in try/catch and return a negative value — so the C side keeps its familiar error-checking style and nothing unwinds through frames that do not expect it.

Expected output

With the fetch example served alongside a config.json of 2,148 bytes:

got 2148 bytes

In the Performance panel the call shows as two separate Wasm slices with an idle gap between them — the unwind, the network wait during which the browser was free, and the rewind. That gap is the point.

Seen from the user’s side, the change is the difference between a frozen tab and a responsive one. Before Asyncify, the same code either could not be compiled at all (no synchronous network API exists in the main thread) or had to use the deprecated synchronous XMLHttpRequest, which blocks every interaction for the length of the request. With it, scrolling and input keep working while the C code is, from its own point of view, still sitting inside fetch_text.

Gotchas

  • RuntimeError: unreachable right after an async call. A function on the async path was excluded from instrumentation, usually by an ASYNCIFY_ONLY list or ASYNCIFY_IGNORE_INDIRECT when the path actually goes through a function pointer. Re-run with ASYNCIFY_ADVISE and compare.
  • Re-entrancy. While a call is unwound and waiting, JavaScript can call into the module again. If that second call also unwinds, Asyncify reports Assertion failed: Cannot have multiple async operations in flight at once. Queue calls on the JavaScript side.
  • The stack buffer overflows. Deep call stacks save more locals than the default buffer holds. Raise -sASYNCIFY_STACK_SIZE=65536 if traps appear only on deep paths.
  • Mixing with pthreads. In a thread you can usually block for real with Atomics.wait, which makes Asyncify unnecessary. Use pthreads or Asyncify, rarely both.
Ways to make C wait for something asynchronous A comparison of Asyncify, JSPI, running in a worker with blocking waits, and restructuring into callbacks, across code changes required, binary cost, and browser support. approach C changes binary cost browser support Asyncify none +3% to +60% all browsers JSPI none near zero newer engines worker + Atomics.wait thread setup small needs isolation callback rewrite large none all browsers

Performance note

Each unwind and rewind copies every instrumented frame’s locals to and from the buffer. For a call path eight frames deep this measured around 4 µs per round trip — irrelevant for a network fetch, significant if you put emscripten_sleep(0) inside an inner loop that runs a million times. Yield at frame or chunk boundaries, not per item. Where it is available, the JavaScript Promise Integration proposal does the same job in the engine with no instrumentation, and Emscripten can target it with -sJSPI instead of -sASYNCIFY.

Frequently Asked Questions

Does Asyncify work with C++ exceptions? With JavaScript-based exceptions, yes. With native Wasm exceptions (-fwasm-exceptions) support has historically lagged; check the release notes for your emsdk version and test the combination explicitly.

Can Rust use Asyncify? Binaryen’s --asyncify pass works on any module, so in principle yes, but Rust code normally uses wasm-bindgen-futures and real async fn instead. Asyncify is mainly a tool for porting synchronous C.

Is Asyncify being replaced? JSPI covers the same use case with far less overhead and is the long-term direction. Asyncify remains the portable choice until JSPI is available in every browser you support.

How do I know which functions ended up instrumented? Build with -sASYNCIFY_ADVISE and keep the output as a build artifact. When a later change adds a new path to an async import, diffing that list shows exactly which functions joined it — and whether a hand-written ASYNCIFY_ONLY list needs updating.

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