Skip to main content

Rust Tutorial: An Introduction to Rust for JavaScript Devs

By Jakob Meier

JavaScript

Share:

Free JavaScript Book!

Write powerful, clean and maintainable JavaScript.

RRP $11.95

Rust is a programming language that originated at Mozilla Research in 2010. Today, it’s used by all the big companies.

Both Amazon and Microsoft endorsed it as the best alternative to C/C++ for their systems. But Rust doesn’t stop there. Companies like Figma and Discord are now leading the way by also using Rust in their client applications.

This Rust tutorial aims to give a brief overview of Rust, how to use it in the browser, and when you should consider using it. I’ll start by comparing Rust with JavaScript, and then walk you through the steps to get Rust up and running in the browser. Finally, I’ll present a quick performance evaluation of my COVID simulator web app that uses Rust and JavaScript.

Rust in a Nutshell

Rust is conceptually very different from JavaScript. But there are also similarities to point out. Let’s have a look at both sides of the coin.

Similarities

Both languages have a modern package managing system. JavaScript has npm, Rust has Cargo. Instead of package.json, Rust has Cargo.toml for dependency management. To create a new project, use cargo init, and to run it, use cargo run. Not too alien, is it?

There are many cool features in Rust that you’ll already know from JavaScript, just with a slightly different syntax. Take this common JavaScript pattern to apply a closure to every element in an array:

let staff = [
   {name: "George", money: 0},
   {name: "Lea", money: 500000},
];
let salary = 1000;
staff.forEach( (employee) => { employee.money += salary; } );

In Rust, we would write it like this:

let salary = 1000;
staff.iter_mut().for_each( 
    |employee| { employee.money += salary; }
);

Admittedly, it takes time to get used to this syntax, with the pipe (|) replacing the parentheses.
But after overcoming the initial awkwardness, I find it clearer to read than another set of parentheses.

As another example, here’s an object destructuring in JavaScript:

let point = { x: 5, y: 10 };
let {x,y} = point;

Similarly in Rust:

let point = Point { x: 5, y: 10 };
let Point { x, y } = point;

The main difference is that in Rust we have to specify the type (Point). More generally, Rust needs to know all types at compile time. But in contrast to most other compiled languages, the compiler infers types on its own whenever possible.

To explain this a bit further, here’s code that is valid in C++ and many other languages. Every variable needs an explicit type declaration:

int a = 5;
float b = 0.5;
float c = 1.5 * a;

In JavaScript, as well as in Rust, this code is valid:

let a = 5;
let b = 0.5;
let c = 1.5 * a;

The list of shared features goes on and on:

  • Rust has the async + await syntax.
  • Arrays can be created as easily as let array = [1,2,3].
  • Code is organized in modules with explicit imports and exports.
  • String literals are encoded in Unicode, handling special characters without issues.

I could go on with the list, but I think my point is clear by now: Rust has a rich set of features that are also used in modern JavaScript.

Differences

Rust is a compiled language, meaning that there’s no runtime that executes Rust code. An application can only run after the compiler (rustc) has done its magic. The benefit of this approach is usually better performance.

Luckily, Cargo takes care of invoking the compiler for us. And with webpack, we’ll be able to also hide cargo behind npm run build. With this guide, the normal workflow of a web developer can be retained, once Rust is set up for the project.

Rust is a strongly typed language, which means all types must match at compile time. For example, you can’t call a function with parameters of the wrong type or the wrong number of parameters. The compiler will catch the error for you before you run into it at runtime. The obvious comparison is TypeScript. If you like TypeScript, then you’re likely to love Rust.

But don’t worry: if you don’t like TypeScript, Rust might still be for you. Rust has been built from the ground up in recent years, taking into account everything humanity has learned about programming-language design in the past few decades. The result is a refreshingly clean language.

Pattern matching in Rust is a pet feature of mine. Other languages have switch and case to avoid long chains like this:

if ( x == 1) { 
    // ... 
} else if ( x == 2 ) {
    // ...
}
else if ( x == 3 || x == 4 ) {
    // ...
} // ...

Rust uses the more elegant match that works like this:

match x {
    1 => { /* Do something if x == 1 */},
    2 => { /* Do something if x == 2 */},
    3 | 4 => { /* Do something if x == 3 || x == 4 */},
    5...10 => { /* Do something if x >= 5 && x <= 10 */},
    _ => { /* Catch all other cases */ }
}

I think that’s pretty neat, and I hope JavaScript developers can also appreciate this syntax extension.

Unfortunately, we also have to talk about the dark side of Rust. To say it straight, using a strict type system can feel very cumbersome at times. If you thought the type systems of C++ or Java are strict, then brace yourself for a rough journey with Rust.

Personally, I love that part about Rust. I rely on the strictness of the type system and can thus turn off a part of my brain — a part that tingles violently every time I find myself writing JavaScript. But I understand that for beginners, it can be very annoying to fight the compiler all the time. We’ll see some of that later in this Rust tutorial.

Hello Rust

Now, let’s get a hello world with Rust running in the browser. We start by making sure all the necessary tools are installed.

Tools

  1. Install Cargo + rustc using rustup. Rustup is the recommended way to install Rust. It will install the compiler (rustc) and the package manager (Cargo) for the newest stable version of Rust. It can also manage beta and nightly versions, but that won’t be necessary for this example.
    • Check the installation by typing cargo --version in a terminal. You should see something like cargo 1.48.0 (65cbdd2dc 2020-10-14).
    • Also check Rustup: rustup --version should yield rustup 1.23.0 (00924c9ba 2020-11-27).
  2. Install wasm-pack. This is to integrate the compiler with npm.
    • Check the installation by typing wasm-pack --version, which should give you something like wasm-pack 0.9.1.
  3. We also need Node and npm. We have a full article that explains the best way to install these two.

Writing the Rust code

Now that everything’s installed, let’s create the project. The final code is also available in this GitHub repository. We start with a Rust project that can be compiled into an npm package. The JavaScript code that imports that package will come afterward.

To create a Rust project called hello-world, use cargo init --lib hello-world. This creates a new directory and generates all files required for a Rust library:

├──hello-world
    ├── Cargo.toml
    ├── src
        ├── lib.rs

The Rust code will go inside lib.rs. Before that, we have to adjust Cargo.toml. It defines dependencies and other package information using TOML. For a hello world in the browser, add the following lines somewhere in your Cargo.toml (for example, at the end of the file):

[lib]
crate-type = ["cdylib"]

This tells the compiler to create a library in C-compatibility mode. We’re obviously not using C in our example. C-compatible just means not Rust-specific, which is what we need to use the library from JavaScript.

We also need two external libraries. Add them as separate lines in the dependencies section:

[dependencies]
wasm-bindgen = "0.2.68"
web-sys = {version = "0.3.45", features = ["console"]}

These are dependencies from crates.io, the default package repository that Cargo uses.

wasm-bindgen is necessary to create an entry point that we can later call from JavaScript. (You can find the full documentation here.) The value "0.2.68" specifies the version.

web-sys contains Rust bindings to all Web APIs. It will give us access to the browser console. Note that we have to select the console feature explicitly. Our final binary will only contain the Web API bindings selected like this.

Next is the actual code, inside lib.rs. The auto-generated unit test can be deleted. Just replace the content of the file with this code:

use wasm_bindgen::prelude::*;
use web_sys::console;

#[wasm_bindgen]
pub fn hello_world() {
    console::log_1("Hello world");
}

The use statements at the top are for importing items from other modules. (This is similar to import in JavaScript.)

pub fn hello_world() { ... } declares a function. The pub modifier is short for “public” and acts like export in JavaScript. The annotation #[wasm_bindgen] is specific to Rust compilation to WebAssembly (Wasm). We need it here to ensure the compiler exposes a wrapper function to JavaScript.

In the body of the function, “Hello world” is printed to the console. console::log_1() in Rust is a wrapper for a call to console.log(). (Read more here.)

Have you noticed the _1 suffix at the function call? This is because JavaScript allows a variable number of parameters, while Rust doesn’t. To get around that, wasm_bindgen generates one function for each number of parameters. Yes, that can get ugly quickly! But it works. A full list of functions that can be called on the console from within Rust is available in the web-sys documentation.

We should now have everything in place,. Try compiling it with the following command. This downloads all dependencies and compiles the project. It may take a while the first time:

cd hello-world
wasm-pack build

Huh! The Rust compiler isn’t happy with us:

error[E0308]: mismatched types
 --> src\lib.rs:6:20
  |
6 |     console::log_1("Hello world");
  |                    ^^^^^^^^^^^^^ expected struct `JsValue`, found `str`
  |
  = note: expected reference `&JsValue`
             found reference `&'static str

Note: if you see a different error (error: linking with cc failed: exit code: 1) and you’re on Linux, you lack cross-compilation dependencies. sudo apt install gcc-multilib should resolve this.

As I mentioned earlier, the compiler is strict. When it expects a reference to a JsValue as an argument to a function, it won’t accept a static string. An explicit conversion is necessary to satisfy the compiler.

    console::log_1(&"Hello world".into());

The method into() will convert one value to another. The Rust compiler is smart enough to defer which types are involved in the conversion, since the function signature leaves only one possibility. In this case, it will convert to JsValue, which is a wrapper type for a value managed by JavaScript. Then, we also have to add the & to pass it by reference rather than by value, or the compiler will complain again.

Try running wasm-pack build again. If everything goes well, the last line printed should look like this:

[INFO]: :-) Your wasm pkg is ready to publish at /home/username/intro-to-rust/hello-world/pkg.

If you managed to get this far, you’re now able to compile Rust manually. Next, we’ll integrate this with npm and webpack, which will do this for us automatically.

JavaScript Integration

For this example, I decided to place the package.json inside the hello-world directory. We could also use different directories for the Rust project and the JavaScript project. It’s a matter of taste.

Below is my package.json file. The easiest way to follow is to copy it and run npm install. Or run npm init and copy just the dev dependencies:

{
    "name": "hello-world",
    "version": "1.0.0",
    "description": "Hello world app for Rust in the browser.",
    "main": "index.js",
    "scripts": {
        "build": "webpack",
        "serve": "webpack serve"
    },
    "author": "Jakob Meier <inbox@jakobmeier.ch>",
    "license": "(MIT OR Apache-2.0)",
    "devDependencies": {
        "@wasm-tool/wasm-pack-plugin": "~1.3.1",
        "@webpack-cli/serve": "^1.1.0",
        "css-loader": "^5.0.1",
        "style-loader": "^2.0.0",
        "webpack": "~5.8.0",
        "webpack-cli": "~4.2.0",
        "webpack-dev-server": "~3.11.0"
    }
}

As you can see, we’re using webpack 5. Wasm-pack also works with older versions of webpack, or even without a bundler. But each setup works a bit differently. I’d suggest you use the exact same versions when following this Rust tutorial.

Another important dependency is wasm-pack-plugin. This is a webpack plugin specifically for loading Rust packages built with wasm-pack.

Moving on, we also need to create the webpack.config.js file to configure webpack. This is what it should look like:

const path = require('path');
const webpack = require('webpack');
const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin");

module.exports = {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'index.js',
    },
    plugins: [
        new WasmPackPlugin({
            crateDirectory: path.resolve(__dirname, ".")
        }),
    ],
    devServer: {
        contentBase: "./src",
        hot: true,
    },
    module: {
        rules: [{
            test: /\.css$/i,
            use: ["style-loader", "css-loader"],
        }, ]
    },
    experiments: {
        syncWebAssembly: true,
    },
};

All paths are configured to have the Rust code and the JavaScript code side by side. The index.js will be in the src folder next to lib.rs. Feel free to adjust these if you prefer a different setup.

You’ll also notice that we use webpack experiments, a new option introduced with webpack 5. Make sure syncWebAssembly is set to true.

At last, we have to create the JavaScript entry point, src/index.js:

import("../pkg").catch(e => console.error("Failed loading Wasm module:", e)).then(
    rust =>
        rust.hello_world()
);

We have to load the Rust module asynchronously. The call rust.hello_world() will call a generated wrapper function, which in turn calls the Rust function hello_world defined in lib.rs.

Running npm run serve should now compile everything and start a development server. We defined no HTML file, so nothing is displayed on the page. You’ll probably also have to go to http://localhost:8080/index manually, as http://localhost:8080 just lists the files without executing any code.

Once you have the blank page, open the developer console. There should be a log entry with Hello World.

Alright, that was quite a bit of work for a simple hello world. But now that all’s in place, we can easily extend the Rust code without worrying about any of this. Upon saving changes to lib.rs, you should automatically see a recompilation and a live update in the browser, just like with JavaScript!

When to Use Rust

Rust is not a general replacement for JavaScript. It can only run in the browser through Wasm and this limits its usefulness quite a bit. Even though you could replace virtually all JavaScript code with Rust if you really wanted to, that’s a bad idea and not what Wasm has been created for. For example, Rust isn’t a good fit for interacting with the UI of your website.

I think of Rust + Wasm as an additional option that can be used to run a CPU-heavy workload more efficiently. For the price of larger download sizes, Wasm avoids the parsing and compilation overhead that JavaScript code faces. This, plus strong optimizations by the compiler, potentially leads to better performance. This is usually why companies pick Rust for specific projects. Another reason to pick Rust could be language preference. But this is a completely different discussion that I won’t get into here.

Corona Infection Simulator

It’s time to create a real application that reveals the true power of Rust in the browser! Below is a live CodePen demo. If you’d rather look at it in full size, click here. A repository with the code is also available.

See the Pen
Corona Simulator Rust Demo App
by SitePoint (@SitePoint)
on CodePen.

The grid at the bottom contains cells, which are our simulation objects. A red square represents a cell infected with the virus. By clicking on individual cells, you can add or remove infections.

The button Start Simulation will simulate infection between cells, with a short delay between each simulation day. Clicking the button that says Next Day will simulate a single day.

The infection rules are simple. A healthy cell has a certain chance of getting infected for each infected neighbor inside the infection radius. After a fixed number of days of being infected, the cell recovers and is immune for the rest of the simulation. There’s also a certain chance that an infected cell dies each day between getting infected until recovery.

The form on the top right side controls the various simulation parameters. They can be updated at any time and take effect on the next day. If you change the size of the grid, the simulation will reset.

Most of what you see is implemented in JavaScript. Only when clicking on Next Day or Start Simulation will the Rust code be invoked to calculate all infections. I also implemented an equivalent interface in JavaScript, so that we can easily do a performance comparison later. The slider changing between Rust and JS changes between the two implementations. Even when running the automated simulation, this takes effect immediately (for the next day).

The simulation interface

The simulation object (implemented in Rust and JavaScript) exposes only one class with a constructor and two additional methods. I’ll give you the JavaScript notation first:

export class JsSimulation {
    constructor() { 
        /* implementation omitted */ 
    }
    reconfigure(
        w,
        h,
        radius,
        recovery,
        infection_rate,
        death_rate,
    ) { 
        /* implementation omitted */ 
    }
    next_day(input, output) { 
        /* implementation omitted */ 
    }
}

The constructor takes no arguments. It will just set all configuration options to default values. Using reconfigure(), the values can be set to the values from the HTML form. Finally, next_day() takes an input Uint8Array and an output Uint8Array.

In Rust, the class is defined as follows:

#[wasm_bindgen]
pub struct Simulation { /* fields omitted */ }

There’s no notion of classes in Rust. There’s only struct. Defining the struct Simulation as above with the #[wasm_bindgen] annotation will generate a JavaScript wrapper class that can be used as a proxy.

To define methods on the struct, we use an impl block in Rust. There can be many such blocks for a struct scattered around in different files. We only need one of them in this example:

#[wasm_bindgen]
impl Simulation {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Simulation {
        /* implementation omitted */
    }
    pub fn reconfigure(
        &mut self,
        w: usize,
        h: usize,
        radius: usize,
        recovery: u8,
        infection_rate: f32,
        death_rate: f32,
    ) {
        /* implementation omitted */
    }
    pub fn next_day(&mut self, input: &[u8], output: &mut [u8]) {
        /* implementation omitted */
    }
}

The biggest difference to JavaScript is that all types are explicitly mentioned on the function signatures. For example, w: usize means that the parameter w is of type usize, which is an unsigned integer sized to match the natural size of the target architecture (such as 64-bit). A simple u8, on the other hand, is an 8-bit unsigned integer on all platforms.

In the function next_day(), the two parameters input: &[u8], output: &mut [u8] each expect a slice of u8, which is essentially an array. Noticed the mut keyword on the output type? This tells the compiler that we’re going to mutate the array’s content, whereas from the input array we’ll only read. (The compiler will stop us if we try to mutate input.)

There’s also this strange &mut self parameter. It’s the equivalent of this in JavaScript. If the method changes the internal state of the object, it must be signified by explicitly adding &mut self in the parameter list. If you only read from the object field but do no mutations, then &self is fine, too. If no fields of the object are accessed at all, you don’t have to mention self.

For the return type of a function, Rust uses the arrow notation (->). So fn new() -> Simulation is a function that returns a Simulation object. And because I added the #[wasm_bindgen(constructor)] annotation, this function will be called when JavaScript code calls the constructor on the wrapper class, as in let sim = new Simulation().

Infection Status Representation

The input and output arrays use a single-byte unsigned integer to represent the infection status of each cell. We need to somehow encode all the relevant information about a cell inside this byte.

In JavaScript, I defined a few constants to represent the different possibilities:

const HEALTHY           =      0;
const INFECTED_DAY_0    =      1;
const INFECTED_DAY_MAX  =     64;
const IMMUNE            =    100;
const DEAD              =    101;

For the infected status, we need to count how many days a cell has already been sick to apply recovery after the defined number of days. Therefore, the constants give a minimum and a maximum value. Any value in between represents an infected cell.

In Rust we can do the equivalent with an enum like this:

enum InfectionStatus {
    Healthy,
    Infected(u8),
    Immune,
    Dead,
}

A value of type InfectionStatus can take any of the above values. Rust will assign unique numbers to the values and we, the programmers, need not worry about it. On top of that, Rust enums are more flexible than C-like enums. For a value of the variant Infected, an additional associated number of type u8 is available to represent the number of days the cell has been infected.

Rust Implementation

Let’s have a look at the Rust implementation of fn next_day(). It starts with two nested for loops to go through all cells in the grid:

for x in 0..self.w {
    for y in 0..self.h {
        /* ...more code... */
    }
}

In this example, x goes from 0 to self.w (exclusive self.w). The two dots between the two values create a range of integers that the for loop iterates over. This is how for loops are written in Rust: some variable x iterates over something iterable. In this case, something iterable is a range of numbers. Similarly, we could iterate over the values of an array (for element in slice { /* do something with element */ }).

Peeking inside the loop body, it starts with a lookup in the input array:

let current = self.get(input, x, y);

The function get is defined further down in lib.rs. It reads the corresponding u8 from the input array and converts it to an InfectionStatus according to the constants defined in JavaScript. Thus, the current variable is of type InfectionStatus and we can use Rust’s powerful pattern matching on it:

let current = self.get(input, x, y);
let mut next = current;
match current {
    Healthy => {
        if self.chance_to_catch_covid_today(input, x, y) > self.rng.gen() {
            next = Infected(1);
        }
    }
    Infected(days) => {
        if self.death_rate > self.rng.gen() {
            next = Dead;
        } else if days >= self.recovery {
            next = Immune;
        } else {
            next  = Infected(days + 1);
        }
    }
    Dead | Immune => { /* NOP */ }
}
self.set(output, x, y, next);

Okay, that’s a lot of code. Let me break it down. First, we set next to the same value as current. (By default, the output status should be the same as the input status.) Then, the match current block switches between the possibilities for the current infection status.

If the cell is healthy now, we compute the chance to get infected today in a separate function that will loop over all neighbors in the configured radius. (Details omitted here.) This function returns a value between 0.0 and 1.0, which we compare against a random number in the same range, generated on the spot. If the chance to get infected today is higher than the random number, we set next to Infected(1), meaning that this is the first day the cell is infected. (Note: in Rust we generally omit parentheses around the if condition.)

If the cell is already infected, we check two things. First, whether the cell dies today, again based on a random number generated on the spot. If the cell is unlucky, next is set to Dead. 😢 Otherwise, we check if the recovery date has been reached, in which case the new status will be Immune. Finally, if none of the two checks were positive, then the infection day counter is increased by one.

The last option in the pattern matching is that the cell is already dead or immune. In both these cases, we do nothing.

After the match expression, we call the setter function that takes an InfectionStatus value. It converts the value back to a u8 and writes it to the output array. I won’t go into the implementation details of that here, but I encourage you to have a look at the source code if you’re curious.

And that’s it! This loop is executed once for every simulation day. Nothing more.

Benchmark results

The final question is, was it even worth it to use Rust here? To answer that, I picked a couple of settings and compared the speed of the Rust implementation to the one in JavaScript. What would you expect? The answer might surprise you.

With the default settings (100 cells, infection radius = 2) the first simulation day takes 0.11ms on average with Rust and 0.72ms with JavaScript. So Rust is more than six times faster! But as I increased the size and continued the simulation for several simulation days, JavaScript was suddenly doing the same work in half the time that Rust takes.

Below are charts from experiments with more cells and an altered radius, which increases the total workload. I performed this test on my desktop PC.

Chart showing simulation time for first 10 days on desktop

The chart shows that Rust is significantly faster than JavaScript on the first simulation day, no matter the setting. This is when the JavaScript code has to be parsed, compiled, and optimized. Afterward, the JavaScript code keeps getting faster day by day, as the just-in-time (JIT) compiler optimizes the branches. The Rust version keeps a comparatively stable execution time across all days.

In this example, the JIT compiler does a great job. The whole workload is one huge loop doing the same computation over and over again with only slightly changing values. Optimizing this at runtime yields better results even than the compile-time optimizations of Rust.

To investigate a bit further, I performed the same tests on my Samsung Galaxy S7. My guess was that the JIT compiler built into mobile browsers would be less aggressive, giving JavaScript code a harder time to execute efficiently.

Chart showing simulation time for first 10 days on mobile

Indeed, the results on my phone were much more in favor of Rust on my mobile phone! On the first simulation day, with 3000 nodes, the Rust version was 36.9 times faster (1.45ms vs 53.5ms)! Starting from day four, both the 3000 and the 10000 nodes experiment reached a relatively stable performance for JavaScript. But even there, Rust was faster by a factor between 2.5 and 3. (Around 28ms vs 79ms for 10k nodes.)

While the COVID Simulation App is a non-representative sample of all Wasm apps, it already shows that the choice between a Wasm and a JavaScript implementation depends on many factors. Wasm can be much faster in the right circumstance and it is generally more consistent than its JavaScript counterpart. But without thorough benchmarking, it’s hard to predict which of the two will perform better at the end user’s device.

Doing the necessary benchmarks can actually be quite simple — assuming you’ve written a quick Rust prototype that you want to compare to your existing JavaScript implementation. You would want measure how long the Rust code takes to execute, including the call-overhead from JavaScript. This can be measured very easily, using just JavaScript:

const t0 = performance.now();
wasm.expensiveCall();
const t1 = performance.now();
console.log(`Rust code executed in ${t1 - t0}ms`);

For this Rust tutorial I did exactly that. The version deployed in the CodePen demo also linked earlier still contains the benchmarking code. You can just open the developer console to read the times you get on your device.

Conclusion and Further Resources

In this Rust tutorial, I showed the steps to integrate Rust in a JavaScript project. Wasm support for Rust has now reached decent maturity, so it’s ready to start using it for your work. The hello world template from this tutorial should be a good start for this. Alternatively, there’s the official wasm-bindgen guide, with much more details and options.

With the COVID Simulation App, I demonstrated how to create a complete app using Rust in the browser. To quantify the performance of Rust, I implemented the full app also in JavaScript and did some benchmarks. The performance evaluation has been slightly in favor of JavaScript on a desktop but clearly in favor of Rust on mobile. The main takeaway is that only benchmarking can tell for sure which language is faster for your app.

I also talked a bit about Rust in general in this tutorial. Rust is a complex language to learn, so I intentionally avoided going into details for this introduction. If you want to really learn Rust, I can highly recommend the Rust book and the Rust by example guide as your primary sources.

Thanks for reading to the end! If you liked this Rust tutorial, you might also like some of the content I put up on my private blog. Be my guest and check out Rust meets the web – a clash of programming paradigms for a more critical perspective on Rust in the browser.

Are you ready to jump on the Rust train while it’s still accelerating?

Jakob is a software engineer with a passion for programming. He holds a degree in Computer Science with specialization in distributed systems. If you catch him at a party, you will probably find him debating about programming languages with his peers.

New books out now!

Get practical advice to start your career in programming!


Master complex transitions, transformations and animations in CSS!