Skip to content

Wasm with Zig for your web app

By the end of this article, you’ll have a working React app with a Zig-powered Wasm module. This will teach you how to perform calculations at previously unimagined speeds client-side, which is important for functionality such as image manipulation and cryptography.

Why is this important?

It is not feasible to send everything for processing on your web server, so a lot of web applications use client-side scripting for their processing. Typically, this is done using JavaScript, or languages that compile to JavaScript, such as TypeScript.

JavaScript, however, is an interpreted language, which makes it too slow for some applications. To enable faster client-side execution, Wasm allows you to run compiled code on the browser.

Many compiled computer languages support Wasm. This article uses Zig, a modern system programming language designed for transparency and simplicity. This article uses the Vite web server and React user interface library. Both are industry standard in their domains, but if you don’t know them don’t worry - we will go over what you need to know.

How do I get started?

If you are not familiar with these tools, here is how you can get started:

  1. Install Node.js.

  2. Create a new Vite project.

    Terminal window
    npm create vite@latest
  3. Provide a name for the project (it will be created in that directory).

  4. When asked, select the React framework and the JavaScript variant.

  5. Change to the project directory.

  6. Install the needed packages and start the server.

    Terminal window
    npm install
    npm run dev
  7. Browse to http://localhost:5173/. What you see is a test page to show you the installation was successful.

Trivial example

This trivial example lets you click a button to change the person’s name, using Wasm to figure out which person goes next.

  1. Use this file as src/App.jsx to call Wasm code.

    import { useState, useEffect } from 'react'
    const names = ["Alice", "Bill", "Carol", "David"]
    function App() {
    const [index, setIndex] = useState(0)
    const [instance, setInstance] = useState()
    const [status, setStatus] = useState("Unloaded")
    const nextIndex = () => {
    if (instance)
    setIndex(instance.exports.nextIndex(index, names.length))
    }
    useEffect(() => {
    const loadWasm = async () => {
    try {
    const { instance } =
    await WebAssembly.instantiateStreaming(fetch("/get-name.wasm"))
    setInstance(instance)
    setStatus("Loaded")
    } catch (err) {
    setStatus(`Error: ${err}`)
    console.error('Error loading wasm:', err)
    }
    }
    loadWasm()
    }, []
    )
    return (
    <>
    <h2>
    {names[index]}
    </h2>
    <button onClick={nextIndex}>
    Change person
    </button>
    <p>
    Status: {status}
    </p>
    </>
    )
    }
    export default App

    The code relevant to Wasm is these lines:

    const { instance } =
    await WebAssembly.instantiateStreaming(fetch("/get-name.wasm"))
    setInstance(instance)

    We fetch our compiled Wasm program and use that to create a WebAssembly.Instance object. This object includes everything needed to run the exported functions from the Wasm code. In this case there is a single exported function, available at instance.exports.nextIndex.

    Detailed explanation

    If you are not familiar with React programming, here is a detailed explanation of the code.

    import { useState, useEffect } from 'react'
    const names = ["Alice", "Bill", "Carol", "David"]
    function App() {

    A React application is divided into different components. A component is essentially a function that is called every time anything relevant in the environment changes and the component might need to be redrawn. The input for the function comes in the properties. The function output is JSX that displays the component.

    const [index, setIndex] = useState(0)
    const [instance, setInstance] = useState() /* */
    const [status, setStatus] = useState("Unloaded")

    We use useState to store state information about the component, information that we’ll need when the component is redrawn in the future. The output of useState is a list with two values. The first is the current value of the variable, and the second is a function to be called to modify that value.

    const nextIndex = () => {
    if (instance)
    setIndex(instance.exports.nextIndex(index, names.length))
    }

    This JavaScript function checks if instance exists, if the Wasm code has already been loaded. If so, it calls instance.exports.nextIndex and uses the result to set the new value for index.

    useEffect(() => {

    The useEffect hook lets us tell React that we are synchronizing with a remote system. It will run the provided function, and when it finishes redraw the component if necessary (if any of the state information changed).

    const loadWasm = async () => {
    try {
    const { instance } =
    await WebAssembly.instantiateStreaming(fetch("/get-name.wasm"))

    Open /get-name.wasmand use it to create a WebAssembly.Instance. This instance includes the exported function we need to call.

    setInstance(instance)

    Set the instance. This provides the JavaScript code with the information it needs for Wasm interoperability.

    setStatus("Loaded")
    } catch (err) {
    setStatus(`Error: ${err}`)
    console.error('Error loading wasm:', err)
    }
    }

    If there is an error, anywhere, report to the user.

    loadWasm()
    }, []
    )

    This is the code that typically terminates a useEffects. The last line of the function calls the async function we just defined. This is followed by the parameter list of the function, which in our case is empty.

    return (

    Everything inside this return statement is what’s rendered to the screen. Each time React redraws this component (when state changes, for instance), it re-evaluates this block to update what the user sees. This variant of HTML is called JSX. JSX needs to be enclosed in parenthesis.

    <>

    JSX output has just one root element. When we have multiple elements, such as here, we enclose them in a <> ... </> tag.

    <h2>
    {names[index]}

    To include a JavaScript expression in JSX we use curly brackets, { ... }.

    </h2>
    <button onClick={nextIndex}>
    Change person
    </button>
    <p>
    Status: {status}
    </p>
    </>
    )
    }
    export default App

    Export App so it will be available as a component in src/main.jsx.

  2. Install Zig.

  3. Create a Zig directory.

    Terminal window
    mkdir zig
  4. Create a file, zig/get-name.zig.

    export fn nextIndex(i: u32, length: u32) u32 {
    return (i+1) % length;
    }
    Explanation
    export fn nextIndex(i: u32, length: u32) u32 {
    return (i+1) % length;
    }

    The export fn tells Zig that this is function (fn) is to be exported, so it will be accessible externally. This only exports it in the object file, to export it to the result we need to use a linker option (see next step).

    The (i: u32, length: u32) portion tells it that we have two parameters, i and length, and that both of them are of type u32 (unsigned 32 bit long integers). The final u32 is the return type.

    The next line is a traditional C style expression. It takes i, adds one, and then wraps around so if the new i is length it gets set to zero. As i is the current index and the result the new index, this gives us a round-robin.

  5. Compile the code.

    Terminal window
    zig cc zig/get-name.zig -target wasm32-freestanding -Wl,--export=nextIndex,--no-entry -o public/get-name.wasm
    What do these flags mean?
    • cc - emulate the traditional Unix C compiler
    • zig/get-name.zig - the input Zig file
    • -target wasm32-freestanding - create a Wasm32 binary.
    • -Wl... - linker options
      • --export=nextIndex - export nextIndex from the object file to the final result file (the one created by the linker)
      • --no-entry - this is a library, there is no “main” function to start with
    • -o public/get-name.wasm - the output file. In Vite, files under public/ are served statically without any changes.
  6. Reload the application to see get a round-robin of people’s names.

  7. You can use this command to see the exports from the compiled Wasm.

    Terminal window
    wasm-objdump --details --section=export public/get-name.wasm

    The expected result:

    get-name.wasm: file format wasm 0x1
    module name: <get-name.wasm>
    Section Details:
    Export[2]:
    - memory[0] -> "memory"
    - func[3] <nextIndex> -> "nextIndex"

Returning a more complex value

Wasm only lets us return a single value, up to 64 bits. If we want to return something longer, for example a string or a structure, we need to rely on the shared memory between Wasm and JavaScript. This shared memory is available in JavaScript as instance.exports.memory.

In this example, we return from Zig an actual name instead of an index to an array.

  1. Replace src/App.jsx with this content.

    import { useState, useEffect } from 'react'
    function App() {
    const [name, setName] = useState("No name yet")
    const [status, setStatus] = useState("Unloaded")
    const [instance, setInstance] = useState()
    const zigStrToJavaScript = (ptr, len) => {
    const strBytes = new Uint8Array(instance.exports.memory.buffer, ptr, len)
    return new TextDecoder().decode(strBytes)
    }
    const readStringFromStruct = ptr => {
    const view = new DataView(instance.exports.memory.buffer, ptr, 8)
    // Read fields from the struct
    const strPtr = view.getUint32(0, true) // offset 0: pointer to string
    const strLen = view.getUint32(4, true) // offset 4: length of string
    return zigStrToJavaScript(strPtr, strLen)
    }
    const nextName = () => {
    if (instance) {
    const ptr = instance.exports.getName()
    setName(readStringFromStruct(ptr))
    }
    }
    useEffect(() => {
    const loadWasm = async () => {
    try {
    const { instance } =
    await WebAssembly.instantiateStreaming(fetch("/get-name.wasm"))
    setInstance(instance)
    setStatus("Loaded")
    } catch (err) {
    setStatus(`Error: ${err}`)
    console.error('Error loading wasm:', err)
    }
    }
    loadWasm()
    }, []
    )
    return (
    <>
    <h2>
    {name}
    </h2>
    <button onClick={nextName}>
    Change person
    </button>
    <p>
    Status: {status}
    </p>
    </>
    )
    }
    export default App

    The way we receive the result is different.

    const nextName = () => {
    if (instance) {
    const ptr = instance.exports.getName()
    setName(readStringFromStruct(ptr))
    }
    }

    The result of instance.exports.getName is a pointer. This is not a pointer directly to the string, but to a StringData struct defined in Zig:

    const StringData = extern struct {
    ptr: [*]const u8,
    len: usize,
    };

    Zig strings are typically a pointer to the start and a length. This means two values, so we return a pointer to a strucure that has both values.

    const readStringFromStruct = ptr => {
    const view = new DataView(instance.exports.memory.buffer, ptr, 8)

    We use the instance memory to create a DataView to let us read the information.

    // Read fields from the struct
    const strPtr = view.getUint32(0, true) // offset 0: pointer to string
    const strLen = view.getUint32(4, true) // offset 4: length of string

    As the structure definition above shows us, the data is a pointer (so it is a usize, the pointer size in the architecture) followed by a usize length. Here we use wasm32, so usize is 32 bits, a.k.a. 4 bytes.

    return zigStrToJavaScript(strPtr, strLen)
    }
    const zigStrToJavaScript = (ptr, len) => {
    const strBytes = new Uint8Array(instance.exports.memory.buffer, ptr, len)
    return new TextDecoder().decode(strBytes)
    }

    To get the string, create a Uint8Array for the string and then use TextDecoder on that array.

  2. Edit zig/get-name.zig:

    const StringData = extern struct {
    ptr: [*]const u8,
    len: usize,
    };
    const names = [_][]const u8{
    "Alice",
    "Bob",
    "Carol",
    "Dave",
    "Eve"
    };
    var outputData: StringData = .{
    .ptr = names[0].ptr,
    .len = names[0].len,
    };
    fn setOutputData(name: []const u8) void {
    outputData.ptr = name.ptr;
    outputData.len = name.len;
    }
    export fn getName() *StringData {
    const State = struct {
    var index: usize = 0;
    };
    State.index += 1;
    if (State.index == names.len) {
    State.index = 0;
    }
    setOutputData(names[State.index]);
    return &outputData;
    }
    Detailed explanation
    const StringData = extern struct {
    ptr: [*]const u8,
    len: usize,
    };

    Define an extern struct type alias named StringData. Zig strings are typically defined by the two fields we have here:

    • ptr: a raw pointer to immutable bytes ([*]const u8).
    • len: a usize indicating how many bytes the pointer spans.
    const names = [_][]const u8{

    Declare a compile-time array names. The type [_][]const u8 means an inline array of string slices, whose length is inferred, as is the length of each individual slice.

    "Alice",
    "Bob",
    "Carol",
    "Dave",
    "Eve"
    };
    This is the actual array data.
    I added a name, "Eve", to create a visible difference in behavior.
    ```zig
    var outputData: StringData = .{
    .ptr = names[0].ptr,
    .len = names[0].len,
    };

    Declare and initialize the mutable global outputData of type StringData. We initialize the variable here because the compiler won’t let us create an uninitialized variable, but the value does not matter because it gets overwritten before it is used.

    This is a global variable so it will be stored in memory (rather than the stack) and we’ll be able to return a pointer to it.

    fn setOutputData(name: []const u8) void {
    outputData.ptr = name.ptr;
    outputData.len = name.len;
    }

    setOutputData is a helper function that takes a string slice ([]const u8) and copies its information into outputData.

    export fn getName() *StringData {
    const State = struct {
    var index: usize = 0;
    };

    When you use var inside a struct, you create a value that is global to all instances of that struct, (a singleton). This lets us have the equivalent of a C static variable.

    State.index += 1;
    if (State.index == names.len) {
    State.index = 0;
    }

    Round-robin logic.

    setOutputData(names[State.index]);
    return &outputData;
    }

    Actually return the pointer to the data structure that points to the next name.

  3. Compile the code.

    Terminal window
    zig cc zig/get-name.zig -target wasm32-freestanding -Wl,--export=getName,--no-entry -o public/get-name.wasm
  4. Reload the application. See that the round-robin functionality works with the new code, with Eve added.

Calling Javascript from Wasm

Wasm is great for performing calculations quickly. However, it does not have a standard library for input/output. If we want to contact the outside world we need to go through JavaScript.

In this example we add the ability to log messages to the console.

  1. Edit src/App.jsx

    import { useState, useEffect } from 'react'
    function App() {
    const [name, setName] = useState("No name yet")
    const [status, setStatus] = useState("Unloaded")
    const [instance, setInstance] = useState()
    const zigStrToJavaScript = (ptr, len) => {
    const strBytes = new Uint8Array(instance.exports.memory.buffer, ptr, len)
    return new TextDecoder().decode(strBytes)
    }
    const zigStrToJavaScriptMem = (memory, ptr, len) => {
    const strBytes = new Uint8Array(memory.buffer, ptr, len)
    return new TextDecoder().decode(strBytes)
    }
    const readStringFromStruct = ptr => {
    const view = new DataView(instance.exports.memory.buffer, ptr, 8);
    // Read fields from the struct
    const strPtr = view.getUint32(0, true); // offset 0: pointer to string
    const strLen = view.getUint32(4, true); // offset 4: length of string
    return zigStrToJavaScript(strPtr, strLen)
    }
    const nextName = () => {
    if (instance) {
    const ptr = instance.exports.getName();
    setName(readStringFromStruct(ptr))
    }
    }
    useEffect(() => {
    const loadWasm = async () => {
    try {
    const { instance } =
    await WebAssembly.instantiateStreaming( /* */
    fetch("/get-name.wasm"),
    {
    env: {
    consoleLog: (ptr, len) =>
    console.log(`Zig says: ${
    zigStrToJavaScriptMem(instance.exports.memory, ptr, len)
    }`)
    }
    }
    )
    setInstance(instance)
    setStatus("Loaded")
    } catch (err) {
    setStatus(`Error: ${err}`)
    console.error('Error loading wasm:', err)
    }
    }
    loadWasm()
    }, []
    )
    return (
    <>
    <h2>
    {name}
    </h2>
    <button onClick={nextName}>
    Change person
    </button>
    <p>
    Status: {status}
    </p>
    </>
    )
    }
    export default App

    There are a few new parts in this code.

    const { instance } =
    await WebAssembly.instantiateStreaming(
    fetch("/get-name.wasm"),
    {
    env: {
    consoleLog: (ptr, len) =>

    In addition to specifying the Wasm file, we need to specify the items it imports. In this case, there is one item, env.consoleLog, for the function we need to call from Zig.

    console.log(`Zig says: ${
    zigStrToJavaScriptMem(instance.exports.memory, ptr, len)
    }`)
    }
    }
    )

    We use zigStrToJavaScriptMem (explained below) to get the string. We need a separate function because of some weirdness with JavaScript and Wasm. When zigStrToJavaScript is first defined, there is no instance yet. As a result, when it is called (indirectly) from Wasm code, it does not see instance and therefore cannot access the memory. However, consoleLog itself can access instance and provide it to functions it calls.

    const zigStrToJavaScriptMem = (memory, ptr, len) => {
    const strBytes = new Uint8Array(memory.buffer, ptr, len)
    return new TextDecoder().decode(strBytes)
    }

    This function also receives a string from Zig, but it takes the memory as a parameter instead of retrieving it from instance.

  2. Edit zig/get-name.zig.

    const std = @import("std");
    extern "env" fn consoleLog(ptr: [*]const u8, len: usize) void;
    const StringData = extern struct {
    ptr: [*]const u8,
    len: usize,
    };
    const names = [_][]const u8{
    "Alice",
    "Bob",
    "Carol",
    "Dave",
    "Eve"
    };
    var outputData: StringData = .{
    .ptr = names[0].ptr,
    .len = names[0].len,
    };
    fn setOutputData(name: []const u8) void {
    outputData.ptr = name.ptr;
    outputData.len = name.len;
    }
    export fn getName() *StringData {
    const State = struct {
    var index: usize = 0;
    };
    State.index += 1;
    if (State.index == names.len) {
    State.index = 0;
    }
    var buf : [256]u8 = undefined;
    var stream = std.io.fixedBufferStream(&buf);
    stream.writer().print("New name: {s} #{d}",
    .{ names[State.index], State.index }
    ) catch unreachable;
    const msg = stream.getWritten();
    consoleLog(msg.ptr, msg.len);
    setOutputData(names[State.index]);
    return &outputData;
    }

    Here are the new parts.

    const std = @import("std");

    We need to import the Zig standard library to be able to formant and print our log message.

    extern "env" fn consoleLog(ptr: [*]const u8, len: usize) void;

    This is the function we are importing.

    export fn getName() *StringData {
    .
    .
    .
    var buf : [256]u8 = undefined;

    One of the features of Zig is that it does not mandate how to use your memory. You can avoid any dynamic memory allocations, or when you call a function that needs to allocate memory, you can include an allocator as a parameter.

    The simplest solution in our case is to allocate a buffer for the message as a variable.

    var stream = std.io.fixedBufferStream(&buf);
    stream.writer().print("New name: {s} #{d}",
    .{ names[State.index], State.index }

    This is a rough equivalent to sprintf in C. We create a stream that writes to a fixed buffer and use it to write the parameters we want, the new name and its index. See the documentation for how to construct format strings.

    ) catch unreachable;

    catch tells Zig that here we’ll handle errors, but unreachable tells it we’ll never reach this state so not to worry about it. Without this line the compiler will give us an error for not handling the errors that stream.writer().print(...) might raise.

    In a production system it would be better to handle errors (in case, for example, a name is so long the 256 character buffer overflows). However, this is a sample program and optimized for clarity rather than fault tolerance.

    const msg = stream.getWritten();
    consoleLog(msg.ptr, msg.len);

    Actually get the message and send it to consoleLog.

Conclusion

Wasm can speed up calculations in client-side applications by orders of magnitude, which is important for applications that use cryptography, for example. Zig is a good language to produce Wasm because it is fast and simple, like a modern version of the original C language.