Rust in Production, Where the 2024 Stack Has Matured
TL;DR — In 2024, async Rust on Tokio + Axum is the boring choice for new services. Tooling is solid, observability is good enough, the compile-time cost is real but stable. The remaining pain points are async trait ergonomics, harder-to-hire developers, and macro debugging.
I’ve shipped Rust services in production for four years now. The conversations have shifted. In 2020, people asked “is Rust ready?” In 2022, they asked “what’s missing?” In 2024, most senior engineers I talk to want to know “what does the mature stack actually look like, and where are the remaining sharp edges?”
This post is the survey. The next four weeks dig into specific pieces. Today: the high-level state of the union as of early March 2024.
What’s actually mature
The async runtime. Tokio 1.36 (released February 2024) is stable, well-instrumented, and the obvious choice. There’s async-std, smol, and glommio, but Tokio has the ecosystem gravity. Pick Tokio for any production work; reach for the others only with a specific reason.
Web frameworks. Axum 0.7 (released November 2023) is the consensus choice. Built on tower and hyper, integrates cleanly with the Tokio ecosystem, and the extractor pattern hits a sweet spot of ergonomics and explicitness. Actix-Web is still excellent but its ecosystem feels less central. Rocket exists but moves slowly.
// Axum 0.7 — March 2024
use axum::{routing::get, Router, Json};
use serde::Serialize;
#[derive(Serialize)]
struct Health { ok: bool }
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/healthz", get(|| async { Json(Health { ok: true }) }))
.route("/echo/:id", get(echo));
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn echo(axum::extract::Path(id): axum::extract::Path<String>) -> String {
format!("you said: {id}")
}
That’s a working service. Add tower-http for tracing, compression, CORS. Add axum-extra for typed extractors. Done.
Database access. sqlx for compile-time-checked SQL, sea-orm for ORM ergonomics, diesel for the type-safe query builder. All three are production-grade in 2024. I default to sqlx for new work — its async story and Postgres integration are the cleanest.
Serialization. serde is canonical. serde_json for JSON, prost for protobuf, rmp-serde for MessagePack. All mature, all stable.
Observability. tracing plus tracing-subscriber is the standard. Wire to OpenTelemetry via tracing-opentelemetry. Send to Tempo, Honeycomb, or any OTLP-compatible backend.
Builds and CI. cargo works. cargo-chef for Docker layer caching. cross for cross-compilation. cargo-deny for licensing and supply-chain audits. The tooling around the language has caught up with Go’s in most areas.
What still hurts
Async trait ergonomics. Rust 1.75 (December 2023) finally stabilized async fn in traits. The constraints are still real — you can’t have dyn Trait for an async trait without async-trait, and lifetime inference is awkward. The async-trait macro is no longer mandatory but still useful. It’s better than 2023; it’s not solved.
Compile times. Rust compiles slowly. Caching helps. Modular crate boundaries help. Incremental compilation helps. Cold builds on a fresh CI runner still take minutes for a non-trivial service. Plan for it.
Macro debugging. Compiler errors inside macros are still rough. cargo expand is the workflow that saves you — install it, use it whenever a derive macro is misbehaving.
Hiring. The Rust talent pool has grown but it’s not Java-sized. If you need to staff a 20-person Rust team this year, plan for either training or longer hiring cycles.
Result propagation in async closures. Looks fine in trivial cases, gets ugly with nested closures and lifetime captures. There are patterns that work; none of them are pretty.
The reasons people pick Rust in 2024
Three honest motivations from teams I’ve talked to:
- Performance per dollar. Rust services cost dramatically less to run than equivalent Node.js or Python at scale. The math gets compelling above ~1000 QPS sustained.
- Memory safety without GC pauses. For latency-sensitive paths (real-time systems, trading, multimedia), the absence of GC pauses matters more than the language’s nominal speed.
- Safe systems programming. Embedded, IoT, kernel modules, drivers. C and C++ are legacy here. Rust is the modern choice. Linux 6.7 shipped meaningful Rust support in early 2024.
The bad reasons:
- Hype. Rust is harder than Go; if you don’t need the performance or safety properties, Go ships faster.
- “We want to be like Discord.” Discord’s success with Rust is real and not your situation.
- Greenfield without a constraint. Your team’s productivity matters more than the language theory.
A skeleton production service
For a 2024 production HTTP service, this is the shape I default to:
# Cargo.toml
[dependencies]
tokio = { version = "1.36", features = ["full"] }
axum = "0.7"
tower-http = { version = "0.5", features = ["trace", "compression-gzip"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "uuid", "chrono"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-opentelemetry = "0.22"
opentelemetry = "0.22"
opentelemetry-otlp = "0.15"
anyhow = "1.0"
thiserror = "1.0"
config = "0.14"
Pin to recent stable versions. Plan to bump every few months. The ecosystem is stable but moves.
// main.rs — production skeleton
use axum::{routing::get, Router};
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::registry()
.with(EnvFilter::from_default_env())
.with(tracing_subscriber::fmt::layer().json())
.init();
let pool = sqlx::PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
let app = Router::new()
.route("/healthz", get(|| async { "ok" }))
.layer(TraceLayer::new_for_http())
.with_state(pool);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
axum::serve(listener, app).await?;
Ok(())
}
Add OpenTelemetry wiring, error handling middleware, and your routes. That’s a production-shaped service in <200 lines.
Deploy and runtime
For containerized deploys, multi-stage builds with cargo-chef keep image sizes and build times reasonable:
# Dockerfile — Rust 1.76, multi-stage with cargo-chef
FROM rust:1.76-slim AS chef
RUN cargo install cargo-chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --bin myservice
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/myservice /usr/local/bin/
EXPOSE 8080
CMD ["myservice"]
Final image lands around 50-80MB. Builds typically cache through the dependencies layer.
For Kubernetes, request and limit sizing for a moderate Rust service is much smaller than equivalent JVM services — 100m CPU / 64Mi memory is often enough for low-traffic. Memory will be stable; no GC churn.
Common Pitfalls
#[tokio::main]everywhere. It works for binaries; don’t use it in library crates. Libraries should be runtime-agnostic.block_oninside an async runtime. Deadlocks. Usetokio::task::spawn_blockingfor sync work inside async.unwrap()in production paths. Convert to?withanyhow::Resultat boundaries andthiserrorfor typed errors in libraries.- Skipping
cargo deny. License compliance and supply-chain checks are cheap once wired and painful to add post-hoc. - One giant crate. Split by domain. Workspaces with multiple crates compile faster after the first build because of better incremental boundaries.
tokio::spawnwithout bounded channels. Unbounded spawning under load is how you OOM in async Rust. Use bounded channels and back-pressure.
The pitfall I tripped on: I treated #[tokio::main] as the default in a library crate. Downstream consumers using a non-Tokio runtime broke. Removed the macro, exposed a start_with_runtime(handle: tokio::runtime::Handle) instead.
Wrapping Up
Rust in production in 2024 is mostly boring, which is the goal. The async runtime, web framework, observability, and database layers are mature. The remaining sharp edges — async traits, compile times, macro debugging — are well-known and tolerable. New backend services that need the performance or safety properties have a clear path.
This month’s posts dig into specific pieces: async Tokio patterns, Axum walkthrough, embedded Rust with Embassy, concurrency primitives, IoT, FFI, and profiling. The Tokio docs and Rust async book are good companion reading.