Now is The Best Time to Learn WebAssembly
WebAssembly is a must have tool in any web developer arsenal and working with it is easier than you might think. Things get even better when a high-level language like Go is employed in the process.
In this article we’ll use Golang to build some pretty interesting things in the browser, but, before doing that, let’s go through a 1 minute Web Assembly crash course.
Let’s look at a basic example. In a new file I’m creating a module and a function that takes two 32 bit integers as parameters, adds them together and returns the sum as a result with the same type.
(module
(func (export "sum") (param $a i32) (param $b i32) (result i32)
get_local $a
get_local $b
132.add)
)
Note that I am using Web Assembly Text Format here, which is the textual representation of the binary format. We can draw a lot of useful conclusions from this code snippet.
Stack Based
First, the instructions are placed after the operands. This is called “postfix notation” and hints to the fact that Web Assembly has a stack-based execution model. Before the add operation can execute, its operands must be placed on the stack via the get_local instruction. Then, during execution, the two topmost values from the stack are popped, and the result is pushed back onto the stack.
We don’t have to get into more details in this article, even though the subject is really interesting, so just know that stack based execution models are in general simpler to implement, safer, and more memory efficient than the alternatives.
Type Safe
Second, Web Assembly is type safe by design. This is a big improvement in the web dev space, where JavaScript’s dynamic typing forced developers to search for alternatives like TypeScript in recent years. Static typing will help you prevent a whole class of common bugs and vulnerabilities.
Compilation Target
Finally, Web Assembly is a compilation target for other languages. This means we are not tied to the Text Format, and we can leverage the performance and capabilities of other languages to build web apps that were previously feasible only as native solutions.
We’ll use Go in this article mostly because it is packed with a powerful Standard Library and a highly regarded concurrency model. All these can now be available in the browser.
There are three things we need to do to have a Web Assembly project running.
First, let’s initialize a Go project and create a new go file with a main function in the main package.
go mod init awesome
package main
func main() {
println("Hello!")
}
We’ll start with the usual “hello world” but don’t worry, we’ll get to some real use cases like interacting with the DOM or applying photo filters directly in the browser in a second.
Next, we need to compile our Go code into Web Assembly, and I’ll add the build command in a Makefile for convenience reasons.
run:
GOOS=js GOARCH=wasm go build -o main.wasm
Finally, once the binary is available, we can load it in our HTML file. Note that Go provides some “glue code” between the Web Assembly binary and the web browser’s JavaScript engine. This is needed because the generated binary file relies on functionalities like I/O operations or interfacing with the browser’s APIs, which are not natively provided by the default WebAssembly environment.
<head>
<script src="wasm_exec.js"></script>
<script>
if (WebAssembly) {
const go = new Go();
WebAssembly.instantiateStreaming(
fetch("main.wasm"),
go.importObject,
).then((result) => {
go.run(result.instance);
});
}
</script>
<title></title>
</head>
With the script loaded, we can load and initialize our module. When you open up this page in the browser you should see a message in the console.
Now that we have all the pieces in place, let’s look at something a bit more useful.
When developing web apps, interacting with the DOM and the browser’s APIs are pretty common tasks. Back in the go file, we’ll import the system call JavaScript standard module which gives us access to the Web Assembly host environment via an API based on JavaScript semantics.
package main
import "syscall/js"
func main() {
js.Global().Call("alert", "Hello!")
}
Now, we can access the global scope, and call the alert method with a random message. Once you compile this code and refresh your web page, the alert should pop up automatically.
Following this approach, we can execute any type of JavaScript code from our Go context. Let’s define an “updateHeader” function on the client, then, back in the go file we can easily call this function.
<head>
<script src="wasm_exec.js"></script>
<script>
function updateHeader(value) {
document.getElementsByTagName("h1")[0].innerHTML = value;
}
</script>
</head>
<body>
<h1>Awesome</h1>
<h2>Coding</h2>
</body>
Of course, we can update the DOM directly from Go as well. First, let’s store our global scope in a separate variable, access the document, and then get all elements by a specific tag name.
func main() {
// Call JS methods
g := js.Global()
g.Call("alert", "Hello!")
// Call custom methods
g.Call("updateHeader", "Really Awesome!")
// Update the DOM directly
doc := g.Get("document")
h2 := doc.Call("getElementsByTagName", "h2").Call("item", 0)
h2.Set("innerHTML", "WASM Coding")
}
This function returns a Node List in the browser, so we’ll call the item method to fetch the first element in this list. Then, we can update the inner html of the header.
Remember that this is Go code, so we enjoy all the benefits of type safety by default. Every time you compile your code, you’ll see that various errors might appear, guiding you towards the proper way of writing things.
Ok, next let’s work on something a bit meatier. The main advantage of WebAssembly over conventional JavaScript is its near-native performance.
Quick side note - web assembly is an intermediate representation which is compiled to machine code at runtime. This compilation can be done just-in-time, as the code is being executed, or ahead-of-time, before execution starts. Modern browsers and runtimes typically employ sophisticated optimization techniques during this compilation process to enhance performance.
This is why resource intensive tasks which were previously executed on the server can now run on the client as well.
Let’s take the scenario where our user uploads an image in our app, applies a grayscale filter to it, and downloads it back to his machine. While this can be achieved using JavaScript and the Canvas API, it was pretty common for our app to send the image to the server via http, and perform this task in a more controlled environment.
Thanks to Web Assembly however, we can get rid of the server round trip, and run these computations on the client hardware.
Back in our go file, let’s define an export function where we’ll expose a golang function in the JavaScript global scope. Once the file is compiled, and loaded in the browser, we’ll be able to invoke this code seamlessly.
func exports() {
js.Global().Set("grayscale", js.FuncOf(grayscale))
}
func main() {
exports()
}
The grayscale function takes in two arguments, the JavaScript “this” object, and a list of values which are passed in from the JS scope. The return type is an empty interface, which is a simple way of achieving dynamic typing in an otherwise statically typed context.
func grayscale(this js.Value, args []js.Value) interface{} {
input := make([]byte, args[0].Get("length").Int())
js.CopyBytesToJS(input, args[0])
// Decode the image data
img, err := jpeg.Decode(bytes.NewReader(input))
if err != nil {
// Handle error
}
// Create a new grayscale image
bounds := img.Bounds()
gray := image.NewGray(bounds)
// Iterate over each pixel in the original image and convert it to grayscale
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
px := img.At(x, y)
gr := color.GrayModel.Convert(px)
gray.Set(x, y, gr)
}
}
// Encode the grayscale image into a JPEG byte slice
var buf bytes.Buffer
jpeg.Encode(&buf, gray, nil)
// Create a JavaScript Uint8Array to hold the encoded data
jsOutput := js.Global().Get("Uint8Array").New(len(buf.Bytes()))
js.CopyBytesToJS(jsOutput, buf.Bytes())
// Return the JavaScript Uint8Array
return jsOutput
}
The uploaded file will be converted to a byte array, then passed over to web assembly. We’ll make sure that these bytes are correctly moved into the go context, and then we can decode that information into the actual image.
The filter we implemented is pretty straight forward. Images are matrices of pixels each containing Red Green and Blue values. We’ll iterate over all these pixels, and convert these values to Grayscale.
Once the conversion is performed, we’ll encode the information and convert it to a JavaScript Array which is then returned.
Before integrating this code in the JavaScript context, there is one additional important thing we need to do. For the Go code to interact with JavaScript, the program needs to remain running. If the main function were to exit, the WebAssembly module would effectively stop, and the registered functions would no longer be accessible from JS. We can easily make our program run “forever” via an unbuffered empty channel where the main thread waits to receive a value.
func main() {
c:= make(chan struct{})
exports()
<- c
}
I know this looks a bit weird if you are not familiar with Go, but working with channels is actually easy once you get the hang of it.
Next, let’s look at how our JS code can interact with WebAssembly functions.
I’m defining a file change listener, where I’m retrieving the uploaded image and converting it to an Array Buffer using the Browser FileReader API. Once the binary data is available, we’ll simply pass it to the grayscale function which is now available in the global scope.
<script>
function fileChange(input) {
const img = input.files[0];
const reader = new FileReader();
reader.readAsArrayBuffer(img);
reader.onloadend = (e) => {
const data = new Uint8Array(reader.result);
const gray = grayscale(data);
const blob = new Blob([gray], { type: "image/jpeg" });
document.getElementById("img").src = URL.createObjectURL(blob);
};
}
</script>
The resulting blob can then be easily inserted in the DOM.
While impressive, this is still a naive demo of the power unleashed by WebAssembly. Various established tools or games were ported to work on web platforms in recent years, making the browser a really versatile environment.
We can easily take things one step further by employing one of the most popular video conversion tools which was ported to WebAssembly. Once the library is loaded in the browser, we can follow the same logic to allow users to upload videos in our app. Once we get the bytes, we’ll load them up, convert them to grayscale, and send the output back to the user.
<script src="ffmpeg.min.js"></script>
<script>
async function fileChange(input) {
const videoFile = input.target.files[0];
ffmpeg.FS("writeFile", "input.mp4", await fetchFile(videoFile));
await ffmpeg.run("-i", "input.mp4", "-vf", "format=gray", "output.mp4");
const data = ffmpeg.FS("readFile", "output.mp4");
const url = URL.createObjectURL(
new Blob([data.buffer], { type: "video/mp4" }),
);
document.getElementById("vid").src = url;
}
</script>
Remember that all this is done directly in the browser and runs using the user’s hardware.
Looking back at some of the code, I believe we can all agree that the advantages of Web Assembly are undeniable. While we used Go in our examples, options like Rust are even more popular for this use case these days. Regardless of the language, type safety and mature tools are things browsers were lacking in the past. While JavaScript is still the de facto solution for the web, Web Assembly is an undeniably useful addition in the ecosystem.
If you liked this article you can become a member of my youtube channel for more exclusive content, or you can check my videos next.
Until next time, thank you for reading!