Can Rust Beat JavaScript in 2023?

·

9 min read

If you've been working with Rust for web development for a while, you'll more than likely know that stacking Rust frontend web development (via WASM) against JavaScript is a hotly debated topic - namely because there are a large number of people who perceive it to be "not production-ready" or "slower than JavaScript".

This may have been true in the past: it has historically been true that there is some overhead for JavaScript to make WASM calls because of WASM not being able to reach the DOM. However, currently this is of very little consequence; benchmark numbers show that Rust WASM frameworks like Leptos, and Dioxus (which uses Sledgehammer under the hood, a Javascript framework that is the 3rd fastest framework!) are ahead of most JavaScript frameworks like React and Vue. You can find the original benchmark testing here:

Frontend framework benchmarks

As you can see in the picture (though blurry) - the order follows vanilla Javascript, Sledgehammer (what Dioxus runs under the hood!), wasm-bindgen (the library that allows inter-op between WASM modules and Javascript), Solid.js, Vue with RxJS and then Leptos, Dioxus, LitJS and then Sycamore... and then Vue and React (and Yew). Clearly, there's something to be said here about the performance of how well Rust frontend frameworks perform over even some of the most popular JavaScript frameworks. You could of course, just write only pure JavaScript with no frameworks - however, whether that's something that can be advised is a different matter entirely.

Let's look at the backend benchmarks from TechEmpower:

TechEmpower benchmarks

With 5 out of the top 10 top backend frameworks being written in Rust, it's clear that the power of Rust as a backend framework is self-explanatory, easily competing with C++. Some might say Rust is overkill for a backend service; however, where you get more performance, you can also save money by not requiring as much of a memory footprint as well as more service uptime with less crashes. This is a factor that shouldn't be underestimated as saving costs where possible from the perspective of a business is never a bad idea.

Despite the results though, it should of course be mentioned that speed and general performance are not the only factors that affect what our overall decision is when we're choosing a new framework. What about the developer experience? Error handling? How can we tackle things like SSR? These are all important questions that need answering if we want to make a final verdict on what we want to use and thankfully, Rust has answers.

Developer Experience

Despite what many people think, Rust nowadays is relatively forgiving to get into when it comes to web development. A lot of the code is somewhat similar to the JavaScript component style of web frameworks like React - let's have a quick dive into some component code from Leptos, a Rust web framework:

use leptos::*;

#[component]
pub fn SimpleCounter(cx: Scope, initial_value: i32) -> impl IntoView {
    // create a reactive signal with the initial value
    let (value, set_value) = create_signal(cx, initial_value);

    // create event handlers for our buttons
    // note that `value` and `set_value` are `Copy`, so it's super easy to move them into closures (for reference: closures are like anonymous/arrow functions in Javascript)
    let clear = move |_| set_value(0);
    let decrement = move |_| set_value.update(|value| *value -= 1);
    let increment = move |_| set_value.update(|value| *value += 1);

    // create user interfaces with the declarative `view!` macro
    view! {
        cx,
        <div>
            <button on:click=clear>"Clear"</button>
            <button on:click=decrement>"-1"</button>
            <span>"Value: " {value} "!"</span>
            <button on:click=increment>"+1"</button>
        </div>
    }
}

// Easy to use with Trunk (trunkrs.dev) or with a simple wasm-bindgen setup
pub fn main() {
    mount_to_body(|cx| view! { cx,  <SimpleCounter initial_value=3 /> })
}

As you can see, the code is really not that far off from something like JSX - the main difference being that instead of returning anything, the component uses a Rust macro to render the HTML and the main function acts like the index.js script for a root file like you would in React, Vue or any other web framework that you can use to write websites in with JavaScript. Another example, from Dioxus:

// An example of a navbar
fn navbar(cx: Scope) -> Element {
    cx.render(rsx! {
        ul {
            // NEW
            Link { to: "/", "Home"}
            br {}
            Link { to: "/blog", "Blog"}
        }
    })
}

// An example of using URL parameters
fn get_blog_post(id: &str) -> String {
    match id {
        "foo" => "Welcome to the foo blog post!".to_string(),
        "bar" => "This is the bar blog post!".to_string(),
        id => format!("Blog post '{id}' does not exist!")
    }
}

As you can see - it's pretty simple to write RSX (Dioxus' answer to React's JSX) and perhaps even easier than using Leptos. What you may find quite evident is that the React component philosophy transcends any one particular programming language and in fact finds a home quite comfortably in Rust. You could even combine these functions with unit structs to get namespaces for your various functions so you could bundle things like API calls, for example:

// this is pseudocode and is just to showcase bundling functions together in a unit struct, which is a struct that doesn't hold any data
pub struct APICalls;

// we make an implementation of our struct and insert the functions we want to use in it - then we can just import the struct and it'll import all of the associated methods.
Impl APICalls {
         pub async fn get_dog_api_data() -> Json<Dog> {
... some code here
// this should probably return some json data
}
         pub async fn get_cat_api_data() -> Json<Cat> {
... some code here
// this should probably return some json data
}
}

fn navbar (cx: Scope) -> Element {
// now we can call the data like this, or something similar
    let dogs = APICalls::get_dog_api_data().await;
}

As you can see, we are pretty much barely scratching the surface of Rust in terms of Rust's unique mechanics and we can still make some relatively decent headway. However, what truly shines through here is Rust's error handling, which is a great advantage over JavaScript or even TypeScript. Normally if you're coding in Typescript you have two options: type checking, and try-catch blocks. However for anyone who's coded for a while, wrapping your code constantly in just try-catch blocks is not really all that ideal and TypeScript still compiles to JavaScript, so you still have to deal with JavaScript-related problems if you're not careful (CJS vs ECMAscript, random errors in runtime that take forever to fix because you don't actually know what's happening, etc...).

Let's have a look at some basic Rust handling:

async fn foo() -> Result<String, String>{
let bar = String::from("foobar!");
// due to Rust being an expressive language, return is not explicitly required here - we can just write the variable
match bar.trim() {
    "foobar!" => Ok(bar),
      _ => Err("Was not foobar!".to_string())
             }
   }

#[tokio::main]
fn main() -> Result<String, String> {
let Ok(res) = foo().await else {
   return Err("Was not foobar :(".to_string());
}

println!("The string was: {res}!");
}

As you can see, this example showcases two examples: we can either use basic pattern matching to determine what the string is and return an OK if the result matches or if it is anything else (hence the underscore), we can just return an error that has a String type (that also implements std::error::Error - hence we can use it as an error type). We can also declare a variable by writing (essentially) that the declared variable must be an actual Result type, otherwise do something else (here we just use it to return early). We can then use res itself as it will be declared as the value contained in the Result.

Ecosystem

While the ecosystem in Javascript (via Node/npm) is much larger than Rust, this doesn't mean that Rust is incapable of suiting the needs of most projects. Rust currently has great support for databases, Redis and many more services that you'd want to include in any competent web app, regardless of what language you're trying to write it in.

If you're trying to build a SaaS, Rust has a crate for pretty much everything you could need: lettre for SMTP, async-stripe for Stripe payments, oauth2 for working with OAuth callbacks for social network account logins, SQLx (and Diesel or SeaORM if you prefer ORMs) for databases (or even airtable!) and of course openai_api for working with GPT-3. Rust even has support for RabbitMQ using lapin as well as rs-rdkafka for Kafka support once you get your SaaS off the ground. From this standpoint, it's pretty clear that Rust can contend with Javascript if you're trying to make a rock-solid, performant service.

In my own experience, I've found cargo to be much easier to use when trying to get around and find good crates, and you can install things like clippy which is a brilliant utility that doesn't need to be initialised to be used - you can simply use cargo clippy and it'll run. and it can help you by detecting things like unnecessary borrows and letting you know when you can optimise your code. What's more, if you need a specific configuration from project to project you can also simply just create a clippy.toml file at the root of your directory and it'll let you configure it as you like!

Due to Rust not being the defacto web programming language, it doesn't typically receive first-class crate support for things like working with service APIs - however, as most service APIs come in the form of HTTP REST web services with endpoints that you can hit, you could also use a crate like reqwest to be able to retrieve the data you need.

Deployment

When it comes to deployment, Shuttle is by far the easiest way to get started on deployment when it comes to using Rust. When it comes to backend deployment it's often the case that you might need to mess around with config files or add your environment variables via a GUI on the website for the service you're trying to use, or you may struggle with trying to serve static files.

Shuttle on the other hand uses an infrastructure-from-code philosophy with code annotations that allow you to get going quickly and without having to mess around with config files by simply declaring them in your main function via Rust macros. We can use them to provision a database and get static file support for adding a compiled frontend from a JavaScript framework like Next.js, React or any other framework that can compile to static assets like so:

// main.rs
#[shuttle_runtime::main]
pub async fn axum (
#[shuttle_shared_db::Postgres] postgres: PgPool,
#[shuttle_secrets::Secrets] secrets: SecretStore,
#[shuttle_static_folder] static: PathBuf
) -> shuttle_axum::ShuttleAxum {
// carry out database migrations (this assumes migrations are idempotent)
    sqlx::migrate!().run(&postgres).await.expect("Migrations failed :(");

    let hello_world = secrets.get("MY_VARIABLE").expect("Couldn't get MY_VARIABLE, is it set in Secrets.toml?");

// Make a router serving API routes that require a DB connection
    let api_router = create_api_router(postgres);

// Add a compiled frontend (like e.g. from Next.js, React, Vue etc) to the router
    let router = Router::new()
        .nest("/api", api_router)
        .nest_service("/",                   get_service(ServeDir::new(static).handle_error(handle_error));

// return the router (Rust returns implicitly so writing "return" is not required)
Ok(router.into())
}

Finishing Up

Thank you for reading! With all of this said it's clear to see that Rust is a great language worthy of usage for web development that offers more performance at a lower memory footprint, saving time and money by offering better service uptime and being able to get more for less.