Rust API Performance: Profiling and Optimization
Rust is renowned for its zero-cost abstractions and predictable performance, but writing a fast API still requires deliberate measurement and optimization. Unlike garbage-collected languages where the runtime often dictates performance characteristics, Rust hands you the reins — which means you also own the responsibility of profiling, identifying bottlenecks, and applying targeted optimizations. This tutorial walks through the full lifecycle of performance work on a Rust HTTP API: from establishing a baseline, to profiling with the right tools, to applying concrete optimizations.
Why Performance Profiling Matters in Rust APIs
Even in a language as efficient as Rust, APIs can suffer from subtle performance issues: unnecessary allocations, blocking I/O on async runtimes, lock contention, inefficient serialization, or suboptimal database access patterns. The danger is that Rust's reputation can breed complacency — developers assume "it's Rust, so it's fast." In reality, a poorly structured async handler or a hot allocation path can make a Rust API slower than a well-tuned Node.js service.
Profiling matters because it replaces guesswork with data. Without measurement, optimization becomes cargo-cult programming: you apply techniques you've heard are "fast" without knowing whether they address your actual bottleneck. The golden rule is simple: measure first, optimize second, measure again.
Establishing a Performance Baseline
Before optimizing anything, you need a reliable way to measure current performance. The most effective approach combines load testing with observability. For load testing, wrk, hey, or k6 work well. For a Rust-native benchmarking approach, you can use the criterion crate for micro-benchmarks of specific functions.
Setting Up a Load Test
Consider a simple Axum-based API. First, let's define the server:
// Cargo.toml dependencies
// [dependencies]
// axum = "0.7"
// tokio = { version = "1", features = ["full"] }
// serde = { version = "1", features = ["derive"] }
// serde_json = "1"
// tracing = "0.1"
// tracing-subscriber = "0.3"
use axum::{routing::get, Router, Json};
use serde::Serialize;
#[derive(Serialize)]
struct User {
id: u64,
name: String,
email: String,
}
async fn get_user() -> Json<User> {
Json(User {
id: 1,
name: "Ada Lovelace".into(),
email: "ada@example.com".into(),
})
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let app = Router::new().route("/user", get(get_user));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
tracing::info!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
With the server running, use wrk to establish a baseline:
wrk -t4 -c100 -d30s http://localhost:3000/user
This runs a 30-second test with 4 threads and 100 connections. Record the requests per second (RPS) and latency percentiles. These numbers are your baseline — every optimization should be measured against them.
Profiling Tools for Rust
Rust compiles to native code, which means you can use standard systems profiling tools. The key tools in the Rust performance toolkit are:
- perf — Linux CPU profiler that samples the running process and produces flame graphs.
- flamegraph — A Rust crate (and CLI) that wraps
perfordtraceto generate flame graphs easily. - pprof-rs — An in-process profiler that integrates with Rust applications, useful for continuous profiling.
- tokio-console — A diagnostic tool for async Rust that visualizes task scheduling, polling times, and blocking behavior.
- hyperfine — A command-line benchmarking tool for comparing the execution time of commands or binaries.
- criterion — A statistics-driven benchmarking library for fine-grained function-level benchmarks.
Generating a Flame Graph
The flamegraph crate provides the fastest path to a visual profile on Linux. Install it with:
cargo install flamegraph
Then run your application under the profiler. You need a debug build with symbols, or a release build with debug info. Add this to your Cargo.toml:
[profile.release]
debug = true
Now generate the flame graph:
cargo flamegraph --bin your_api -- --port 3000
While the profiler is running, generate load with wrk in another terminal. The resulting flamegraph.svg file shows which functions consume the most CPU time. Wide bars indicate hot spots. Look for unexpected allocations, deep serialization stacks, or time spent in locks.
Using pprof-rs for In-Process Profiling
For production scenarios where you cannot run perf, pprof-rs enables on-demand CPU profiling from within the application:
// Cargo.toml
// [dependencies]
// pprof = { version = "0.13", features = ["flamegraph", "protobuf"] }
// axum = "0.7"
// tokio = { version = "1", features = ["full"] }
use axum::{routing::get, Router};
use pprof::ProfilerGuardBuilder;
use std::sync::Arc;
use std::time::Duration;
async fn start_profile() -> String {
let guard = ProfilerGuardBuilder::default()
.frequency(1000)
.blocklist(&["libc", "libgcc", "pthread", "vdso"])
.build()
.unwrap();
// Store guard globally so it stays alive
// In production, use a Mutex<Option<ProfilerGuard>>
std::mem::forget(guard);
"Profiling started".to_string()
}
async fn stop_profile() -> String {
// In a real implementation, retrieve the guard and generate the report
let guard = unsafe {
// This is simplified — use proper state management in production
ProfilerGuardBuilder::default().build().unwrap()
};
if let Ok(report) = guard.report().build() {
let file = std::fs::File::create("profile.pb").unwrap();
let _ = report.pprof().unwrap().write(&mut std::io::BufWriter::new(file));
}
"Profile saved".to_string()
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/debug/profile/start", get(start_profile))
.route("/debug/profile/stop", get(stop_profile));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
This approach lets you trigger profiling on a running server, capture a snapshot during real traffic, and analyze it later with tools like pprof or the Chrome tracing UI.
Diagnosing Async Issues with tokio-console
One of the most common performance killers in Rust APIs is blocking the async runtime. If a handler performs synchronous file I/O, CPU-intensive computation, or calls std::thread::sleep, it blocks a worker thread and starves other tasks. tokio-console makes these issues visible.
Enable it by adding the tracing console subscriber:
// Cargo.toml
// [dependencies]
// console-subscriber = "0.4"
// tokio = { version = "1", features = ["full", "tracing"] }
// axum = "0.7"
use console_subscriber::ConsoleLayer;
#[tokio::main]
async fn main() {
let console_layer = ConsoleLayer::builder().spawn();
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.finish();
let app = axum::Router::new()
.route("/health", axum::routing::get(|| async { "ok" }));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Then connect with:
tokio-console http://localhost:6669
The console shows each task's poll duration, wake-up count, and whether tasks are stalling. Tasks with high poll times or long idle periods indicate blocking operations that need to be offloaded to tokio::task::spawn_blocking.
Common Optimization Strategies
Once you have identified bottlenecks through profiling, you can apply targeted optimizations. The following sections cover the most impactful strategies for Rust APIs.
1. Reducing Allocations
Heap allocations are expensive, especially in hot paths. Rust's String, Vec, and Box all allocate on the heap. In request handlers that run thousands of times per second, even small allocations add up. Strategies include:
- Using
&strinstead ofStringwhere lifetimes permit. - Reusing buffers with
Vec::clear()andVec::reserve()instead of creating new vectors. - Using
SmallVecorArrayVecfor collections with a known small maximum size. - Avoiding
format!()in hot paths; usewrite!into a reusable buffer. - Using
Arc<str>instead ofArc<String>to save one level of indirection.
Here is an example of reducing allocations in a JSON response handler:
use axum::{routing::get, Json, Router};
use serde::Serialize;
// Before: allocates a new String for every request
#[derive(Serialize)]
struct ResponseV1 {
message: String,
}
async fn handler_v1() -> Json<ResponseV1> {
Json(ResponseV1 {
message: "Hello, World!".to_string(),
})
}
// After: uses a static string slice, zero allocations
#[derive(Serialize)]
struct ResponseV2<'a> {
message: &'a str,
}
async fn handler_v2() -> Json<ResponseV2<'static>> {
Json(ResponseV2 {
message: "Hello, World!",
})
}
pub fn app() -> Router {
Router::new()
.route("/v1", get(handler_v1))
.route("/v2", get(handler_v2))
}
The second handler avoids a heap allocation entirely. For a handler returning a static message, this is a clear win. For dynamic data, consider using a thread-local buffer or an arena allocator.
2. Optimizing Serialization
Serialization is often the single largest CPU consumer in JSON APIs. serde_json is excellent, but there are faster alternatives and configuration options worth considering:
// Cargo.toml
// [dependencies]
// serde = { version = "1", features = ["derive"] }
// serde_json = "1"
// simd-json = "0.13" // SIMD-accelerated JSON parsing
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct Product {
id: u64,
name: String,
price: f64,
tags: Vec<String>,
}
// Standard serde_json
fn parse_standard(input: &[u8]) -> Result<Product, serde_json::Error> {
serde_json::from_slice(input)
}
// SIMD-accelerated parsing (requires aligned input)
fn parse_simd(input: &mut [u8]) -> Result<Product, simd_json::Error> {
simd_json::from_slice(input)
}
// For responses, consider raw pre-serialized JSON for static or cached data
use axum::response::IntoResponse;
use axum::http::header;
async fn cached_product() -> impl IntoResponse {
// Pre-serialized JSON stored in memory or Redis
let cached: &'static [u8] = br#"{"id":1,"name":"Widget","price":9.99,"tags":["sale"]}"#;
(
[(header::CONTENT_TYPE, "application/json")],
cached,
)
}
For endpoints that return the same data repeatedly (like configuration or static catalogs), caching the serialized bytes eliminates both serialization and allocation overhead entirely.
3. Avoiding Blocking in Async Contexts
Blocking operations in async handlers are a silent performance killer. The Tokio runtime uses a fixed number of worker threads (default: number of CPU cores). If one worker is blocked, the runtime's capacity drops. Common blocking operations include:
- Synchronous file I/O (
std::fs) - CPU-bound computations (hashing, compression, parsing large payloads)
- Blocking library calls (some database drivers, legacy C bindings)
std::thread::sleep
Use spawn_blocking to offload these operations to a dedicated thread pool:
use axum::{routing::post, Json, Router};
use serde::{Deserialize, Serialize};
use sha2::{Sha256, Digest};
#[derive(Deserialize)]
struct HashRequest {
data: String,
}
#[derive(Serialize)]
struct HashResponse {
hash: String,
}
async fn hash_handler(Json(req): Json<HashRequest>) -> Json<HashResponse> {
// BAD: This blocks the async runtime for large inputs
// let mut hasher = Sha256::new();
// hasher.update(req.data.as_bytes());
// let result = hasher.finalize();
// GOOD: Offload CPU-bound work to the blocking thread pool
let hash = tokio::task::spawn_blocking(move || {
let mut hasher = Sha256::new();
hasher.update(req.data.as_bytes());
let result = hasher.finalize();
format!("{:x}", result)
})
.await
.expect("blocking task panicked");
Json(HashResponse { hash })
}
pub fn app() -> Router {
Router::new().route("/hash", post(hash_handler))
}
For database access, use async drivers like sqlx or deadpool-postgres rather than synchronous drivers wrapped in spawn_blocking. Async-native drivers integrate with the runtime's I/O reactor and avoid thread pool overhead.
4. Connection Pooling
Database connection establishment is expensive. A well-tuned connection pool is critical for API throughput. With sqlx and deadpool, the key parameters are pool size, timeout, and idle timeout:
// Cargo.toml
// [dependencies]
// sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls"] }
// tokio = { version = "1", features = ["full"] }
// axum = "0.7"
use sqlx::postgres::PgPoolOptions;
use axum::{routing::get, Router, extract::State};
use std::time::Duration;
#[derive(Clone)]
struct AppState {
pool: sqlx::PgPool,
}
async fn get_count(State(state): State<AppState>) -> String {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&state.pool)
.await
.unwrap();
format!("User count: {}", row.0)
}
#[tokio::main]
async fn main() {
let pool = PgPoolOptions::new()
.max_connections(20) // Tune based on DB capacity
.min_connections(5) // Keep warm connections ready
.acquire_timeout(Duration::from_secs(3)) // Fail fast under load
.idle_timeout(Duration::from_secs(600)) // Close stale connections
.max_lifetime(Duration::from_secs(1800)) // Prevent long-lived conns
.connect("postgres://user:pass@localhost/db")
.await
.unwrap();
let state = AppState { pool };
let app = Router::new()
.route("/count", get(get_count))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
The optimal pool size depends on your database's capacity and your API's concurrency model. A common starting point is 2-3 connections per CPU core on the database server. Too many connections can degrade database performance due to context switching overhead.
5. Caching with In-Memory Stores
For read-heavy endpoints, caching can deliver order-of-magnitude improvements. The moka crate provides a high-performance concurrent cache inspired by Caffeine:
// Cargo.toml
// [dependencies]
// moka = { version = "0.12", features = ["future"] }
// axum = "0.7"
// serde = { version = "1", features = ["derive"] }
// sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls"] }
use moka::future::Cache;
use axum::{routing::get, Router, extract::{State, Path}, Json};
use serde::Serialize;
use std::time::Duration;
#[derive(Clone, Serialize)]
struct User {
id: i64,
name: String,
email: String,
}
#[derive(Clone)]
struct AppState {
cache: Cache<i64, Arc<User>>,
pool: sqlx::PgPool,
}
use std::sync::Arc;
async fn get_user(
Path(id): Path<i64>,
State(state): State<AppState>,
) -> Result<Json<Arc<User>>, String> {
// Try cache first
if let Some(user) = state.cache.get(&id).await {
return Ok(Json(user));
}
// Cache miss: query database
let user = sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE id = $1",
id
)
.fetch_optional(&state.pool)
.await
.map_err(|e| e.to_string())?
.ok_or("User not found".to_string())?;
let user = Arc::new(user);
state.cache.insert(id, Arc::clone(&user)).await;
Ok(Json(user))
}
#[tokio::main]
async fn main() {
let cache = Cache::builder()
.max_capacity(10_000)
.time_to_live(Duration::from_secs(300))
.time_to_idle(Duration::from_secs(60))
.build();
let pool = sqlx::PgPool::connect("postgres://user:pass@localhost/db")
.await
.unwrap();
let state = AppState { cache, pool };
let app = Router::new()
.route("/users/:id", get(get_user))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Using Arc<User> in the cache means the cached value is cheap to clone and share across concurrent requests. The TTL and idle eviction policies ensure the cache does not serve stale data indefinitely or grow unbounded.
6. Tuning the Tokio Runtime
The default Tokio runtime configuration works well for many cases, but production APIs often benefit from explicit tuning:
use tokio::runtime::Builder;
#[tokio::main]
async fn main() {
// The default #[tokio::main] uses a multi-threaded runtime
// with worker threads equal to the number of CPU cores.
// For more control, build the runtime manually:
let runtime = Builder::new_multi_thread()
.worker_threads(num_cpus::get())
.max_blocking_threads(512) // Thread pool for spawn_blocking
.thread_name("api-worker")
.thread_stack_size(2 * 1024 * 1024) // 2 MB stack per thread
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
// Your server startup code here
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
// axum::serve(listener, app).await.unwrap();
});
}
Key considerations:
- Worker threads: Usually equal to CPU cores. More threads do not help CPU-bound work and can hurt due to context switching.
- Blocking threads: Increase if you use
spawn_blockingheavily. The default of 512 is usually sufficient. - Stack size: The default 2 MB is fine for most cases. Deeply recursive algorithms may need more.
Best Practices for Ongoing Performance
Build a Benchmark Suite
Use criterion to create benchmarks for critical functions. Run them in CI to catch performance regressions:
// benches/api_bench.rs
// [dev-dependencies]
// criterion = { version = "0.5", features = ["async_tokio"] }
use criterion::{criterion_group, criterion_main, Criterion, BenchmarkId};
use criterion::async_executor::TokioExecutor;
fn bench_serialization(c: &mut Criterion) {
let data = vec!["item1", "item2", "item3", "item4", "item5"];
let mut group = c.benchmark_group("serialization");
group.bench_function("serde_json", |b| {
b.to_async(TokioExecutor).iter(|| async {
serde_json::to_string(&data).unwrap()
});
});
group.bench_function("serde_json_to_writer", |b| {
b.to_async(TokioExecutor).iter(|| async {
let mut buf = Vec::with_capacity(128);
serde_json::to_writer(&mut buf, &data).unwrap();
buf
});
});
group.finish();
}
criterion_group!(benches, bench_serialization);
criterion_main!(benches);
Run with cargo bench and commit the baseline results. Criterion automatically compares runs and flags statistically significant regressions.
Compile with Optimizations
For production builds, enable Link Time Optimization and codegen optimizations:
# Cargo.toml
[profile.release]
opt-level = 3
lto = "fat" # Full LTO across all crates
codegen-units = 1 # Better optimization at cost of compile time
panic = "abort" # Smaller binary, no unwinding overhead
strip = true # Strip debug symbols from binary
# For profiling builds, keep debug info but still optimize:
[profile.profiling]
inherits = "release"
debug = true
strip = false
Note that panic = "abort" changes error handling semantics — ensure your application does not rely on unwinding for cleanup. For libraries, keep the default panic strategy.
Monitor in Production
Profiling in development is necessary but not sufficient. Production traffic patterns differ from synthetic benchmarks. Integrate metrics collection with prometheus or metrics crates:
// Cargo.toml
// [dependencies]
// metrics = "0.23"
// metrics-exporter-prometheus = "0.15"
// axum = "0.7"
// tokio = { version = "1", features = ["full"] }
use axum::{routing::get, Router, middleware::{self, Next}, extract::Request, response::Response};
use std::time::Instant;
async fn metrics_middleware(req: Request, next: Next) -> Response {
let start = Instant::now();
let path = req.uri().path().to_string();
let method = req.method().to_string();
let response = next.run(req).await;
let duration = start.elapsed().as_secs_f64();
metrics::counter!("api_requests_total",
"method" => method.clone(),
"path" => path.clone(),
"status" => response.status().as_u16().to_string()
).increment(1);
metrics::histogram!("api_request_duration_seconds",
"method" => method,
"path" => path
).record(duration);
response
}
#[tokio::main]
async fn main() {
let builder = metrics_exporter_prometheus::PrometheusBuilder::new();
let handle = builder.install_recorder().unwrap();
let app = Router::new()
.route("/health", get(|| async { "ok" }))
.route("/metrics", get(move || std::future::ready(handle.render())))
.layer(middleware::from_fn(metrics_middleware));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Track these key metrics over time:
- Request latency percentiles (p50, p90, p99, p99.9)
- Requests per second
- Error rate by endpoint
- Database query latency
- Cache hit/miss ratio
- Memory usage and allocation rate
Conclusion
Optimizing a Rust API is an iterative process grounded in measurement. Start by establishing a baseline with load testing, then use profiling tools — flame graphs, tokio-console, and in-process profilers — to identify where time is actually spent. Apply targeted optimizations: reduce allocations in hot paths, choose efficient serialization strategies, keep async handlers non-blocking, tune connection pools, and cache aggressively where data permits. Compile with appropriate optimization flags, build a benchmark suite to catch regressions, and monitor production metrics to ensure optimizations hold under real traffic. The discipline of measure-optimize-measure, combined with Rust's performance characteristics, will help you build APIs that are not just fast by reputation but fast by evidence.