Rust Axum: I Built a REST API Without Losing My Mind (Much) 🦀🌐
Rust Axum: I Built a REST API Without Losing My Mind (Much) 🦀🌐
Confession: I thought building a web API in Rust would feel like performing surgery with oven mitts.
I was wrong. Mostly. The compiler still yells at you. But in a loving way.
Coming from 7 years of Laravel and Node.js, I've been spoiled. Laravel gives you routing, validation, authentication, a majestic ORM, artisan commands, and free therapy in the form of readable error messages. Node.js gives you npm install express and you're somehow shipping code 10 minutes later.
So when I decided to build a lightweight HTTP API for my RF/SDR hobby project — I needed something to expose decoded radio signals over a REST endpoint — I went looking for the Rust equivalent of Express. I found Axum. And it was... actually kind of great?
What Even Is Axum? 🤔
Axum is a web framework built by the Tokio team (the same people behind Rust's async runtime you might have met if you've been following this series). It's opinionated about a few things and completely hands-off about others.
If Express.js and Laravel had a systems-programming baby with a PhD in type safety, you'd get Axum.
It's fast — like, embarrassingly fast. We're talking benchmarks where it handles hundreds of thousands of requests per second on a single machine. Your Laravel app running on a shared DigitalOcean droplet is giving Axum the side-eye right now.
Setting Up: Cargo Is Still My Hero 📦
# Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
That's it. No composer.json with 40 nested dependencies. No package.json with 800MB of node_modules. Just four lines and cargo build does the rest.
Coming from PHP: yes, Cargo actually resolves and locks dependencies correctly on the first try. I know. I was suspicious too.
Your First Route: Shockingly Familiar 🛣️
use axum::{routing::get, Router};
async fn hello() -> &'static str {
"Hello from Rust! 🦀"
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(hello));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
Compare that to Laravel:
Route::get('/', fn() => 'Hello from PHP! 🐘');
Okay, Laravel is more concise. But notice what's happening in Rust: it's not magic. You can see the TCP listener. You can see the async runtime. Everything is explicit. Coming from a background where Laravel hides a lot behind facades and service containers, I actually found this refreshing — even if my fingers were typing more.
JSON Responses: Serde Is Your Best Friend 🔄
The real test of any web framework is JSON in, JSON out. Here's where Axum shines:
use axum::{routing::get, Json, Router};
use serde::Serialize;
#[derive(Serialize)]
struct Signal {
frequency: f64,
strength: f32,
decoded: String,
}
async fn get_signal() -> Json<Signal> {
Json(Signal {
frequency: 433.92,
strength: -65.4,
decoded: "Garage door opener detected 🚪".to_string(),
})
}
The #[derive(Serialize)] macro tells Rust how to convert your struct to JSON automatically. No json_encode(). No res.json(). Just annotate your struct and return it. The type system guarantees your response is always valid JSON — the compiler won't let you return something that can't be serialized.
What excited me about this: in Laravel, I can accidentally return non-serializable objects in API Resources and only find out in production. In Axum? That's a compile error. Your CI pipeline catches it, not your users.
Route Parameters: Extractors Are Elegant 🎯
This is where Axum gets clever. Instead of passing a Request object everywhere (hello, Express), Axum uses extractors — typed function parameters that the framework fills in automatically.
use axum::{extract::Path, routing::get, Json, Router};
async fn get_station(Path(station_id): Path<u32>) -> Json<serde_json::Value> {
// station_id is already a u32 — no parsing, no "did they send a string?" panic
Json(serde_json::json!({
"station": station_id,
"status": "monitoring"
}))
}
let app = Router::new()
.route("/stations/:id", get(get_station));
Coming from Laravel's route model binding, this felt familiar. The framework handles the parsing. But unlike Laravel, if someone sends GET /stations/abc, Rust rejects it at the type level — the handler never even runs. u32 doesn't accept "abc". No custom validation rule needed. The type IS the validation.
Shared State: No More Global Facades 🏗️
This was the biggest mental shift for me. Laravel has its service container. Node.js has module-level globals. Axum makes you be explicit about shared state — which sounds annoying until you realize it means no mysterious "why is this singleton behaving weirdly in tests" bugs.
use axum::{extract::State, routing::get, Router};
use std::sync::Arc;
// Your shared "application state"
struct AppState {
active_frequencies: Vec<f64>,
api_key: String,
}
async fn list_frequencies(State(state): State<Arc<AppState>>) -> String {
format!("Monitoring {} frequencies", state.active_frequencies.len())
}
let state = Arc::new(AppState {
active_frequencies: vec![433.92, 868.0, 915.0],
api_key: "secret".to_string(),
});
let app = Router::new()
.route("/frequencies", get(list_frequencies))
.with_state(state);
Arc is Rust's reference-counted pointer for sharing data across threads. You pass state in once, and every handler that needs it just declares it in its parameters. Thread-safe by default. No Singleton::getInstance() prayer required.
The Performance Number That Made Me Choke On My Coffee ☕
I built a tiny signal-reporting API. Same logic, two implementations:
- Laravel (PHP 8.3, FPM): ~800 requests/second on my dev machine
- Axum (Rust): ~120,000 requests/second on the same machine
That's not a benchmark artifact. That's the difference between "interpreted language with a request lifecycle" and "compiled binary that talks directly to your OS's networking stack."
For my SDR project — where I want to push decoded packet data to a dashboard in near-real-time — Axum means I can serve hundreds of concurrent WebSocket connections on a Raspberry Pi without breaking a sweat. The Pi that previously struggled to run PHP-FPM.
What's Still Harder Than Laravel 😤
Look, I'm not going to pretend Axum replaces Laravel for building a full web app. Some things are still genuinely painful:
- No built-in ORM. You need
sqlxordiesel. Both are excellent. Neither is Eloquent. - No authentication scaffolding. Building JWT middleware from scratch taught me more about security than anything else, but it's not a 30-second job.
- Error messages are improving but still dense. When the compiler rejects your handler signature, the error can span 40 lines. You learn to love it, eventually.
But here's the thing: for APIs where performance matters — real-time data, high-concurrency endpoints, edge deployments — Axum makes the hard parts worth it. The compile-time guarantees mean my signal processing API has been running for weeks without a single panic or null pointer crash.
The RF/SDR Use Case That Sold Me 📡
My actual use case: I have a Raspberry Pi 4 running an RTL-SDR dongle, monitoring ISM-band wireless sensors around my house. I needed an API to expose the decoded data to a dashboard.
In Laravel, I'd need PHP-FPM, a queue worker for real-time processing, Redis for buffering, and probably a WebSocket server like Pusher or Soketi. That's four processes, two paid services, and a deployment that makes me nervous.
In Axum with Tokio channels (from the previous post in this series!), it's one binary. The SDR reader, the decoder, the HTTP server — one compiled program. No services to manage. No memory leaks in the PHP process that require weekly restarts. No npm audit warnings.
It fits on a Pi. It's fast enough to not miss a packet. It crashes approximately never.
That's the promise of systems programming that web development has been quietly whispering about for years.
TL;DR: Should You Try Axum? 📋
- If you're building a performance-critical API — yes, absolutely, start here.
- If you need rapid prototyping and an ORM — stick with Laravel or Express for now, revisit Axum when you need speed.
- Extractors are the killer feature — typed parameters that eliminate an entire class of runtime bugs.
- Explicit state is annoying until it saves you — no mysterious global state bugs in production.
- The learning curve is real but finite — after a week, the patterns click and you stop fighting the compiler.
Coming from 7 years of Laravel, I thought Rust web development would feel alien. Axum is the framework that proved me wrong. The patterns — routing, handlers, middleware, state — are recognizable. What's different is the layer of compile-time guarantees underneath them.
And for a hobby project where I'm decoding radio signals on a Raspberry Pi at 2am, those guarantees matter more than "fast to write." I want it to be fast to run. 🦀📡
Building REST APIs in Rust? Find me on LinkedIn — I'm probably comparing Axum route handler signatures to Laravel middleware chains and finding them weirdly similar.
Curious about the SDR-to-API pipeline? Check out my GitHub for hobby projects where Axum serves real decoded radio packets.
Your types are your validation. Your compiler is your test suite. 🦀🌐