WebAssembly: Solving Performance Problems on the Web

    Camilo Reyes
    Share

    In modern JavaScript, the goal is often to find every way to optimize performance in the browser. There are times when web applications demand high performance and expect browsers to keep up.

    Traditional JavaScript has performance limitations because of how the engine treats the language. An interpreted (or even JIT-compiled) language that’s rendered as part of a page can only get so much — even from the most powerful hardware.

    WebAssembly is designed from the ground up to solve the performance problem. It can overcome bottleneck issues that traditional JavaScript wasn’t meant to solve. In WebAssembly, there’s no need to parse and interpret code. WebAssembly takes full advantage of its bytecode format to grant you runtime speeds that match native programs.

    Think of it another way: imagine traditional JavaScript as a good, all-purpose tool that can get you anywhere. WebAssembly, in contrast, is the high-performance solution capable of achieving near-native speeds. These are two separate programming tools now at your disposal.

    The questions for me are these: does WebAssembly replace good old traditional JavaScript? If not, is it worth the investment in learning WebAssembly?

    What Is WebAssembly?

    WebAssembly is a different type of code that can be sent to the browser. It’s in bytecode, format meaning that it’s shipped in low-level assembly language by the time it reaches the browser. The bytecode is not meant to be written by hand, but can be compiled from any programming language such as C++ or Rust. The browser can then take any WebAssembly code, load it as native code, and achieve high performance.

    You can think of this WebAssembly bytecode as a module: the browser can fetch the module, load it, and execute it. Each WebAssembly module has import and export capabilities that behave a lot like a JavaScript object. A WebAssembly module acts a lot like any other JavaScript code, minus the fact that it runs at near-native speeds. From a programmer’s perspective, you can work with WebAssembly modules the same way you work with current JavaScript objects. This means that what you already know about JavaScript and the web transfers into WebAssembly programming as well.

    The WebAssembly tooling often consists of a C++ compiler. There are many tools in current development, but one that has reached maturity is Emscripten. This tool compiles C++ code into a WebAssembly module and builds standards-compliant modules that can run anywhere. The compiled output will have a WASM file extension to indicate that it’s a WebAssembly module.

    One advantage in WebAssembly is that you have all the same HTTP caching headers when you fetch modules. Plus, you can cache WASM modules using IndexedDB, or you can cache modules using session storage. The caching strategy revolves around caching fetch API requests and avoiding yet another request by keeping a local copy. Since WebAssembly modules are in bytecode format, you can treat the module as a byte array and store it locally.

    Now that we know what WebAssembly is, what are some of its limitations?

    Known Limitations

    JavaScript runs in a different environment from any typical C++ program. Therefore, limitations include what native APIs can do in a browser environment.

    Network functions must be asynchronous and non-blocking operations. All the underlying JavaScript networking functions are asynchronous in the browser’s Web API. WebAssembly, however, doesn’t benefit from asynchronous I/O-bound operations. An I/O operation must wait for the network to respond, which makes all near-native performance gains negligible.

    Code that runs in a browser, runs in a sandboxed environment and doesn’t have access to the file system. You may create an in-memory virtual file system instead that comes preloaded with data.

    The application’s main loop uses co-operative multitasking, and each event has a turn to execute. An event on the web often comes from a mouse click, finger tap, or a drag and drop operation. The event must return control to the browser so that other events can be processed. It’s wise to avoid hijacking the main event loop, as this can turn into a debugging nightmare. DOM events are often tied to UI updates, which are expensive. And this brings us to another limitation.

    WebAssembly cannot access the DOM; it leans on JavaScript functions to make any changes. Currently, there’s a proposal to allow interoperability with DOM objects on the web. If you think about it, DOM repaints are slow and expensive. All the gains one gets from near-native performance get thwarted by the DOM. One solution is to abstract the DOM into an in-memory local copy that can be reconciled later by JavaScript.

    In WebAssembly, some good advice is to stick to what can perform very fast. Use the tool for the job that yields the most performance gains while avoiding pitfalls. Think of WebAssembly as this super-high-speed system that runs well in isolation without any blockers.

    Browser compatibility in WebAssembly is dismal, except in modern browsers. There’s no support in IE. Edge 16+, however, does support WebAssembly. All modern big players like Firefox 52+, Safari 11+, and Chrome 57+ support WebAssembly. One idea is to have feature detection and do feature parity between WebAssembly modules and a fallback to JavaScript. This way you don’t break the web and modern browsers get all the performance gains from WebAssembly.

    Can I Use wasm? Data on support for the wasm feature across the major browsers from caniuse.com.

    WebAssembly Demo

    Enough talk; time for a nice set of demos. This time we’ll explore export and import functions in WebAssembly. Export and import functions are the hallmarks of interoperability with WebAssembly. These functions enable programmers to work with WebAssembly modules like any other JavaScript object.

    An export function is one you get from a WebAssembly module. Once a module loads, you’ll find the export function inside instance.exports. For this demo, I’ll export an add function that calculates the sum of two numbers you pass in as parameters. The calculation will perform in near-native WebAssembly code. In this demo, the export function will be a pure JavaScript function — meaning that it’s stateless and immutable.

    An import function is one you feed into a WebAssembly module. It’s a plain old JavaScript object that has a callback function. Then, the module calls the function with parameters from WebAssembly. I’ll import a simple callback that receives one parameter from WebAssembly. The parameter is a constant assigned a value of 42. I’ll then use this value to set the DOM from JavaScript:

    <p>Add result: <span id="addResult"></span></p>
    <p>Simple result: <span id="simpleResult"></span></p>
    

    Exported WebAssembly Function

    First, let’s peek at the text format of the WebAssembly module. This is a text representation of the WASM module that can be read by humans. It’s designed for text editors or any other tool that can work with plain text:

    (module
      (func $add (param $lhs i32) (param $rhs i32) (result i32)
        get_local $lhs
        get_local $rhs
        i32.add)
      (export "add" (func $add)))
    

    It’s not overly important to understand every detail here. This is the text format of the WebAssembly module which you often find with a WAT file extension. The i32.add performs the addition using near-native code. The export "add" then grabs func $add and makes it available to JavaScript.

    To load up the WebAssembly module, you can do this:

    // URL to the WASM module
    const WASM_ADD_MODULE = 'https://myhost.com/add.wasm';
    
    fetch(WASM_ADD_MODULE)
      .then(response => response.arrayBuffer())
      .then(bytes => WebAssembly.instantiate(bytes))
      .then(result => document.getElementById('addResult').innerHTML =
        result.instance.exports.add(1, 5));
    

    The Fetch API gets the module from a URL and turns it into a byte array. This byte array comes from the response.arrayBuffer. Note that inspecting the exported function exports.add says that it’s compiled as native code:

    function 0() {
      [native code]
    }
    

    One gotcha is that using WebAssembly.instantiate is more lenient than WebAssembly.instantiateStreaming. The latter says that WASM modules must have a MIME type of application/wasm. You’ll run into this issue when you get a TypeError while working with it. If you’re serving up WASM modules through a CDN and can’t control the MIME type, then use WebAssembly.instantiate. WebAssembly.instantiateStreaming is more efficient than the former, but it’s a newer web API so it’s not available in all modern browsers yet.

    Imported WebAssembly Function

    For imported functions, start with this module in text format. Imagine doing this CPU-bound and expensive calculation in WebAssembly. So intense, in fact, it’s the answer to the ultimate question of life and everything.

    For example:

    (module
      (func $i (import "imports" "imported_func") (param i32))
      (func (export "exported_func")
        i32.const 42
        call $i))
    

    Note the constant i32.const 42 being declared. Then, take the imported function and call the callback function with call $i. The export "exported_func" declares the name of the exported function one calls from JavaScript.

    In JavaScript, we can work with this module in this way:

    const WASM_SIMPLE_MODULE = 'https://myhost.com/simple.wasm';
    
    const simpleFn = (arg) => document.getElementById('simpleResult').innerHTML = arg;
    const importSimpleObj = {imports: {imported_func: simpleFn}};
    
    fetch(WASM_SIMPLE_MODULE)
      .then(response => response.arrayBuffer())
      .then(bytes => WebAssembly.instantiate(bytes, importSimpleObj))
      .then(result => result.instance.exports.exported_func());
    

    Look at importSimpleObj, as this is the JavaScript object that has the callback function. The exports.exported_func then executes the WebAssembly module. Once called, the imported function simpleFn runs with the constant parameter.

    Below is a CodePen demo you can play around with. Feel free to inspect every function and object on this code sample. This will give a good feel for the glue code necessary to integrate with WebAssembly.

    See the Pen WebAssembly Demo by SitePoint (@SitePoint) on CodePen.

    Conclusion

    To answer my first original questions, WebAssembly is a nice complement to the Web. It’s not meant as a replacement to JavaScript, but only enhances current web technology. Any web engineer looking for speed, efficiency, and high performance should be looking at WebAssembly. JavaScript works as the glue code that executes and handles the result from WebAssembly.

    One idea is to port existing JavaScript code that does a lot of CPU-bound work — say, an in-memory virtual representation of the DOM that only abstracts the real DOM. The WebAssembly port, for example, can also provide an elegant fallback to browsers that don’t yet support WebAssembly.

    As WebAssembly modules become more prevalent, npm packages may come with these modules that are behind a nice JavaScript abstraction. This both enhances the current ecosystem and increases code reuse. There may come a time when authoring your own WebAssembly modules won’t be necessary.

    The possibilities are endless with WebAssembly. It’s a tool you can add to your arsenal now — to solve many performance bottlenecks one encounters on the Web.