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:
-
Install Node.js ↗.
-
Create a new Vite project.
Terminal window npm create vite@latest -
Provide a name for the project (it will be created in that directory).
-
When asked, select the React framework and the JavaScript variant.
-
Change to the project directory.
-
Install the needed packages and start the server.
Terminal window npm installnpm run dev -
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.
-
Use this file as
src/App.jsxto 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 AppThe 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 aWebAssembly.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 atinstance.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 ofuseStateis 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
instanceexists, if the Wasm code has already been loaded. If so, it callsinstance.exports.nextIndexand uses the result to set the new value forindex.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 aWebAssembly.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 theasyncfunction 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 AppExport
Appso it will be available as a component insrc/main.jsx. -
Install Zig ↗.
-
Create a Zig directory.
Terminal window mkdir zig -
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 fntells 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,iandlength, and that both of them are of typeu32(unsigned 32 bit long integers). The finalu32is the return type.The next line is a traditional C style expression. It takes
i, adds one, and then wraps around so if the newiislengthit gets set to zero. Asiis the current index and the result the new index, this gives us a round-robin. -
Compile the code.
Terminal window zig cc zig/get-name.zig -target wasm32-freestanding -Wl,--export=nextIndex,--no-entry -o public/get-name.wasmWhat do these flags mean?
cc- emulate the traditional Unix C compilerzig/get-name.zig- the input Zig file-target wasm32-freestanding- create a Wasm32 binary.-Wl...- linker ↗ options--export=nextIndex- exportnextIndexfrom 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 underpublic/are served statically without any changes.
-
Reload the application ↗ to see get a round-robin of people’s names.
-
You can use this command to see the exports from the compiled Wasm.
Terminal window wasm-objdump --details --section=export public/get-name.wasmThe expected result:
get-name.wasm: file format wasm 0x1module 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.
-
Replace
src/App.jsxwith 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 structconst strPtr = view.getUint32(0, true) // offset 0: pointer to stringconst strLen = view.getUint32(4, true) // offset 4: length of stringreturn 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 AppThe way we receive the result is different.
const nextName = () => {if (instance) {const ptr = instance.exports.getName()setName(readStringFromStruct(ptr))}}The result of
instance.exports.getNameis a pointer. This is not a pointer directly to the string, but to aStringDatastruct 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 structconst strPtr = view.getUint32(0, true) // offset 0: pointer to stringconst strLen = view.getUint32(4, true) // offset 4: length of stringAs the structure definition above shows us, the data is a pointer (so it is a
usize, the pointer size in the architecture) followed by ausizelength. Here we usewasm32, sousizeis 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 useTextDecoder↗ on that array. -
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: ausizeindicating how many bytes the pointer spans.
const names = [_][]const u8{Declare a compile-time array
names. The type[_][]const u8means 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.```zigvar outputData: StringData = .{.ptr = names[0].ptr,.len = names[0].len,};Declare and initialize the mutable global
outputDataof typeStringData. 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;}setOutputDatais a helper function that takes a string slice ([]const u8) and copies its information intooutputData.export fn getName() *StringData {const State = struct {var index: usize = 0;};When you use
varinside astruct, you create a value that is global to all instances of that struct, (a singleton ↗). This lets us have the equivalent of a Cstaticvariable ↗.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.
-
Compile the code.
Terminal window zig cc zig/get-name.zig -target wasm32-freestanding -Wl,--export=getName,--no-entry -o public/get-name.wasm -
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.
-
Edit
src/App.jsximport { 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 structconst strPtr = view.getUint32(0, true); // offset 0: pointer to stringconst strLen = view.getUint32(4, true); // offset 4: length of stringreturn 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 AppThere 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. WhenzigStrToJavaScriptis first defined, there is noinstanceyet. As a result, when it is called (indirectly) from Wasm code, it does not seeinstanceand therefore cannot access the memory. However,consoleLogitself can accessinstanceand 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. -
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;catchtells Zig that here we’ll handle errors, butunreachabletells 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 thatstream.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 ↗.