A JavaScript developer's honest journey into Rust. Ownership, borrowing, lifetimes explained through the lens of someone who thinks in closures and callbacks. What clicked, what didn't, and why Rust made me a better JS developer.
I have been writing JavaScript professionally for years. I think in closures. I dream in Promise.all. My muscle memory types const before my brain even processes what variable I need. JavaScript is the water I swim in.
And then I tried to learn Rust, and the water turned into concrete.
Not because Rust is bad. Not because it is unnecessarily complex. But because Rust forced me to confront assumptions about programming that I did not even know I had. Assumptions about memory. About who owns data. About what it means for a value to "exist." JavaScript lets you ignore all of these questions. Rust makes them the entire point.
This post is an honest retelling of that journey. What broke my brain, what eventually clicked, and why — against all expectations — learning Rust made me significantly better at writing JavaScript.
Let me be upfront about motivation, because "learn Rust" is not something most JS developers wake up wanting to do.
My reasons were practical. I had a computationally intensive process running in Node.js that was too slow. I needed to ship a CLI tool that could not require users to install Node. I was curious about WebAssembly and wanted to write something that compiled to it without fighting C++ toolchains.
Rust kept showing up as the answer to all three problems. And unlike C or C++, Rust promised that I would not spend my weekends debugging segmentation faults and memory corruption. The compiler would catch those bugs before I could even run the code.
That promise turned out to be true. But the cost of that promise — the mental overhead of satisfying the Rust compiler — is something nobody adequately prepares you for.
In JavaScript, you never think about who "owns" a value. You create an object, pass it to a function, store it in three different arrays, close over it in a callback, and the garbage collector sorts everything out. You do not manage memory. You do not think about memory. Memory is someone else's problem.
// JavaScript: nobody worries about this
function processUser(user) {
const name = user.name;
logUser(user); // user still works here
saveToDatabase(user); // and here
sendEmail(user); // and here
return user; // and we can return it too
}Every function gets a reference to the same object in the heap. The garbage collector tracks how many references exist. When the count hits zero, the memory gets freed. Simple. Invisible. Automatic.
Now look at the Rust version of a similar pattern:
fn process_user(user: User) {
let name = user.name; // `name` field MOVED out of `user`
log_user(user); // ERROR: `user` is partially moved
}Wait, what? I just read user.name and now user is broken?
Yes. In Rust, assigning a String (or any non-Copy type) to a new variable does not copy it. It moves it. The original variable no longer owns that data. Trying to use it after the move is a compile-time error.
This was the first moment where I realized Rust and JavaScript have fundamentally different mental models. In JavaScript, assignment creates a new reference to the same data. In Rust, assignment transfers ownership of the data. The old variable becomes invalid.
let greeting = String::from("hello");
let other = greeting; // `greeting` is MOVED to `other`
println!("{}", greeting); // ERROR: value used after moveComing from JavaScript, this feels like the language is broken. You assigned a variable and now the original does not work? But the more I sat with it, the more I realized Rust was making explicit something JavaScript hides from you: data has to live somewhere, and someone has to be responsible for cleaning it up.
In JavaScript, the garbage collector is that someone. In Rust, the ownership system is. And ownership has one iron rule: every piece of data has exactly one owner, and when that owner goes out of scope, the data is dropped (freed).
{
let s = String::from("hello"); // `s` owns the string
// use s...
} // `s` goes out of scope, string memory is freed. Immediately. Deterministically.No garbage collector running at unpredictable intervals. No GC pauses in the middle of your hot loop. No memory slowly climbing because the GC has not gotten around to cleaning up yet. When the scope ends, the memory is gone. Period.
Once you accept that values have owners, the next question is obvious: how do you let multiple parts of your code look at the same data without transferring ownership every time?
The answer is borrowing, and it comes in two flavors:
Immutable borrows (&T): multiple readers, no writers.
Mutable borrows (&mut T): exactly one writer, no readers.
fn print_length(s: &String) { // borrows `s`, does not own it
println!("Length: {}", s.len());
}
fn main() {
let greeting = String::from("hello");
print_length(&greeting); // lend `greeting` to the function
println!("{}", greeting); // still works! we only lent it
}The & symbol means "I am borrowing this, not taking ownership." The function can read the data, but when it returns, the original owner still has it.
Think of it like lending a book to a friend. They can read it, but you still own it, and you get it back when they are done.
The mutable borrow is stricter. Only one mutable borrow can exist at a time, and while it exists, no immutable borrows are allowed either:
let mut data = vec![1, 2, 3];
let r1 = &data; // immutable borrow - OK
let r2 = &data; // another immutable borrow - OK
let r3 = &mut data; // MUTABLE borrow while immutable borrows exist - ERRORThis feels oppressive at first. But then you realize what it prevents: data races at compile time. In JavaScript, you can have one part of your code mutating an array while another part is iterating over it, and you will find out about the bug at 3 AM in production. In Rust, the compiler simply does not let you write that code.
The JavaScript equivalent of this problem is painfully common:
// JavaScript: a subtle bug
const items = [1, 2, 3, 4, 5];
items.forEach((item, index) => {
if (item % 2 === 0) {
items.splice(index, 1); // mutating while iterating. good luck.
}
});
// Result: [1, 3, 5]? Nope. [1, 3, 4] — the iteration skips elements.Rust would refuse to compile the equivalent code. You cannot hold an iterator (immutable borrow) and mutate the collection (mutable borrow) simultaneously. The borrow checker catches it. I cannot tell you how many hours this single rule would have saved me over my JavaScript career.
About two weeks into learning Rust, something clicked that changed how I think about code in every language.
In JavaScript, function signatures tell you almost nothing:
function processOrder(order, user, options) {
// what is `order`? An object? What shape?
// can `user` be null? Who knows.
// what keys does `options` have? Check the implementation.
// what does this return? Could be anything.
// can it throw? Always assume yes.
}Even with TypeScript, the situation only improves so much. Types are optional and can be cast away with as any. The compiler trusts you when you say something is a certain type, even if it is not.
In Rust, the function signature IS the contract, and the compiler enforces it with zero exceptions:
fn process_order(
order: &Order,
user: &User,
options: ProcessOptions,
) -> Result<Receipt, OrderError> {
// ...
}From this signature alone, I know:
order and user immutably (it will not modify them).options (the caller cannot use it after this call).Receipt on success or an OrderError on failure.OrderError.Receipt is a real value, guaranteed.That is an extraordinary amount of information in four lines. And none of it requires reading the function body or hoping the documentation is up to date. The compiler guarantees all of it.
This changed how I write TypeScript. I started being much more precise with my types. I stopped using any. I started modeling error states explicitly instead of relying on thrown exceptions. Rust's influence, even in a completely different language.
JavaScript has two mechanisms for "something might not be there" and "something might go wrong":
null/undefined for missing valuesthrow/try/catch for errorsBoth are invisible in function signatures. You cannot look at a JavaScript function and know whether it might return null or throw an error. You have to read the implementation, read the docs, or just wrap everything in try/catch and hope for the best.
Rust replaces both with algebraic types that are part of the function signature.
// Rust: explicit about the possibility of absence
fn find_user(id: u64) -> Option<User> {
// Returns Some(user) if found, None if not
}
// You MUST handle both cases to use the result
match find_user(42) {
Some(user) => println!("Found: {}", user.name),
None => println!("User not found"),
}Compare with JavaScript:
// JavaScript: null sneaks up on you
function findUser(id) {
// returns user or undefined or null, who knows
// hope you remember to check
}
const user = findUser(42);
console.log(user.name); // TypeError: Cannot read property 'name' of null
// at 3 AM, in production, on a SaturdayThe Option type forces you to acknowledge the possibility that a value might not exist. You literally cannot access the inner value without first checking whether it is Some or None. The compiler will not let you. No more "Cannot read property of null" — the entire category of bug is eliminated.
This one might be the single most impactful concept I brought back to JavaScript.
use std::fs;
fn read_config(path: &str) -> Result<Config, ConfigError> {
let content = fs::read_to_string(path)?; // the ? propagates errors
let config: Config = parse_toml(&content)?;
Ok(config)
}The ? operator is gorgeous. If the operation returns Err, the function immediately returns that error to its caller. If it returns Ok, the inner value is unwrapped and execution continues. It is like try/catch but explicit, composable, and visible in the type signature.
In JavaScript, error handling is a mess. Thrown exceptions are invisible. You do not know which functions can throw. You do not know what types of errors they throw. And the control flow is non-local — a throw deep in a call stack will unwind everything until it hits a catch, or it will crash the process.
// JavaScript: error handling is an afterthought
async function loadDashboard(userId) {
try {
const user = await fetchUser(userId); // can throw
const prefs = await fetchPreferences(user); // can throw
const data = await fetchDashboardData(prefs); // can throw
return renderDashboard(data); // can also throw!
} catch (error) {
// what kind of error? Network? Auth? Parse? 404?
// error.message is a string. good luck switching on that.
console.error("Something went wrong:", error);
}
}The Rust equivalent makes every failure point explicit:
async fn load_dashboard(user_id: u64) -> Result<Dashboard, DashboardError> {
let user = fetch_user(user_id).await?;
let prefs = fetch_preferences(&user).await?;
let data = fetch_dashboard_data(&prefs).await?;
let dashboard = render_dashboard(&data)?;
Ok(dashboard)
}
enum DashboardError {
UserNotFound,
AuthFailed,
NetworkError(String),
ParseError(String),
}Every function call that can fail has a ?. Every possible error type is enumerated. The caller knows exactly what can go wrong and can handle each case specifically. No more catching a generic Error object and praying.
After learning this pattern in Rust, I started using a similar approach in TypeScript:
// TypeScript: Result-inspired pattern I now use everywhere
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function findUser(id: string): Result<User, "not_found" | "db_error"> {
// ...
}
const result = findUser("abc");
if (!result.ok) {
// TypeScript KNOWS result.error is "not_found" | "db_error" here
switch (result.error) {
case "not_found": return notFoundPage();
case "db_error": return retryLater();
}
}
// TypeScript KNOWS result.value is User hereThis is one of the most concrete ways Rust made me a better JavaScript developer. Error handling should be explicit, typed, and visible at the call site. Exceptions should be truly exceptional, not the primary mechanism for control flow.
JavaScript has switch. It works. It is also one of the most error-prone constructs in the language because of fall-through behavior and the lack of exhaustiveness checking.
Rust has match, and it is in a completely different league:
enum Shape {
Circle(f64),
Rectangle(f64, f64),
Triangle(f64, f64, f64),
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle(radius) => std::f64::consts::PI * radius * radius,
Shape::Rectangle(w, h) => w * h,
Shape::Triangle(a, b, c) => {
let s = (a + b + c) / 2.0;
(s * (s - a) * (s - b) * (s - c)).sqrt()
}
}
}Three things make this vastly superior to JavaScript's switch:
Exhaustiveness: if you add a new variant to Shape (say, Pentagon), the compiler will error on every match statement that does not handle it. You cannot forget to update a handler. In JavaScript, your switch just silently falls through to the default case (or has no default and does nothing).
Destructuring: the match arm binds the inner values directly. Shape::Circle(radius) extracts the radius. No need to access properties on an object.
No fall-through: each arm is independent. No break needed. No accidental fall-through bugs.
The JavaScript equivalent is awkward:
function area(shape) {
switch (shape.type) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle": {
const { a, b, c } = shape.sides;
const s = (a + b + c) / 2;
return Math.sqrt(s * (s - a) * (s - b) * (s - c));
}
// forgot "pentagon"? No error. No warning. Silent bug.
}
}Rust's match works on any type and supports deeply nested patterns, guards, and binding:
match response {
Ok(data) if data.len() > 0 => process(data),
Ok(_) => handle_empty(),
Err(Error::NotFound) => return default_value(),
Err(Error::Timeout) => retry(),
Err(e) => return Err(e),
}Try writing that in JavaScript without a chain of if/else. I dare you.
If you come from TypeScript, you are used to interfaces:
interface Printable {
print(): string;
}
class User implements Printable {
print() {
return `User: ${this.name}`;
}
}Rust has traits, which look similar on the surface but are fundamentally more powerful:
trait Printable {
fn print(&self) -> String;
}
struct User {
name: String,
}
impl Printable for User {
fn print(&self) -> String {
format!("User: {}", self.name)
}
}The key difference: in Rust, you can implement traits for types you did not define. You can add behavior to someone else's type without modifying their code:
// I can implement my trait for standard library types
impl Printable for Vec<i32> {
fn print(&self) -> String {
format!("Vector with {} elements", self.len())
}
}Try doing that in TypeScript. You would have to use module augmentation, prototype modification, or wrapper classes. All of which are hacky and fragile. Rust makes it a first-class, type-safe operation.
Traits also enable something TypeScript cannot do: static dispatch with generics. When you write a function that accepts a trait bound, Rust generates specialized machine code for each concrete type at compile time:
fn log_item<T: Printable>(item: &T) {
println!("{}", item.print());
}
// When called with User, the compiler generates a specialized version
// for User. When called with Vec<i32>, another specialized version.
// No vtable, no dynamic dispatch, no runtime cost.This is called monomorphization, and it means generic code in Rust runs at the same speed as if you had written separate functions for each type. JavaScript generics (even in TypeScript) are purely a compile-time fiction — at runtime, everything is dynamic dispatch through property lookups.
Closures are where JavaScript developers feel most at home in Rust, because both languages make heavy use of them. But Rust closures have a twist: they interact with the ownership system.
JavaScript closures capture variables by reference, always:
function makeCounter() {
let count = 0;
return () => {
count += 1;
return count;
};
}
const counter = makeCounter();
counter(); // 1
counter(); // 2Rust closures can capture in three ways:
// Capture by immutable reference (&T)
let name = String::from("Alice");
let greet = || println!("Hello, {}", name); // borrows `name`
greet();
println!("{}", name); // still valid, was only borrowed
// Capture by mutable reference (&mut T)
let mut count = 0;
let mut increment = || { count += 1; };
increment();
increment();
// count is now 2
// Capture by value (move)
let name = String::from("Alice");
let greet = move || println!("Hello, {}", name); // takes ownership
greet();
// println!("{}", name); // ERROR: name was moved into the closureThe move keyword is crucial when you need a closure to outlive the scope where it was created — which is exactly the pattern we use constantly in JavaScript for callbacks:
fn spawn_greeter(name: String) -> impl Fn() {
move || println!("Hello, {}", name)
// `name` is moved into the closure, so the closure owns it
// and can be returned safely
}Without move, the closure would hold a reference to name, but name would be dropped when spawn_greeter returns. The compiler catches this and refuses to compile. In JavaScript, the garbage collector keeps name alive as long as the closure exists. In Rust, you have to be explicit about it.
This is one of those cases where Rust's explicitness is initially annoying but eventually illuminating. It forces you to think about the lifecycle of your data, and that thinking prevents real bugs.
If there is one area where a JavaScript developer will feel immediately comfortable in Rust, it is iterators. Rust's iterator API is strikingly similar to JavaScript's array methods, with one massive advantage: zero-cost abstraction.
// JavaScript
const result = users
.filter(u => u.active)
.map(u => u.name.toUpperCase())
.find(name => name.startsWith("A"));// Rust
let result = users.iter()
.filter(|u| u.active)
.map(|u| u.name.to_uppercase())
.find(|name| name.starts_with("A"));The syntax is almost identical. The semantics are almost identical. But under the hood, they could not be more different.
In JavaScript, each method (.filter(), .map()) creates a new intermediate array. If you have a million users, .filter() creates a new array of (say) 500,000 elements, then .map() creates another array of 500,000 strings, then .find() iterates through that array and throws the whole thing away. Two unnecessary heap allocations, millions of unnecessary copies.
In Rust, the iterator chain is lazy. Nothing happens until .find() requests the first element. Then the chain processes one element at a time, all the way through. No intermediate collections. No heap allocations. The compiler can even inline the entire chain into a single loop that is as fast as hand-written C.
// This iterator chain...
let result = (0..1_000_000)
.filter(|x| x % 2 == 0)
.map(|x| x * x)
.take(10)
.collect::<Vec<_>>();
// ...compiles to roughly the same machine code as:
let mut result = Vec::with_capacity(10);
let mut count = 0;
let mut x = 0;
while count < 10 {
if x % 2 == 0 {
result.push(x * x);
count += 1;
}
x += 1;
}This is what "zero-cost abstraction" means. You write high-level, expressive code, and the compiler generates low-level, optimal code. JavaScript cannot do this because the runtime does not have enough information to perform these optimizations.
Rust also has a parallel iterator library called rayon that lets you turn any iterator into a parallel one by changing a single method call:
use rayon::prelude::*;
// Sequential
let sum: i64 = data.iter().map(|x| expensive_computation(x)).sum();
// Parallel — just change iter() to par_iter()
let sum: i64 = data.par_iter().map(|x| expensive_computation(x)).sum();Try doing that in JavaScript. You would need to set up Web Workers, serialize data, manage message passing, and merge results. In Rust, it is one word change.
I have been mostly positive about Rust so far. Time for some honesty: async Rust is hard. Significantly harder than async JavaScript.
In JavaScript, the async story is relatively simple. There is one runtime (the event loop). There is one way to do async (Promise). There is convenient syntax (async/await). The runtime is built into the language.
// JavaScript: async is straightforward
async function fetchData(url) {
const response = await fetch(url);
const data = await response.json();
return data;
}In Rust, async is a language feature but the runtime is not included. You have to choose one. The two main options are tokio and async-std, and they are not interchangeable. Libraries built for one may not work with the other.
// Rust: need to pick a runtime and add it as a dependency
#[tokio::main]
async fn main() {
let data = fetch_data("https://api.example.com/data").await;
}That is manageable. But here is where it gets genuinely painful: async functions return Future types, and those types interact with the borrow checker in ways that produce some of the most confusing error messages you will ever see.
// This innocent-looking code might not compile
async fn process(data: &str) -> String {
let processed = format!("processed: {}", data);
tokio::time::sleep(Duration::from_secs(1)).await;
processed
}The moment you hold a reference across an .await point, you enter a world of lifetime constraints that can take hours to untangle. The Rust compiler is trying to ensure that the reference is still valid when the async function resumes, and proving that statically is genuinely hard.
I will not sugarcoat this: I have spent entire afternoons fighting the borrow checker in async code. The error messages have improved dramatically over the years, but there are still situations where the solution is not obvious even to experienced Rust developers.
My honest advice: if your Rust project is primarily async (say, a web server), be prepared for a steeper learning curve than if you are writing a CLI tool or data processing pipeline. The async ecosystem is mature and powerful, but it demands more from you than JavaScript's event loop ever will.
Lifetimes are the feature that makes most JavaScript developers go "nope" and close the Rust book. They look scary:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}What are those 'a annotations? Why are they there? What are they for?
Here is the thing that took me weeks to understand: lifetimes are not a new concept. They are an annotation of something that already exists.
Every reference in every program has a lifetime — the period during which the reference is valid. In JavaScript (and most garbage-collected languages), you never think about this because the garbage collector ensures references are always valid. The GC keeps data alive as long as any reference to it exists.
In Rust, there is no garbage collector. So the compiler needs to verify, at compile time, that every reference is used only while the data it points to is still alive. Most of the time, the compiler can figure this out on its own. But sometimes — specifically, when a function takes references as input and returns a reference as output — the compiler needs you to tell it how the lifetimes relate.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}This says: "The returned reference will be valid for at least as long as BOTH input references are valid." The compiler uses this information to ensure the caller does not use the returned reference after either input has been freed.
fn main() {
let result;
let string1 = String::from("long string");
{
let string2 = String::from("xyz");
result = longest(&string1, &string2);
println!("{}", result); // OK: both string1 and string2 are alive
}
// println!("{}", result); // ERROR: string2 is dropped, result might
// reference it
}The compiler prevents you from using result outside the inner scope because string2 might have been the longer string, and string2 is dropped at the end of that scope. This is a bug that JavaScript developers have never had to worry about — and that C developers discover in production as a use-after-free vulnerability.
The good news: you rarely need to write lifetime annotations in practice. Rust has lifetime elision rules that handle the vast majority of cases automatically. I would estimate that 90% of my Rust code has zero explicit lifetime annotations. They only show up in certain patterns, usually involving structs that hold references:
// A struct that borrows data instead of owning it
struct Excerpt<'a> {
text: &'a str,
}
// The lifetime annotation says: this struct cannot outlive
// the string it referencesMy advice for JavaScript developers encountering lifetimes: do not try to understand them abstractly. Write code. Hit lifetime errors. Read the error messages (they are excellent). Fix the code. After about two dozen lifetime errors, the pattern clicks and you stop thinking about them consciously.
After years of node_modules bloat, package-lock.json merge conflicts, and "works on my machine" dependency issues, using Cargo felt like stepping into the future.
# Create a new project
cargo new my-project
# Add a dependency
cargo add serde --features derive
# Build
cargo build
# Run
cargo run
# Test
cargo test
# Format
cargo fmt
# Lint
cargo clippy
# Generate docs
cargo doc --openThat is one tool doing the job of npm + eslint + prettier + jest + typedoc combined.
But the real difference is not the commands — it is the reliability. Cargo uses a proper dependency resolver with semantic versioning that actually works. Cargo.lock is deterministic and merge-conflict-friendly. Builds are reproducible. Cross-compilation is a flag, not a weekend project.
The dependency situation is also healthier. The JavaScript ecosystem has a notorious problem with micro-dependencies — packages that do one trivial thing and pull in a dozen transitive dependencies. Rust's standard library is more comprehensive, so you need fewer external crates for basic operations. And the crate ecosystem tends toward fewer, larger, well-maintained libraries rather than thousands of tiny ones.
That said, compilation speed is Cargo's Achilles heel. A fresh build of a medium-sized Rust project can take minutes. JavaScript developers are used to sub-second feedback loops. Rust developers learn to love incremental compilation (rebuilds after small changes are much faster) and to structure their code to minimize recompilation. But the first build of a dependency-heavy project will test your patience.
# First build: go get coffee
$ time cargo build
Compiling libc v0.2.153
Compiling serde v1.0.197
... 47 more crates ...
Compiling my-project v0.1.0
Finished dev [unoptimized + debuginfo] in 42.7s
# Incremental rebuild after changing one file: acceptable
$ time cargo build
Compiling my-project v0.1.0
Finished dev [unoptimized + debuginfo] in 1.3sTypeScript enums are essentially named constants:
enum Direction {
Up,
Down,
Left,
Right,
}Rust enums are algebraic data types — each variant can carry different data:
enum WebEvent {
PageLoad,
PageUnload,
KeyPress(char),
Paste(String),
Click { x: i64, y: i64 },
}
fn handle_event(event: WebEvent) {
match event {
WebEvent::PageLoad => println!("page loaded"),
WebEvent::KeyPress(c) => println!("pressed '{}'", c),
WebEvent::Click { x, y } => println!("clicked at ({}, {})", x, y),
WebEvent::Paste(text) => println!("pasted: {}", text),
WebEvent::PageUnload => println!("page unloaded"),
}
}This is how Option and Result are implemented — they are just enums:
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}In JavaScript, you would model this with objects and a type discriminator:
const event = { type: "click", x: 100, y: 200 };
const event2 = { type: "keypress", key: "a" };But there is no compiler enforcement that you handle all variants. There is no guarantee that a click event actually has x and y properties. Rust enums give you both, at zero runtime cost.
Rust does not have classes. It has structs (data) and impl blocks (methods), and they are separate:
struct Rectangle {
width: f64,
height: f64,
}
impl Rectangle {
// Associated function (like a static method)
fn new(width: f64, height: f64) -> Self {
Rectangle { width, height }
}
// Method (takes &self)
fn area(&self) -> f64 {
self.width * self.height
}
// Method that mutates (takes &mut self)
fn scale(&mut self, factor: f64) {
self.width *= factor;
self.height *= factor;
}
}
let mut rect = Rectangle::new(10.0, 20.0);
println!("Area: {}", rect.area()); // 200.0
rect.scale(2.0);
println!("Area: {}", rect.area()); // 800.0No constructor boilerplate. No this binding confusion. No prototype chain to reason about. And because methods explicitly declare whether they take &self (read-only) or &mut self (mutable), you can tell from the signature whether a method modifies the struct.
Compare with JavaScript, where this binding is a perennial source of bugs:
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
scale(factor) {
this.width *= factor; // mutates! but the signature doesn't tell you
this.height *= factor;
}
}
const rect = new Rectangle(10, 20);
const getArea = rect.area;
getArea(); // NaN — `this` is undefined. Classic JS footgun.Rust's approach — composition over inheritance, explicit mutability, no this binding issues — produces code that is easier to reason about. You can look at a method signature and know exactly what it does to its receiver.
Let me be practical. You should not rewrite your Express API in Rust. You should not build your Next.js app in Rust. The JavaScript ecosystem has decades of web-specific tooling and libraries that Rust simply cannot match.
But there are specific, compelling use cases where Rust is the right choice for a JavaScript developer:
This is the killer use case. If you have a performance-critical function in your web app — image processing, data compression, encryption, physics simulation, complex parsing — you can write it in Rust, compile to WebAssembly, and call it from JavaScript.
// lib.rs — compiles to WASM
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn fibonacci(n: u32) -> u64 {
match n {
0 => 0,
1 => 1,
_ => {
let mut a: u64 = 0;
let mut b: u64 = 1;
for _ in 2..=n {
let temp = b;
b = a + b;
a = temp;
}
b
}
}
}// JavaScript: calling the WASM module
import init, { fibonacci } from './pkg/my_lib.js';
await init();
console.log(fibonacci(50)); // instant, and handles large numbers correctlyThe wasm-bindgen and wasm-pack tools make this workflow smooth. You write Rust, run wasm-pack build, and get a JavaScript package you can import. The performance improvement for computational tasks is typically 10x-100x compared to equivalent JavaScript.
JavaScript CLI tools require Node.js to be installed. Rust compiles to a single static binary that works on any machine with no runtime dependencies. Ship it, run it, done.
use clap::Parser;
#[derive(Parser)]
#[command(name = "myapp", about = "Does something useful")]
struct Cli {
#[arg(short, long)]
input: String,
#[arg(short, long, default_value = "output.txt")]
output: String,
#[arg(short, long)]
verbose: bool,
}
fn main() {
let cli = Cli::parse();
// your tool logic here
}Compile for Linux, macOS, and Windows from one codebase. No pkg, no nexe, no bundler shenanigans. cargo build --release --target x86_64-unknown-linux-musl and you have a binary you can scp to any Linux server.
If you have a specific service that needs to handle extreme throughput or has strict latency requirements — a WebSocket server, a real-time data pipeline, a matching engine — Rust is an excellent choice. Frameworks like axum and actix-web are mature, and the performance characteristics are in a different league from Node.js.
But be honest with yourself: most backend services do not need this level of performance. If your bottleneck is database queries and network I/O (as it is for most web services), rewriting in Rust will not magically make things faster. Choose Rust for CPU-bound problems, not I/O-bound ones.
Tools like napi-rs let you write native Node.js modules in Rust. This is how projects like swc (the Rust-based JavaScript compiler) and Turbopack achieve their performance. If you maintain a Node.js tool or library and need to speed up a hot path, writing that path in Rust via napi-rs is a well-trodden path.
I will not pretend the Rust learning curve is gentle. Here are the frustrations I remember most vividly:
The borrow checker fights. In your first month, you will spend more time satisfying the compiler than writing logic. You will write code that "should" work and the compiler will say no. This is normal. It gets dramatically better with practice. The key insight: when the compiler says no, it is almost always preventing a real bug you would have encountered at runtime in another language.
Compile times. Coming from JavaScript's instant feedback loop, waiting 30 seconds for a compile feels like an eternity. Use cargo check instead of cargo build during development — it skips code generation and runs in about half the time. Use cargo watch to automatically recheck on save. Accept that the compilation cost is front-loaded: you pay at compile time instead of paying at debug time.
String types. Rust has String (owned, heap-allocated) and &str (borrowed slice). Plus OsString, CString, PathBuf, and more for interop with different systems. Coming from JavaScript where everything is just string, this feels like overkill. The distinction between String and &str maps roughly to the ownership system: if you own the string data, use String. If you are just looking at someone else's string, use &str.
Error handling verbosity. The ? operator helps enormously, but defining error types and implementing conversions between them can feel like boilerplate. Libraries like thiserror and anyhow reduce this significantly. Use anyhow for applications and thiserror for libraries.
Async complexity. I covered this above. It is real. Start with synchronous code and add async only when you need it.
The orphan rule. You cannot implement an external trait for an external type. This prevents library conflicts but occasionally forces awkward wrapper types. Just accept it and write the wrapper.
The learning curve was not linear. There were distinct phases:
Week 1-2: Fighting the compiler on every line. Everything I wrote had ownership errors. I cloned everything because I did not understand borrowing. The code compiled but was not idiomatic.
Week 3-4: Understanding borrowing. The & and &mut rules started making sense. I stopped cloning everything and started passing references. The compiler stopped yelling at me as much.
Month 2: Pattern matching and enums clicked. I started modeling problems with enums instead of objects-with-type-fields. Error handling with Result became second nature. I stopped writing unwrap() everywhere and started propagating errors properly.
Month 3: Traits and generics. I could write generic functions and understand trait bounds. I started seeing the elegance of Rust's type system — how traits, generics, and lifetimes compose together to give you both safety and flexibility.
Month 4+: Thinking in Rust. I stopped translating JavaScript patterns into Rust and started thinking in Rust idioms. Ownership was no longer a constraint — it was a design tool. The compiler felt like a pair-programming partner, not an adversary.
The most important realization: Rust is not hard because it is poorly designed. Rust is hard because it makes you think about things that other languages let you ignore. Memory management, data ownership, reference validity, thread safety — these are real concerns in every program. JavaScript hides them. Rust surfaces them. Once you internalize those concepts, you become a better programmer in every language.
This is the part I did not expect. After six months of Rust, I came back to JavaScript and found that my code was noticeably different:
I stopped mutating data casually. In Rust, mutation is explicit and controlled. I brought that discipline to JavaScript. More const, more spread operators, more immutable patterns. Fewer mutation-related bugs.
I started modeling errors as values. No more naked throw in library code. I return discriminated unions for expected failure cases and only throw for truly exceptional situations. Callers can handle errors precisely instead of guessing what might go wrong.
I think about data ownership. Even though JavaScript has garbage collection, thinking about "who owns this data" and "who is allowed to modify it" leads to cleaner architecture. Functions that take ownership of data (and are free to mutate it) versus functions that just read data (and should receive a copy or a frozen object) — this distinction makes APIs clearer.
I use TypeScript more strictly. Strict mode. No any. Exhaustive switch statements. Discriminated unions. These are all patterns I adopted after seeing how Rust's type system catches bugs at compile time.
I appreciate the garbage collector. Seriously. After manually thinking about lifetimes and ownership for months, I have a much deeper appreciation for what the GC does for you. It is not free (GC pauses are real, memory overhead is real), but the developer productivity it provides is enormous. Rust made me grateful for JavaScript's strengths, not just aware of its weaknesses.
Most Rust learning resources are written by systems programmers for systems programmers. Here are the ones that clicked for my JavaScript brain:
"The Rust Programming Language" book (the official one, freely available online). Yes, it is long. Read it cover to cover anyway. The ownership and borrowing chapters are the most important prose ever written about a programming language concept. Do not skip them.
Rust by Example — official companion to the book with runnable code for every concept. When the book's explanation does not click, the examples usually will.
"Programming Rust" by Blandy, Orendorff, and Tindall — the best intermediate Rust book. Read it after the official book. It explains the "why" behind Rust's design decisions in a way that makes everything click.
Exercism's Rust track — practice problems with mentor feedback. The progression is well-designed for building up from basic syntax to ownership to traits to generics.
rustlings — small exercises that teach you Rust through compiler errors. You fix broken code until it compiles. This is the fastest way to build muscle memory with the borrow checker.
Yes, with caveats.
If you are a JavaScript developer who is curious, who enjoys understanding how things work under the hood, and who has a practical use case (WASM, CLI tools, performance-critical code), learning Rust is one of the highest-return investments you can make. Not because you will use Rust for everything, but because it will fundamentally change how you think about software.
If you are looking for a language to build web apps faster, Rust is not it. JavaScript and TypeScript with modern frameworks will be more productive for web development for the foreseeable future. Rust's strengths lie elsewhere.
If you are intimidated by the learning curve, know that it is real but it is finite. The first month is brutal. The second month is hard. The third month, things start clicking. By six months, you are productive. And the skills you gain transfer to every other language you write.
The mental model shift — from "the runtime handles it" to "I am responsible for this data's lifecycle" — is the most valuable thing I have learned in years. It does not make JavaScript worse. It makes you understand what JavaScript is doing on your behalf, and that understanding makes you a more thoughtful, more deliberate, and ultimately better developer.
Rust did not replace JavaScript in my toolkit. But it earned a permanent seat at the table, and it fundamentally changed how I use every other tool in the box.