Skip to content

Instantly share code, notes, and snippets.

@dominictarr
Last active October 6, 2025 03:20
Show Gist options
  • Save dominictarr/a4e820d42ef6a0cdd176f19bfac6d9fa to your computer and use it in GitHub Desktop.
Save dominictarr/a4e820d42ef6a0cdd176f19bfac6d9fa to your computer and use it in GitHub Desktop.
wasm zig async example

run

build: zig build-lib -target wasm32-freestanding -dynamic --export-table await.zig

run node await.js

output hello world!!! (but slow like a typwritter effect)

what it does

generate async zig wasm. this requires grappling with several issues:

  • wasm can only expose export/extern which cannot be async
  • frames need to survive until callback happens
  • frames must be passed as pointers

figure out the right approach to all of these at the same time, else cryptic errors.

I did not find any examples of this available, but now there is this one

const fs = require('fs');
const source = fs.readFileSync("./await2.wasm");
const typedArray = new Uint8Array(source);
;(async function () {
var callback, frame
var buffer = Buffer.from("hello world!!!\n")
var result = await WebAssembly.instantiate(typedArray, {
env: {
print: function (ptr, len) {
//if there was memory allocation, result.instance.exports.memory
//might be a different size now and it needs to be bufferized again.
var memory = Buffer.from(result.instance.exports.memory.buffer)
process.stdout.write(memory.slice(ptr, ptr+len))
},
read: function (ptr, len) {
if(len > buffer.length) return 0
var memory = Buffer.from(result.instance.exports.memory.buffer)
var l = Math.min(buffer.length,len)
buffer.copy(memory, ptr, 0, len)
buffer = buffer.slice(len)
return l
},
onReady: function (fn_ptr, frame) {
//callback when something is ready.
//pass back "frame" a pointer to whatever
var table = result.instance.exports.__indirect_function_table
var memory = Buffer.from(result.instance.exports.memory.buffer)
var cb = table.get(fn_ptr)
setTimeout(function () {
cb(frame)
}, Math.random()*1000)
},
}})
result.instance.exports.init()
}())
//onReady takes a callback and a frame pointer,
//and then at some point passes the frame pointer to the callback.
//the frame pointer is typed as anyopaque (same as c void pointer)
//instead of any frame because of compiler errors in get
extern fn onReady(
cb: fn ( frame: *anyopaque ) callconv(.C) void,
frame: *anyopaque
) void;
//copies memory from host into pointer, up to len
extern fn read(ptr: [*]const u8, len:usize) usize;
//writes len bytes from ptr to stdout
extern fn print(ptr: [*]const u8, len:usize) void;
fn get (slice: []u8) usize {
//put a suspend here, otherwise async get()
//will run to the end then return frame at return value.
//we don't want to do that because memory to read isn't ready yet.
suspend {
//because we pass a pointer to our own frame to an external function
//that parameter must be typed *anyopaque. if it's typed anyframe
//there are compiler errors that "@Frame(get) not analyzed yet"
onReady(cb, @frame());
}
return read(slice.ptr, slice.len);
}
fn cb (frame: *anyopaque) callconv(.C) void {
//defer allocator.destroy(frame);
//cast frame:*anyopaque to frame:*anyframe so we can resume it.
//this requires also casting the alignment.
resume @ptrCast(anyframe, @alignCast(4, frame));
}
//internal _init function, this can have any sort of async pattern.
fn _init () void {
var array1:[1]u8 = [_]u8{0};
//note: because of zig's uncoloured async, this just looks like a normal loop
while (0 != get(array1[0..array1.len])) {
print(&array1, 1);
}
}
//this is the essential trick, store the _init frame in global variable.
var init_frame :@Frame(_init) = undefined;
//the exported function can't be async
//but we can call another function with async keyword
//but if we did that with a local variable the memory wouldn't be alive
//after init() returns
//so, we make it a global. then, call async _init
//and have any kind of async pattern in there.
//(note, this does mean that init() should only be called exactly once, like main())
export fn init () void {
init_frame = async _init();
}
@lisawoods825
Copy link

I once struggled with something kind of similar when trying to implement real-time multiplayer for a simple browser game. I was using WebSockets and the communication delays created a very stuttery gaming experience until I figured out how to properly handle asynchronous network events. My initial, naive approach felt a bit like trying to wrangle snakes in Slither io, lots of unexpected behavior until I had the right strategy.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment