Getting started with Rust and WebAssembly

Rust's Ferris (crab mascot) and the WebAssembly logo

WebAssembly (Wasm) is a binary instruction format (similar to assembly) supported by all major browsers and various runtimes. It is nearly as fast as natively compiled code while being cross-platform and sandboxed. WebAssembly, like assembly, is normally used as a compilation target for compiled languages like Rust, C, C++, C# and others (some interpreted languages can also run in Wasm). JavaScript can interface with WebAssembly to leverage its power, but this should be done with care as switching from JavaScript to WebAssembly has overhead (upfront load times and possible serialization / deserialization across the boundary), which can nullify the advantage of using WebAssembly if the switch is made too often.

We’ve prepared a repository that allows you to easily get started with compiling Rust to Wasm, currently focusing on the following targets (JavaScript / TypeScript):

The repository supports the following 3 methods of instrumentation for building and running Rust (compiled to WebAssembly) + .js / .ts files:

  1. cargo-make (Node.js, Deno)

Cargo make allows you to define and run tasks in .toml files, while specifying various dependencies and allowing for a lot of useful instrumentation around your tasks. Here we use cargo-make to invoke wasm-bindgen-cli (which is partially what wasm-pack does, but it does not support Deno for example), deno, node and others. This condenses the entire build into cargo make build/run-target.

  1. wasm-pack (Node.js)

wasm-pack is a tool seeking to simplify workflows involving compiling Rust to WebAssembly for use in the Browser or Node.js. For Node.js this is the simplest flow, but wasm-pack does not currently support Deno as a target. As the Webpack flow will already invoke wasm-pack we don’t recommend using this directly if you’re using Webpack.

  1. Webpack (Browser)

Webpack’s Rust compilation is mostly equivalent to option 2, with a different target flag (bundler) for wasm-pack and added bundling / Webpack functionality for JS. We’ll go over the configuration for Webpack later in this guide.

To compile Rust to Wasm, we first start with our Rust entry point. This is a normal lib.rs file (as we intent to export functions from Wasm). In our example, our file looks like this:

use wasm_bindgen::prelude::*; // ¹ #[wasm_bindgen] // ² pub fn greeter(name: &str) -> Result<String, JsError /* ³ */> { Ok(format!("Hello {name}!")) } #[wasm_bindgen(start)] // ⁴ fn main() { console_error_panic_hook::set_once(); // ⁵ }

This file, which has a greeter function that outputs Ok(format!("Hello {name}!")) (more on the error value later), and a main function. This mostly looks like normal Rust, other than a few key points:

¹ use wasm_bindgen::prelude::* - wasm_bindgen is a library and CLI that exposes high-level interactions between Rust and JavaScript. It generates glue code and bindings for your Rust code and automates things that would otherwise need to be done manually. Here we’re useing wasm_bindgen's prelude, which exports a few convenience types to be used when interfacing with JS. See other points for our usage in this example.

² #[wasm_bindgen] - This is an example of using the wasm_bindgen attribute macro, which exposes a function to JavaScript. This allows us to call our Rust code from JavaScript by name. The function using the attribute must be public.

³ JsError - This type encodes a JavaScript error, which allows Rust to emulate the behavior of a normal JavaScript function. If we so desired we could also return a non Result value.

⁴ Here we’re using the wasm_bindgen macro with the argument start, which exposes a function that runs only once when the Wasm binary is loaded. We can use this section of the code to prepare various things or set hooks.

console_error_panic_hook::set_once() - console_error_panic_hook prints Rust panics (be it from panic!(), todo!(), unreachable!() or any other operation that can panic) to the console (via JavaScript console.error()) to make panics user / debug visible and emulate JavaScript behavior. Using set_once here ensures that additional invocations do not set the hook again.

As this guide deals with Webpack, Node.js and Deno, our example does not contain use of web_sys, a crate that exposes browser APIs (e.g. window, document) to Rust. web_sys is split into many granular features so that you can use only what you need. Similarly, if you wish to interface with JavaScript builtins (e.g. JSON, Math) you can use js_sys. See also wasm-bindgen-futures - Converts between JavaScript Promises to Rust Futures.

If you just need to expose pure Rust functionality, you don’t need to use the _sys crates. Note that interfacing back and forth between Rust and JavaScript has a performance overhead as stated above, switches back and forth should be minimized in performance sensitive contexts.

Our Cargo.toml (Rust manifest file) is as follows:

[package] name = "getting_started_with_rust_wasm" version = "0.1.0" edition = "2021" license = "Apache-2.0" homepage = "https://grafbase.com/blog" repository = "https://github.com/grafbase/getting-started-with-rust-wasm" # ¹ [lib] crate-type = ["cdylib"] [dependencies] # ² wasm-bindgen = "0.2" # ³ console_error_panic_hook = "0.1" # ⁴ [profile.release] opt-level = "z" strip = true lto = true codegen-units = 1

¹ crate-type = ["cdylib"] - This specifies that our will be built as a C compatible dynamic library, which helps cargo pass the correct flags to the Rust compiler when targeting wasm32.

² wasm-bindgen - See lib.rs above

³ console_error_panic_hook - See lib.rs above

[profile.release] - As we’re targeting Wasm which may be transmitted over the wire / read at runtime by a browser, these are a few optimizations meant to prioritize binary size over compilation speed (except in the case of opt-level which also affects execution speed).

  • opt-level - By setting the opt-level to "z", we’re instructing the Rust compiler to reduce LLVM’s inline threshold, which effectively means less code at the price of more function calls. Normally LLVM can decide to inline a function in more cases which would result in a larger but more performant binary. We could also use "s" here to only partially make this change (allow loop vectorization).
  • strip = true - This strips everything (symbols, debuginfo) from the binary. This reduces the resulting binary size at the cost of losing the names of functions in backtraces and other debug info.
  • lto = true - This instructs LLVM to perform link time optimization (LTO), which is not used by default. LTO is slower when compiling but can perform additional optimizations which can reduce the resulting binary size.
  • codegen-units = 1 - This setting normally allows splitting a crate into multiple “codegen units”, allowing LLVM to process the crate faster but giving up on certain optimizations which may reduce size. Setting this to 1 enables LLVM to perform those optimizations at the cost of slower compile times.
  • See johnthagen/min-sized-rust for more details

Our Node.js and Deno files are similar, and are as follows:

import { greeter } from '../pkg/getting_started_with_rust_wasm.js' const greeting = greeter('Grafbase') console.log({ greeting })

Both are just a simple import and usage of the function we exposed from WebAssembly, with a log of the resulting value for demonstration. Our build methods do all of the work here so no extra configuration is needed.

Our Webpack entrypoint is similar to the above:

import { greeter } from '../pkg' let greeting = greeter('Grafbase') document.getElementById('root').innerText = greeting

With the distinction that it imports the entire pkg directory, and writes the result of greeter to the DOM rather than to the console.

This target requires an additional configuration for Webpack, seen here:

import WasmPackPlugin from '@wasm-tool/wasm-pack-plugin' import HtmlWebpackPlugin from 'html-webpack-plugin' import path from 'path' import { fileURLToPath } from 'url' // these are needed since we're using a .mjs file const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) export default { entry: './src/bundler.js', output: { path: path.resolve(__dirname, 'dist'), filename: 'index.js', }, plugins: [ new HtmlWebpackPlugin({ template: 'src/bundler.html', }), // ¹ new WasmPackPlugin({ crateDirectory: path.resolve(__dirname, '.'), }), ], // ² experiments: { asyncWebAssembly: true }, mode: 'production', }

¹ WasmPackPlugin - This plugin allows Webpack to run wasm-pack directly and reload when Rust files change

² experiments: { asyncWebAssembly: true } - Supports the new WebAssembly specification which makes WebAssembly modules async. Required for working with the output of wasm-pack.

The different methods of compilation detailed above will all result in a pkg directory in the root of the repo with a compiled .wasm file or files, glue JS code, TypeScript type definitions and possibly various files for deployment (e.g. package.json, README.md) depending on the method you used. This output is then imported by the various .ts / .js files and used.

Size

These are the artifact sizes for the provided example repository build:

  • WebAssembly: ~22.5 KB (21-24 KB depending on the target)
  • JavaScript: 5 KB

Note that we do not remove the standard library or perform a few other case dependent optimizations in the provided repository, see johnthagen/min-sized-rust for possible additional optimizations that may fit your use-case. WebAssembly binaries may also be lazy-loaded when needed / after page initialization to improve performance if needed.

Node.js

  • With wasm-pack
    1. wasm-pack build --target nodejs - Builds and generates bindings for src/lib.rs
    2. node src/node.mjs - Runs src/node.mjs
  • With cargo-make
    • cargo make run-node - Runs src/node.mjs

Webpack

  1. npm run serve
  2. Open http://localhost:8080

Deno

  • cargo make run-deno - Runs src/deno.ts

In this guide we went over what WebAssembly is, how to compile Rust to WebAssembly for use in different targets and best practices for doing so. Make sure to check out the linked repository and CodeSandbox!

  • We've added a Nix flake as an additional setup option for the linked repository. For more details see README.md#with-nix

Grafbase - Instant GraphQL APIs for Your Data

Grafbase enables developers to ship data APIs faster with modern tooling.

At Grafbase we use WebAssembly to deploy your API to the edge: preventing cold-starts, caching your API close to your users and allowing you to write business logic in any language you choose that compiles to WebAssembly (coming soon).

You can find our public GitHub repository here.

Get Started

Build your API of the future now.