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.
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.
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: unreachableright after an async call. A function on the async path was excluded from instrumentation, usually by anASYNCIFY_ONLYlist orASYNCIFY_IGNORE_INDIRECTwhen the path actually goes through a function pointer. Re-run withASYNCIFY_ADVISEand 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=65536if 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.
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.
Related
- Calling async JavaScript with JSPI — the engine-level successor.
- Keeping the UI responsive during long Wasm tasks — yielding strategies beyond Asyncify.
- Calling C functions from JavaScript with ccall and cwrap — the call API that gains an async mode.
- Porting a C game loop to Emscripten — the main-loop alternative to sleeping.
← Back to C/C++ to Wasm with Emscripten