← All tech

Backend / language

Rust

Maximum speed with memory safety the compiler proves — no GC, no data races.

  • Tight latency and consistency budgets (predictable, no GC pauses)
  • Memory- and thread-safety enforced at compile time
  • Zero-cost abstractions over systems-level control
  • High-throughput async services on the tokio runtime
Use it when

Reach for Rust when correctness and tail latency both matter — a hot path where a GC pause is unacceptable, a service that must not data-race under load, or a library you want fast and safe with no runtime to ship.

Reach for something else when

Skip Rust when you need to move fast on a CRUD service and a GC is fine (use Go), when one language across front and back wins (use TypeScript), or for heavy ML/data work where the ecosystem lives elsewhere (use Python).

Official docs ↗


Rust earns its place when two things must hold at once: the code must be fast and it must be correct under concurrency — without a garbage collector to bail you out. This track moves from cargo new to ownership, Result/Option, traits, an async axum service, and finally profiling — tagged by level so you can read only as deep as you need.

Install Rust and create a project

Beginner

Install the toolchain with rustup, then scaffold a project with cargo new.

Why rustup and cargo, not a bare compiler

rustup installs and manages the toolchain (compiler, cargo, rustfmt, clippy) and lets you switch between stable and nightly. cargo is Rust’s build tool and package manager in one — it compiles, runs, tests, and resolves dependencies from crates.io. cargo new lays down a Cargo.toml manifest and a src/main.rs, so you have a runnable binary from the first command.

Install and scaffold
Run these in your terminal / editor
# Install the toolchain (https://rustup.rs)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# Verify, then create a project
rustc --version
cargo new photon-cli
cd photon-cli
cargo run

Feel ownership and the move

Beginner

Bind a String to one variable, then to another, and let the compiler explain what just happened.

Ownership is the whole idea

Every value in Rust has exactly one owner. When you assign a heap value like String to another variable, ownership moves — the original binding is no longer usable. This is how Rust frees memory deterministically with no garbage collector: when the owner goes out of scope, the value is dropped. To use a value without taking ownership, you borrow it with & (shared, read-only) or &mut (exclusive, mutable). The rule “many readers XOR one writer” is what eliminates data races at compile time.

A move, then a borrow
Run these in your terminal / editor
fn main() {
    let a = String::from("photon");
    let b = a;          // ownership MOVES from a to b
    // println!("{a}"); // <- uncomment: compile error, a was moved

    let len = char_count(&b); // borrow, don't move
    println!("{b} has {len} chars"); // b is still usable here
}

fn char_count(s: &str) -> usize {
    s.chars().count()
}

Model absence with Option and failure with Result

Beginner

Write a function that returns Option<T> when a value may be missing, and Result<T, E> when an operation may fail.

No null, no exceptions — values instead

Rust has no null and no exceptions. Absence is Option<T> (Some(v) or None); fallibility is Result<T, E> (Ok(v) or Err(e)). Because they are ordinary enums, the compiler forces you to handle both arms — you cannot accidentally use a missing value. The ? operator propagates an Err (or None) up to the caller in one character, so the happy path stays flat and readable.

Option, Result, and the ? operator
Run these in your terminal / editor
fn first_word(s: &str) -> Option<&str> {
    s.split_whitespace().next()
}

fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> {
    let port: u16 = s.parse()?; // ? returns the Err early on failure
    Ok(port)
}

fn main() {
    match first_word("photon arena") {
        Some(w) => println!("first: {w}"),
        None => println!("empty"),
    }
    println!("{:?}", parse_port("8080")); // Ok(8080)
    println!("{:?}", parse_port("oops")); // Err(...)
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Rust engineer working in this repo (the photon-cli crate).
Context: Stable Rust, a binary crate with src/main.rs. No external dependencies.
Task: Add a module src/parse.rs with `fn parse_pair(input: &str) -> Result<(String, u16), String>`.
Requirements:
- Input format is "host:port"; return the host and the parsed u16 port.
- On a missing colon, return Err("missing ':' separator".to_string()).
- On a non-numeric or out-of-range port, return Err with a message mentioning the bad value.
- Use the ? operator internally where it reads cleanly; do not panic on bad input.
- Declare the module in main.rs with `mod parse;`.
Tests / acceptance:
- Add #[cfg(test)] tests covering: "localhost:8080" -> Ok(("localhost".into(), 8080)),
  "localhost" -> Err, "localhost:notaport" -> Err, "localhost:70000" -> Err.
- `cargo test` passes; `cargo clippy -- -D warnings` is clean.
Output: a unified diff plus a one-paragraph summary of the error model.

Branch with pattern matching

Beginner

Use match to handle every variant of an enum, and let the compiler enforce that you covered them all.

Exhaustiveness is checked for you

match is Rust’s primary control-flow tool. It destructures values and binds their contents in one step, and it is exhaustive: if you add a variant to an enum later and forget to handle it, the code stops compiling. That turns a whole class of “forgot a case” bugs into build errors. Use if let when you care about only one variant, and _ as a catch-all only when a default is genuinely correct.

Exhaustive match over an enum
Run these in your terminal / editor
enum Event {
    Connect { user: String },
    Disconnect,
    Message(String),
}

fn describe(e: &Event) -> String {
    match e {
        Event::Connect { user } => format!("{user} joined"),
        Event::Disconnect => "left".to_string(),
        Event::Message(text) => format!("said: {text}"),
    }
}

Share behaviour with traits

Intermediate

Define a trait describing a capability, implement it for a type, and write a function that accepts any type that has it.

Traits are Rust's interfaces — resolved at zero cost

A trait declares a set of methods a type can provide; impl Trait for Type supplies them. Functions take trait bounds (fn f<T: Summary>(x: &T) or fn f(x: &impl Summary)), so they work for any conforming type. With static dispatch (generics) the compiler monomorphises a specialised copy per concrete type — the abstraction compiles away to the same code you’d write by hand, the literal meaning of “zero-cost”. When you genuinely need runtime polymorphism, &dyn Trait opts into dynamic dispatch instead.

Define, implement, and bound on a trait
Run these in your terminal / editor
trait Summary {
    fn summary(&self) -> String;
}

struct Article {
    title: String,
    words: u32,
}

impl Summary for Article {
    fn summary(&self) -> String {
        format!("{} ({} words)", self.title, self.words)
    }
}

// Static dispatch: monomorphised per concrete T, no vtable.
fn print_summary(item: &impl Summary) {
    println!("{}", item.summary());
}
Chat prompt — paste into a chat to get the code
For a plain chat. It returns complete code; you paste it in yourself.
Role: Rust teacher. The reader has no repo access — return complete, compiling code.
Task: Show a `Shape` trait with `fn area(&self) -> f64`, implemented for `Circle { radius: f64 }`
and `Rectangle { width: f64, height: f64 }`, plus a function `total_area` that sums the areas of a
slice of trait objects.
Requirements:
- `total_area(shapes: &[Box<dyn Shape>]) -> f64` uses dynamic dispatch (explain why a slice of mixed
  shapes needs `dyn`).
- Include a `fn main` that builds a Vec<Box<dyn Shape>> with one of each and prints the total.
- Use std::f64::consts::PI for the circle.
Tests / acceptance (describe, since no repo):
- A circle of radius 1 plus a 2x3 rectangle totals PI + 6.0; show this in main's output.
Output: the complete main.rs, no commentary.

Add dependencies and (de)serialize with serde

Intermediate

Add serde to Cargo.toml, derive Serialize/Deserialize on a struct, and round-trip it through JSON.

serde is the (de)serialization standard

serde is the de-facto framework for turning Rust data into formats and back. You #[derive(Serialize, Deserialize)] on a struct and a companion crate (serde_json, serde_yaml, …) does the format-specific work. Derives are checked at compile time, so a field-name or type mismatch is a build error, not a runtime surprise. cargo add writes the dependency into Cargo.toml for you, pinning a compatible version from crates.io.

Derive and round-trip JSON
Run these in your terminal / editor
cargo add serde --features derive
cargo add serde_json
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug)]
struct Player {
    id: String,
    #[serde(rename = "displayName")]
    name: String,
    score: u32,
}

fn main() -> Result<(), serde_json::Error> {
    let p = Player { id: "p1".into(), name: "Nova".into(), score: 42 };
    let json = serde_json::to_string(&p)?;     // serialize
    let back: Player = serde_json::from_str(&json)?; // deserialize
    println!("{json}\n{back:?}");
    Ok(())
}

Test in-crate with cargo test

Intermediate

Add a #[cfg(test)] module beside your code and run the whole suite with cargo test.

Tests live next to the code they cover

Rust’s test runner is built into cargo. Unit tests live in the same file under a #[cfg(test)] module (compiled only for tests) and may call private functions. Mark each test with #[test] and assert with assert_eq! / assert!. cargo test builds the test binary and runs every test in parallel; a panicking assertion fails just that test. Integration tests go in a top-level tests/ directory and exercise only the public API.

A unit test module
Run these in your terminal / editor
pub fn clamp_score(raw: i64) -> u32 {
    raw.clamp(0, 1000) as u32
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn clamps_low_and_high() {
        assert_eq!(clamp_score(-5), 0);
        assert_eq!(clamp_score(42), 42);
        assert_eq!(clamp_score(5000), 1000);
    }
}
cargo test
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Rust engineer in this repo (the photon-cli crate).
Context: Stable Rust. A library module with a public function `clamp_score(raw: i64) -> u32`.
Task: Add a thorough #[cfg(test)] module for clamp_score and one property-style boundary test.
Requirements:
- Cover below-range (-1 -> 0), in-range (0, 500, 1000), and above-range (1001, i64::MAX -> 1000).
- Add a test asserting the result is always within 0..=1000 for a fixed set of sampled inputs.
- Do not change the function's behaviour or signature.
Tests / acceptance:
- `cargo test` passes.
- `cargo clippy -- -D warnings` is clean.
Output: a unified diff plus a one-line note on which boundary was most likely to break.

Serve HTTP with axum on tokio

Intermediate

Add axum and tokio, then start an async web server with a GET /healthz route.

Why tokio and axum together

Rust’s async is built on futures that do nothing until a runtime polls them; tokio is the dominant runtime that drives them and provides async I/O, timers, and tasks. axum is the tokio team’s web framework — handlers are plain async fns, routing is a typed Router, and extractors pull typed data out of a request. #[tokio::main] sets up the runtime so main itself can be async. The pairing gives you C-class throughput with the borrow checker still guarding shared state.

A minimal axum server
Run these in your terminal / editor
cargo add tokio --features full
cargo add axum
use axum::{routing::get, Router};

async fn healthz() -> &'static str {
    "ok"
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/healthz", get(healthz));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080")
        .await
        .unwrap();
    println!("listening on :8080");
    axum::serve(listener, app).await.unwrap();
}
cargo run
# in another terminal:
curl -i localhost:8080/healthz
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Rust web engineer in this repo.
Context: Stable Rust. Dependencies: axum, tokio (features = ["full"]), serde + serde_json (derive).
Task: Build an axum server in src/main.rs with a typed JSON route GET /players/{id}.
Requirements:
- Define `#[derive(Serialize)] struct Player { id: String, name: String, score: u32 }`.
- The handler uses the Path extractor to read {id} and returns axum::Json<Player>.
- Keep the GET /healthz route returning the string "ok".
- Bind 0.0.0.0:8080 with tokio::net::TcpListener and axum::serve.
- No .unwrap() in request handlers; only main may unwrap during startup.
Tests / acceptance:
- `cargo build` succeeds; `cargo clippy -- -D warnings` is clean.
- `curl -s localhost:8080/players/p1` returns JSON containing "id":"p1".
- `curl -s -o /dev/null -w "%{http_code}" localhost:8080/healthz` prints 200.
Output: a unified diff plus a one-paragraph summary of how the Path extractor is typed.

Share state safely across async tasks

Advanced

Hold shared state in Arc<Mutex<...>>, pass it to handlers with axum’s State, and let the compiler prove the access is sound.

Send + Sync, enforced — not hoped for

Concurrent handlers may touch the same data, so that data must be safe to share. Arc<T> is an atomically reference-counted pointer that lets many tasks own a handle to one value; Mutex<T> serialises mutation so only one task writes at a time. The compiler tracks the Send and Sync marker traits and refuses to compile a handler that would share non-thread-safe state — the data race is caught at build time, not in production. In async code prefer tokio::sync::Mutex when a lock is held across an .await.

Shared counter behind Arc<Mutex<_>>
Run these in your terminal / editor
use std::sync::Arc;
use axum::{extract::State, routing::get, Router};
use tokio::sync::Mutex;

#[derive(Clone)]
struct AppState {
    hits: Arc<Mutex<u64>>,
}

async fn count(State(state): State<AppState>) -> String {
    let mut hits = state.hits.lock().await;
    *hits += 1;
    format!("hit #{hits}")
}

#[tokio::main]
async fn main() {
    let state = AppState { hits: Arc::new(Mutex::new(0)) };
    let app = Router::new().route("/count", get(count)).with_state(state);
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Rust concurrency engineer in this repo.
Context: Stable Rust with axum + tokio (features = ["full"]). A running axum server.
Task: Add an in-memory scoreboard guarded by Arc<tokio::sync::Mutex<HashMap<String, u32>>> in AppState,
with POST /score/{player} (increments by 1) and GET /score/{player} (reads, 0 if absent).
Requirements:
- AppState derives Clone; the map lives behind Arc<Mutex<...>>.
- Handlers take State<AppState> and a Path extractor; lock only for the minimal critical section.
- The lock is tokio::sync::Mutex because it is held across .await points.
- No data race and no .unwrap() in handlers; the project must compile clean.
Tests / acceptance:
- `cargo build` and `cargo clippy -- -D warnings` are clean.
- Manual: two POSTs to /score/nova then GET /score/nova returns 2.
Output: a unified diff plus a one-paragraph note on why tokio::sync::Mutex over std::sync::Mutex here.

Make lifetimes explicit when borrows escape

Advanced

Annotate a function that returns a reference so the compiler knows how long the borrow stays valid.

Lifetimes name relationships the compiler already tracks

A lifetime is the span during which a reference is valid. The borrow checker tracks them automatically; you only write annotations when a function returns a reference and the compiler cannot infer which input it borrows from. fn longest<'a>(x: &'a str, y: &'a str) -> &'a str says “the returned reference lives at least as long as both inputs”. This is purely a compile-time contract — there is no runtime cost — and it is what makes returning a borrow into a caller’s data provably safe with no dangling pointers.

An explicit lifetime annotation
Run these in your terminal / editor
// The returned &str is tied to both inputs; the caller's data must outlive it.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() >= y.len() { x } else { y }
}

fn main() {
    let a = String::from("photon");
    let b = String::from("arena!");
    println!("{}", longest(&a, &b)); // borrows a and b; valid while both live
}
Chat prompt — paste into a chat to get the code
For a plain chat. It returns complete code; you paste it in yourself.
Role: Rust teacher. The reader has no repo access — return complete, compiling code.
Task: Implement `fn first_field<'a>(line: &'a str, sep: char) -> Option<&'a str>` that returns the slice
before the first `sep`, borrowing from the input (no allocation), or None if `sep` is absent.
Requirements:
- The returned &str must borrow from `line` (explicit lifetime 'a), not a freshly allocated String.
- Use str::find and slicing; do not pull in any external crate.
- Include #[cfg(test)] tests: ("a,b,c", ',') -> Some("a"); ("nocomma", ',') -> None; (",x", ',') -> Some("").
- Include a `fn main` that prints first_field("photon:arena", ':').
Tests / acceptance (describe, since no repo):
- `cargo test` would pass with the included tests.
Output: the complete main.rs, no commentary.

Build for release and profile the hot path

Advanced

Compile with --release, write a benchmark with criterion, and find where the time actually goes before optimising.

Measure before you optimise — and always benchmark in release

A debug build skips optimisations; never judge performance from it. cargo build --release turns on the optimiser (opt-level = 3 by default), often a 10–100x difference on hot numeric code. criterion is the standard statistical benchmarking crate: it runs many iterations, accounts for variance, and reports confidence intervals, so a “faster” claim is real and not noise. Profile with a sampling profiler (e.g. perf + a flame graph) to find the hot function, then optimise only what the data proves is hot.

Release build and a criterion benchmark
Run these in your terminal / editor
# Cargo.toml
[dev-dependencies]
criterion = "0.5"

[[bench]]
name = "scoring"
harness = false
// benches/scoring.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use photon_cli::clamp_score; // pub fn from the crate

fn bench_clamp(c: &mut Criterion) {
    c.bench_function("clamp_score", |b| {
        b.iter(|| clamp_score(black_box(5000)))
    });
}

criterion_group!(benches, bench_clamp);
criterion_main!(benches);
cargo build --release
cargo bench
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Rust performance engineer in this repo.
Context: Stable Rust. The crate exposes `pub fn clamp_score(raw: i64) -> u32`. criterion is a dev-dependency
and a [[bench]] named "scoring" (harness = false) is declared in Cargo.toml.
Task: Add benches/scoring.rs benchmarking clamp_score across representative inputs and report results.
Requirements:
- Use criterion with black_box to prevent the optimiser folding the call away.
- Benchmark at least three inputs: below range, in range, above range.
- Do not change clamp_score's behaviour; correctness tests must still pass.
Tests / acceptance:
- `cargo bench` runs and prints timing with confidence intervals.
- `cargo test` is still green.
Output: a unified diff plus the criterion summary numbers and a one-line interpretation.

Where to take it next

  • Put this to work in Concord, where Rust drives the conflict-free (CRDT) merge engine — the convergence a GC pause or a data race would ruin.
  • Want the same backend job with less ceremony and a GC you can live with? Compare against Go.
  • Prefer one language across front and back, trading raw speed for reach? See TypeScript.
  • Weigh all the backend options side by side on the Compare page.