Why using WebAssembly and Rust together improves Node.js performance
A team at IBM achieved incredible performance improvements using WebAssembly and Rust
Have you or your team ever run into a situation where application speed and developer productivity were incredibly important, but the business demands were rapidly shifting? I work on the IBM Garage Method team, and my team found ourselves in this exact situation when I was tasked to find a solution and build a proof-of-concept transpiler for our team’s content files.
I needed to find a technology stack that would enable us to deliver on all fronts and, at the same time, be something that integrated well with our existing toolchain. The following article details how I came to decide on using Rust together with WebAssembly and talks about why the combination of those two technologies is so powerful.
Note: The work surrounding this tool is still a work in progress and we have not fully implemented everything that is a business requirement at this point.
The journey to Rust and WebAssembly
First, allow me to elaborate on the business demands that I mentioned earlier. Our team is currently in the process of rewriting and refactoring our entire application that currently uses a domain-specific language (DSL) for all of our content. We have thousands of these files, totaling over a gigabyte of content, and sometimes, we even have multiple templates contained in the same file.
For a successful rewrite, we need to automate how we transpile the content to a new system. We also have to consider that hundreds of our content writers are trained on how to use this DSL, so any change will require them to adapt to the new system. With those requirements, we had to decide between two options:
- A command-line tool that we would run one time to mass migrate our existing content. The content writers would learn the new system.
- A command-line tool that we could run as a mass migration OR as a means to run on locally changed files for more incremental builds so that the writers could continue using their existing system.
Since our team needed maximum flexibility and we did not think it was feasible to retrain all of our content writers, we decided to go for the second option and build a tool that we could run either once or as an incremental build step.
One of our first performance considerations was related to speed: If we expect to run this every time someone commits their changes, the transpiling process has to be fast and happen within seconds. In one of our scenarios, we considered exposing the tool as an API that transforms content and serves the transpiled result back to a client. This solution increases the performance considerations considerably, as some of these files can be over a megabyte by themselves. With this case, the file would be sent, parsed, rendered to the new format, compressed, and then sent to the client in a few seconds. So, speed was a top consideration.
The second major consideration was the ability to run the transpiler on the web. Many languages could process these files quickly on a local machine, but far fewer achieve near-native speeds if moved to the web.
Finally, we needed a language that we could easily and efficiently integrate with our Node.js-based environment. Our team has come to appreciate the Node.js ecosystem, and the newly refactored application will be a Vue-based client with an Express-based backend implemented as a REST API. Node’s tooling, open source community, and the ability to write our client and server in the same language (in our case, Typescript) is an enormous boon to our team’s agile delivery schedule. We knew we had to keep these amazing benefits for our applications, but we also knew that to get the performance we needed, we had to look elsewhere. Thus, having excellent integration with our main Node apps was an absolute necessity.
After we understood our business requirements, it was time to strum up some proof of concepts.
First proof of concept: Scala
My initial thought process was to look for a language with the following properties:
- Functional programming support
- Well-established with major businesses using it in production
- Documented ability to work well as a language for parsing
After initially working on the parsing module with Scala, I was able to achieve a decent amount of progress, even parsing the basic rules of our DSL (based on Handlebars and Markdown). As I continued on, though, I quickly found numerous issues with the architecture.
Plus, developing in Scala was very slow. Tests for test-driven development easily took 5+ minutes to run every time, slowing down the process considerably. It also was failing in the performance category, with basic parsing tests taking anywhere from 500-1000ms in development configuration to 300-450ms in release configuration.
Note: I am not primarily a JVM developer, so I am certain that better performance could be achieved, but we wanted something that was performant by default, not with hacking around in Scala or the JVM if we didn’t have to.
Second proof of concept: Rust
After finding Scala unsuitable for our needs, I started to explore other alternatives — and Rust was one of the first on my list because I had been working with Rust for a year and a half. Rust checked all the boxes mentioned in the first POC, and, most crucially, has excellent support for WebAssembly. First, I prototyped new functionality for the parser in Rust, and then translated it to Scala at the same time that I wrote tests and benchmarks for both.
I immediately noticed it was easier to work with Rust than with Scala, and could quickly develop new solutions and parsing rules. I also found that running the unit tests and the parsing itself were all much faster. I sent code samples of the same functionality written in both Scala and Rust to some of my team members and our development lead. They all found the Rust code far easier to understand, and that was when our dev lead approved moving my efforts over to Rust as a second POC.
The results were nothing short of amazing for our team! For starters, I moved all of our previous work in Scala to Rust in only a week and a half. After a few more weeks, I had implemented far more parsing rules. At this point, we wanted to see what kinds of performance increases we got from Rust, and, again, the results were astounding! Go ahead and take a guess at the percentage increase we saw in performance in release mode? 200%? 400%? 800%? Nope, all good guesses, but we saw an extraordinary 1200-1500% increase in our speed! We went from 300-450ms in release mode with Scala with fewer parsing rules implemented, to 25-30ms in Rust with more parsing rules implemented!
At this point with Rust’s ability to integrate so easily with Node.js and our team as a whole, and the incredible performance gains that made both the CLI and possible API feasible, we knew we finally had a winning combination.
Our 1200-1500% increase in speed might not be the norm, but there are other examples of companies that have started to move to Rust achieving 200% plus speed improvements over Java and greatly reducing memory consumption. A great article and example of this is hosted on BitBucket and written by a developer at Oviyum Technologies which can be found here.
Now that you understand how these technologies have worked for our team, let’s talk a little bit about the technologies themselves and why they are so popular.
So, what exactly is Rust? Rust is a relatively new systems programming language that was started by, and is still supported by, Mozilla. Mozilla is also the company that develops Firefox, and they use Rust in production code for their browser, which helps them to find pain points within the language and increase Rust’s performance and usability.
Even though Rust is relatively new to the scene of programming languages (only hitting 1.0 on May 15, 2015), developers and companies alike have already quickly adopted the language. In fact, according to the annual Stack Overflow survey, Rust has topped the charts for most loved language every year since it’s 1.0 release. That’s four years in a row! In the 2019 survey, Rust also jumped up to 6th place in the “Most Wanted” list, highlighting that more developers wanted their companies to adopt Rust and let them use it in their projects. So, what does Rust offer that garners it so much love and support from developers?
There isn’t a singular thing that Rust does so much better than any other programming language, but, rather, how the whole package comes together creates a remarkable and unique solution! Rust offers an impressive list of features which can fit rather nicely into four distinct categories:
Let’s take a look at some of the highlights from each of these categories so you can see why Rust is so popular!
- Rust strives to compete with the likes of C and C++ in terms of performance with features like zero-cost abstractions that mean you pay for only what you use and not a large runtime regardless of whether or not you use all of it’s features.
- The binary size and memory footprint are also comparable to C and C++ which is rather remarkable and paves the way to use cases in embedded, IoT, and other memory-limited environments.
- The application performance isn’t the only performance increase to be found. Developer productivity is also much greater with features taken from higher-level languages like advanced pattern matching, easy to use syntax, and excellent compilation error messages.
- Rust has many ways of helping your app stay memory- and thread-safe like assisted memory management. While managing memory is never quite as easy in terms of developer productivity, compared to working with C and C++ it is rather easy for developers to pick up on, and almost impossible to do in an unsafe way unless you use the
unsafekeyword in Rust basically allows you to perform operations that the compiler cannot guarantee that you are safe to do. Some examples include: dereferencing a raw pointer, calling an unsafe function or method, or working with code from a C interface. This makes it rather easy to grep for
unsafein code reviews and find where a related issue is coming from. I would say the difficulty is between Go and C++ in terms of dev effort in memory management, but it gets far easier the longer you do it.
- Features like option types that eliminate
nullptrexceptions (unless again, you use the
unsafekeyword) also greatly reduce the amount of errors that won’t be caught by the compiler and lead to crashes or even worse, undefined behaviour at runtime.
- The compiler really holds your hand when working through the errors that you do get. This lets you focus on your business objectives rather than bug hunting or deciphering cryptic messages.
- Supports compiling for every major operating system.
- A package manager, Cargo, that acts like npm from Node.js or pip from Python. This makes it vastly easier to integrate packages, either locally or remotely into your application or library.
- It’s very easy to manage and install multiple Rust versions using
- In addition to the great tooling surrounding the language itself, it also has an incredibly easy way to work with other languages through its set of Foreign Function Interface (FFI) tools.
- Rust has a very active official Discord channel which is always ready and willing to help! I was helped quite a few times by various people as I was working through the transpiler project.
- Excellent official learning resources and books created and maintained by the community which greatly reduces the challenges of onboarding new developers to the language.
- Rust receives support from many major partners and companies through monetary and code contributions. The rate of adoption is also remarkable for how long the language has been past its 1.0 release.
- The Request for Comment (RFC) process to decide what gets worked on next has been a good process thus far, and their scheduled releases every six weeks proves that the development is not stagnant. At the same time, the releases don’t create breaking changes that library developers have to deal with!
Now that you’ve seen what makes Rust such a unique offering, let’s now take a look at WebAssembly and how it is changing the game of web development!
- Compilers and language virtual machines
- Opt-in security measures
- And more!
So with all of this excitement and amazing potential, what are some actual use cases for Wasm? Check out Wasm’s non-exhaustive list to see a broader view, but here is a smaller list that I’ve personally seen or worked with:
- AR/VR web applications
- Data science
- Computer-aided drafting (CAD)
If you’d like more information on Wasm itself, I highly recommend looking through https://webassembly.org as they have an incredible amount of information on the project. For now though, let’s look at how we interface with Wasm through Rust and how that toolchain works.
Putting Rust and Wasm together
Between Rust and Wasm’s extensive tooling, excellent efficiency, and portability across a broad spectrum of hardware and software environments, they can form a formidable combo when working together. Let’s look at how they can integrate and what tools they offer to make that as seamless and easy of a process as possible.
Stable, easy interoperational tooling
First let’s dive into the integration toolchain that links Rust and Wasm as it is one of the most stable and easy to use out there. The toolchain supporting these languages is already very mature, with both languages having great tools and documentation to assist in deploying your applications.
Some of those tools that you will commonly use include the following:
wasm-pack: Helps you build and publish
npmpackages or es6 modules
wasm-opt: Optimizes the generated .wasm binary produced by tools like
rustcwhich is used by
wasm2js: Can transform .wasm files into .js files for supporting browsers with no Wasm support like Internet Explorer. For those familiar with it, this tool basically produces files that act very similarly to asm.js
twiggy: Code size profiler for Wasm binaries that helps spot and eliminate dead code in your binary
Notice that there’s only one Rust-specific tool and the rest are all general Wasm tools. Believe it or not, that’s actually a huge advantage! This one tool does everything you need unlike many other languages where you need to use multiple tools or manual processes.
Let’s look a little closer at what
wasm-pack optimizes your binary size, generates TypeScript definition files for easy interoperability and editor assistance, and creates a package.json file that you can use as an
npm module right from the start! Each one of these steps would be a separate tool or even a manual process with a tool like
emscripten for C/C++. Languages like Go don’t even have an automated way to generate TypeScript definition files. The lack of definition files also makes it much more difficult to discover what is being exported and available from the binary package.
Another major advantage of using Rust with Wasm is the execution speed and the binary size. Both of these are comparable or even a little better than other lower-level, non-garbage collected languages like C and C++. In contrast, even a relatively small runtime language like Go has a hello world binary size of 2MB after being compiled to .wasm.
Rust’s hello world binary size is a meager 1.46KB after being compiled to .wasm. You also will see proportional execution speeds to native applications when compiling to .wasm, so well-written Rust/C/C++ will still outperform well-written Java/Go/Python. Sorry to burst any notions of Python becoming ridiculously fast!
The last point I wanted to touch on is just how portable this tech stack can make your Rust code. A perfect example being the project I’ve been working on for my team. It started as a CLI tool meant to run as a build step. As business requirements changed, we needed a way to take this tool to an API format as well. We were able to handle these changing requirements with ease, knowing that we could write our entire API in Rust, or we could use only the main functionality of the library and wrap it in a Node.js-based setup to help our Node.js developers.
Having the ability to not force everyone on our team to have to learn a new language was a huge benefit, since we could incrementally add more people to the project as needed. This opens a whole new realm of possibilities for lower-level projects, especially when it’s being added as a library to an existing application through a C-based interface, or a brand new project that’s just starting out like ours was!
wasm-pack that make it easy to integrate Rust with Node, you can very easily adopt Rust and Wasm incrementally for your most performance-critical workloads, and then you can decide if you want to move the other areas over as well. Having that kind of versatility with ever changing needs is exactly why we chose Rust and Wasm for our project!
I hope that this article has inspired you to take a look at if there is a place for Rust or Wasm on your team! If you have shied away from Node.js in the past, especially out of concern about performance, there is now a solution to that problem, and thankfully it is a very seamless integration.
If you’d like more info on Rust and Wasm, I highly recommend going through their official book which takes you through creating Conway’s game of life in Rust and Wasm and shows you how to optimize and profile your solution. You can find that book here. Be sure to keep a lookout on IBM Developer for more upcoming content on using Rust and Wasm together. In my next article, we’ll take a look at what Node devs need to know to be successful with Rust!