[Deciding When to Replace JavaScript Modules With WebAssembly for Performance Gains]
Introduction
JavaScript (JS) has long been the backbone of web development. However, it can't meet the demand of performance-intensive web apps, such as 3D games, video editing or large data processing tools. This limitation has spurred the development of WebAssembly (WASM), a binary instruction format designed as a target for performant high-level languages like C, C++, and Rust, enabling them to run on the web at near-native speed. In this article we explore JS limitations, pros and cons of WASM, cases of WASM integration and methods for spotting JS code suitable for replacement.
Use cases in production
Before we delve into the technical aspects, let's pause to acknowledge some notable successes in integrating WASM. While these achievements are indeed remarkable, be cautious of survivorship bias.
Figma is a web-based design tool, experienced a significant performance boost in 2017. By transitioning from JS transpiled C++ to WASM compiled C++ using emscripten, and WebGL for rendering graphics, they achieved a 300% speed increase. Although the switch didn't reduce the size of app's bundle, the WASM binaries are cached by the browser after the initial load, making load times on subsequent visits almost immediate.
Micrio is a web technology for viewing 3D and high-resolution images, commonly used by museums and photographers. In 2020 engineering team migrated from JS to AssemblyScript (TypeScript-based language compiled to WASM) + WebGL suite which gave them 65%speed boost and 60% bundle size reduction.
Doom 3. In 2018 Gabriel Cuvillier ported Doom 3 to a web app, using compiled with emscripten C++ and WebGL. To run smoothly, the game requires a robust CPU and at least 850MB of RAM, achieving about 30-40 FPS across modern web browsers.
Flawed nature of JS
JS is a great language known for its speed and ease of use, making it a great choice for quick development at a relatively low cost. However, it's not the best fit for every project, especially as they grow and require better performance. That's why some teams consider integrating WASM to their projects, to overcome the limitations of JS.
JS is a dynamically-typed language, where variables don't have a fixed type. That’s why, JS engine must inspect the types of variables each time it executes a line of code to ensure it performs the correct operations. Even though modern engines, like V8, have made incredible job optimizing Just-In-Time (JIT) compilation and type prediction techniques for JS runtime, deoptimizations are inevitable due to JS inherent flaw. If a variable's type changes in a way that contradicts compiler’s assumptions, the engine must deoptimize and recompile the code, reverting to a slower, more general execution path. This comes at a huge cost in terms of performance.
Memory management in JS is handled through garbage collection (GC), automated mechanism for releasing unused memory. While being convenient, GC reduces app’s performance and predictability. To release unused memory, GC must occasionally pause the execution of the app. During this pause the app is completely frozen for a few of milliseconds. Timing of GC is controlled not by developer, but the engine, so it can cause unpredictable performance.
JS can execute only one piece of code at a time. This single-threaded model is managed through an event loop. If an operation, for example 3D model rendering, takes a long time to complete, it can block the event loop. Blocking leads to a frozen UI and crushes. Unlike Rust or C++, JS can't utilize multiple CPU cores to perform parallel data processing.
Multiple benchmarks have shown that JS is actually capable of complex computation particularly with small datasets, thanks to highly optimized engines. However, analogs written in Rust/WASM or C++/WASM have shown more impressive results with large datasets.
Nonetheless, benchmarks should be approached with caution for several reasons. First, the "artificial" nature of benchmarks often doesn't accurately reflect real-world performance. Second, the level of optimization applied to the WASM code significantly affects the outcomes. Unoptimized WASM can exhibit surprisingly poor performance and produce large binary sizes. The key factor is how well the source code has been adapted for compilation to WASM and execution within the WASM virtual machine.
Key features of WASM
By using WASM as a compilation target, developers can execute Rust, C or C++ in the web and take advantage of the vast libraries and tools available for these languages.
WASM is designed to be platform-agnostic. This universality reduces concerns about code performance variability across different browsers and OS. However, it's important to note that the efficiency of WASM execution can still vary depending on how each platform's engine handles WASM. For example, some browsers might be more optimized for WASM execution than others.
WASM uses static typing, which eliminates the possibility of deoptimization. WASM's runtime is simpler and potentially more efficient than the dynamic nature of JIT-compiled JS.
Unlike JS, WASM supports multi-threading, allowing for concurrent execution of tasks. Effective use of multi-core processors can significantly improve the performance of computationally intensive tasks.
As WasmGC (proposal, which aims to integrate GC into WASM environment) is not accepted yet, WASM has no GC and utilizes only linear memory. This absence of GC provides developers with increased control over memory management but also places a higher responsibility on their shoulders. Managing memory manually can lead to significant performance improvements, yet it may also result in more complex code and larger binary sizes. Additionally, employing complex data structures such as lists or hash maps in WASM can lead to performance issues due to its linear memory model.
Caveats of using WASM
- In spite of recent advancements, the interaction between WASM and JS remains a challenge. While WASM is highly efficient at running compiled code, the process of transferring data between WASM and JS can negate these performance gains.
- WASM can’t interact with DOM without relying on JS methods. Given the slow pace of WASM-JS communication, this limitation is a significant concern.
- The handling of strings and complex data structures is costly since they must be converted into formats that WASM can understand, leading to considerable overhead.
- Incorporating JS libraries into a front-end app that uses WASM is not straightforward. Developers might struggle to integrate WASM code with libraries, usually written in JS.
- Poorly optimized WASM often results in larger binary sizes and longer loading times compared to JS. It may be fixed with rigorous optimization but it’s time-consuming and not necessarily effective. The need to include support for higher-level concepts within the compiled binary can make WASM less suitable for apps where minimal download size and quick startup times are critical.
- As we mentioned earlier, JS executed in highly optimized engines can often outperform WASM. These engines are specifically designed and continuously refined to execute JS with maximum efficiency, using JIT-compilation and other optimization strategies that aren't applicable to WASM binaries.
- Maximizing WASM's performance benefits demands extensive hand-optimization and a thorough understanding of both the source language (e.g., Rust) and WASM's specifics. Simple alternatives, such as AssemblyScript, might not deliver optimal performance without further fine-tuning.
- Debugging WASM can be challenging, as it often involves writing code in one language and debugging in another, without the support of mature tools tailored to WASM. This can slow down development and complicate bug fixing.
- The pool of developers proficient in WASM is relatively small, which further complicates the development and maintenance of projects using WASM.
When WASM integration is justified
Deciding to migrate your entire app or rewrite specific parts to WASM requires careful consideration of various needs, requirements, and conditions. Here are some arguments for integration:
- Critical client-side performance: Your app's speed on the user's end is essential and you've exhausted all other optimization options
- Complex front-end computations: Your app performs complex computation at front-end, e.g. graphics rendering or data processing in real-time
- Handling unpredictable variable types: Your app frequently falls into deoptimization in runtime. I’d recommend to refactor your existing code first. If issues persist, consider rewriting crucial parts in statically typed languages (like Rust or C++), compiling them to WASM, and calling WASM methods from your JS code. If the speed is still insufficient, try to optimize WASM-JS communication or rewrite the whole module to use WASM so as to minimize interaction with JS.
- Reducing GC overhead: Your app frequently allocates large amounts of memory, leading to noticeable slowdown and GC pauses. Again, try to optimize memory usage within JS first. Should inefficiencies continue, adopting a language with manual memory management for the problematic module might help manage memory more effectively.
WASM's limitations are manageable: Consider WASM if its known drawbacks won't hinder your project:
- There's minimal need for communication between WASM and JS modules.
- The app doesn't require manipulating the DOM, BOM, Web APIs, or using JS libraries directly from WASM.
- Your project or module can work well with minimal use of complex data structures and strings.
- Larger binary sizes and longer initial loading times won't negatively impact the user experience.
Team readiness: you and your team are ready for the challenges that come with using WASM, such as ongoing learning, hand-optimization, and obscure debugging.
Criteria for identifying JS code replaceable with WASM
- CPU load: math operations, loop nesting, deep recursion, high cyclomatic complexity.
- Data types variability.
- Extensive memory allocation.
- No obstacles to WASM integration: minimal communication with JS modules, libraries, APIs, moderate use of strings and complex data structuresю.
Automation
Based on the criteria mentioned above, I've created a prototype CLI tool in Rust named wasm-grate. Currently, wasm-grate is just an experimental tool capable of very simple analysis.
Wasm-grate performs static analysis on JS/TS code, creates an Abstract Syntax Tree (AST), and uses the Visitor pattern to spot code sections that could benefit from optimization with WASM. When it identifies code that meets the criteria, wasm-grate assesses the code's complexity. If it surpasses a certain threshold, the tool generates a report in the console. This report details the code's location, its complexity on a scale from 0 to 10, and place of declaration. Users can customize the threshold levels for different criteria (like setting a maximum for loop nesting) in a configuration file.
You can run wasm-grate on your project but keep in mind it’s just a prototype. I would greatly appreciate any feedback or assistance.
Conclusion
The idea of enhancing JS code with WASM can seem quite appealing, especially after hearing about the successful integrations mentioned earlier. However, as we've discovered, WASM is complex and integrating it into existing front-end apps can present serious challenges. Before making such an important decision, it's crucial to carefully evaluate your project's needs and requirements, consider both the benefits and limitations of WASM, and plan your strategy accordingly. While WASM has the potential to significantly improve performance of your app, maximizing its capabilities requires considerable effort.
Summary
This article showcases examples of successful WASM integration to JS projects, delves into the limitations of JS that made it unacceptable for performance-intensive web apps, and examines the pros and cons of adopting WASM. It also guides on identifying the optimal time and area for WASM optimization. Furthermore, it outlines how to pinpoint JS code that can be efficiently replaced with WASM and provides overall advice on incorporating WASM into existing front-end apps to boost their performance.
References
- Clark L. What makes WebAssembly fast? // What makes WebAssembly fast? – Mozilla Hacks - the Web developer blog
- Clark L. Calls between JavaScript and WebAssembly are finally fast // Calls between JavaScript and WebAssembly are finally fast 🎉 – Mozilla Hacks - the Web developer blog
- Duin M. Going from JavaScript to WebAssembly in three steps // From JavaScript to WebAssembly in three steps
- Hall S. Case Study: A WebAssembly Failure, and Lessons Learned // Case Study: A WebAssembly Failure, and Lessons Learned
- Jangda A., Powers B., Berger E.D., Guha A. Not so fast: analyzing the performance of webassembly vs. native code // Usenix Annual Technical Conference (Renton, USA, July 10–12, 2019). – Renton: 2019. – P. 107.
- Ketonen E. Examining performance benefits of real-world WebAssembly applications: a quantitative multiple-case study: Bachelor’s thesis – Lahti University of Technology, 2022. – P. 19-29.
- Kievits D. What effect does applying WebAssembly have on a compute intensive client-side application versus JavaScript?: A Thesis in the Field of Computer Science for the Degree of Bachelor of Science. – Rotterdam University, 2021. – P. 18-23
- Marshall K. Trash talk: the Orinoco garbage collector // Trash talk: the Orinoco garbage collector · V8
- Surma. Replacing a hot path in your app's JavaScript with WebAssembly // Replacing a hot path in your app's JavaScript with WebAssembly | Blog | Chrome for Developers
- Surma. Is WebAssembly magic performance pixie dust? // Is WebAssembly magic performance pixie dust? — surma.dev