When Rust Actually Makes Sense for Backend Work
Let's get the honest part out of the way first: Rust has a steep learning curve, compilation is slow compared to Go or TypeScript, and the ecosystem, while growing fast, still has fewer batteries-included frameworks than mature alternatives. If you're building a standard CRUD API, Go or Python will get you to production faster.
But there are cases where Rust's tradeoffs pay off handsomely:
- CPU-bound services where you've profiled Go or Java and need better performance without the GC pauses
- Memory-constrained environments (embedded, edge computing, Lambda functions where you're paying per MB-ms)
- Systems that absolutely cannot crash — Rust's ownership model eliminates entire categories of runtime errors
- Services handling tens of thousands of concurrent connections where memory overhead per connection matters
If one of those describes your situation, keep reading. If you're picking Rust because it's cool, you'll have a frustrating 6 months.
Axum: The Pragmatic Choice
The Rust web framework field has settled over the past year. Actix-web is mature and fast. Rocket is developer-friendly. But Axum, built by the Tokio team, has become the default recommendation for new projects.
Why? It's built on top of Tower (the service/middleware abstraction) and Tokio (the async runtime), which means it composes well with the broader ecosystem. Middleware from Tower works in Axum. Hyper's HTTP implementation sits underneath. It's not a walled garden.
A basic Axum service:
use axum::{
extract::{Path, State, Json},
routing::{get, post},
Router,
http::StatusCode,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
#[derive(Serialize)]
struct User {
id: i64,
name: String,
email: String,
}
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
async fn get_user(
State(pool): State<PgPool>,
Path(id): Path<i64>,
) -> Result<Json<User>, StatusCode> {
let user = sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE id = $1", id)
.fetch_optional(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(user))
}
async fn create_user(
State(pool): State<PgPool>,
Json(payload): Json<CreateUser>,
) -> Result<(StatusCode, Json<User>), StatusCode> {
let user = sqlx::query_as!(
User,
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email",
payload.name,
payload.email,
)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok((StatusCode::CREATED, Json(user)))
}
#[tokio::main]
async fn main() {
let pool = PgPool::connect("postgres://localhost/myapp")
.await
.expect("Failed to connect to database");
let app = Router::new()
.route("/users/:id", get(get_user))
.route("/users", post(create_user))
.with_state(pool);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Notice how extractors work — State(pool) pulls the shared database pool, Path(id) extracts the URL parameter, Json(payload) deserializes the request body. It's all type-driven. If the JSON doesn't match CreateUser, Axum returns a 422 automatically. No manual validation code.
Tokio: Understanding the Async Runtime
Every async fn in Rust compiles to a state machine. Tokio is the runtime that drives those state machines — scheduling tasks, polling futures, and managing the thread pool.
The default Tokio runtime uses a work-stealing scheduler with one thread per CPU core. For I/O-bound services (most web apps), this is optimal. Each thread can handle thousands of concurrent connections because it switches between tasks at every .await point instead of blocking.
The critical rule: never block the async runtime. A synchronous operation that takes 10ms blocks one of your 8 worker threads, reducing throughput by 12.5%. Common traps:
std::thread::sleep()— usetokio::time::sleep()- Synchronous file I/O — use
tokio::fsorspawn_blocking - CPU-intensive computation — wrap in
tokio::task::spawn_blocking - Mutex contention — use
tokio::sync::Mutexfor async code, orstd::sync::Mutexonly for short critical sections
// Wrong: blocks the async worker thread
let data = std::fs::read_to_string("large_file.txt").unwrap();
// Right: runs on the blocking thread pool
let data = tokio::task::spawn_blocking(|| {
std::fs::read_to_string("large_file.txt")
}).await.unwrap().unwrap();
// Also right: async file I/O
let data = tokio::fs::read_to_string("large_file.txt").await.unwrap();
Error Handling With anyhow and thiserror
Rust's Result type is great in theory but verbose in practice. Two crates make it bearable:
thiserror for library code — defines typed error enums with derive macros:
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("User not found: {0}")]
NotFound(i64),
#[error("Database error")]
Database(#[from] sqlx::Error),
#[error("Validation failed: {0}")]
Validation(String),
}
anyhow for application code — wraps any error into a single type with context:
use anyhow::{Context, Result};
async fn process_order(id: i64) -> Result<()> {
let order = fetch_order(id)
.await
.context("Failed to fetch order")?;
validate_inventory(&order)
.context("Inventory check failed")?;
charge_payment(&order)
.await
.context("Payment processing failed")?;
Ok(())
}
In Axum, you'll typically implement IntoResponse for your error type to map errors to HTTP status codes:
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
AppError::Validation(_) => (StatusCode::BAD_REQUEST, self.to_string()),
AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error".into()),
};
(status, Json(json!({"error": message}))).into_response()
}
}
Performance Reality Check
Rust's performance reputation is deserved, but context matters. For a typical JSON API backed by PostgreSQL, the database query takes 2-50ms. Whether the framework overhead is 0.01ms (Rust) or 0.5ms (Node.js/Express) is irrelevant — the bottleneck is I/O.
Where Rust actually shows measurable gains:
- JSON serialization/deserialization of large payloads (serde is stupidly fast)
- In-memory data processing — aggregations, transformations, sorting
- High connection counts — Rust's per-task memory overhead is ~300 bytes vs ~8KB for a goroutine and ~1MB for a Java thread
- Startup time and memory footprint — relevant for serverless and edge deployments
Our team's real-world benchmark: replacing a Go service that processes 50,000 webhook events/second with Rust reduced p99 latency from 12ms to 3ms and memory usage from 400MB to 45MB. The Go service wasn't slow — but for this specific high-throughput, CPU-bound workload, Rust was measurably better.
Don't pick Rust for backend services based on synthetic benchmarks. Pick it when you've identified a specific performance constraint that Rust's characteristics address.