Rust-specific patterns that come up repeatedly when building web applications with this stack. This section focuses on the Rust angle: ownership patterns in request handlers, async pitfalls, linting configuration, and dependency decisions. Topics that have dedicated sections elsewhere (Tower middleware, database performance, project structure) are summarised here with links to the full treatment.
Ownership and borrowing in web contexts
Shared application state
Axum handlers receive shared application state through the State extractor. Since multiple handlers run concurrently, the state must be wrapped in Arc:
use std::sync::Arc;
use axum::extract::State;
struct AppState {
db: sqlx::PgPool,
config: AppConfig,
}
async fn list_contacts(
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
let contacts = sqlx::query_as!(Contact, "SELECT * FROM contacts")
.fetch_all(&state.db)
.await?;
// ...
}
Register the state once when building the router:
let state = Arc::new(AppState { db: pool, config });
let app = Router::new()
.route("/contacts", get(list_contacts))
.with_state(state);
Use State for application-wide data (database pools, configuration, service handles). Use Extension only for request-scoped data injected by middleware, such as the authenticated user. State is type-safe at compile time; a missing .with_state() call produces a compiler error. Extension is not: a missing .layer(Extension(...)) compiles but panics at runtime.
Extractors give you owned data
Axum extractors (Path, Query, Form, Json) deserialise into owned types. Handlers receive owned String, Vec<T>, and struct fields. This matches the request lifecycle: each request is independent, so its data is naturally owned by the handler that processes it.
The practical consequence is that you rarely fight the borrow checker inside handlers. Borrowing becomes relevant for intermediate operations within the handler body, not for the handler’s inputs and outputs.
The extractor ordering rule
Extractors that consume the request body (Form, Json, Bytes, String, Multipart) implement FromRequest. Only one body-consuming extractor can appear per handler, and it must be the last parameter. Non-body extractors (Path, Query, State, HeaderMap) implement FromRequestParts and can appear in any order.
// Correct: State and Path before Form (body-consuming)
async fn update_contact(
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
Form(data): Form<ContactForm>,
) -> impl IntoResponse {
// ...
}When you need mutability
Mutable shared state is uncommon in web applications. Database pools handle their own synchronisation internally. Configuration is read-only after startup. If you genuinely need mutable shared state, choose based on whether the lock crosses an .await:
- No
.awaitwhile holding the lock: Usestd::sync::Mutex(orparking_lot::Mutex). Cheaper and simpler. - Must hold across
.await: Usetokio::sync::Mutex. This is rare and usually a sign the design can be restructured.
// Short synchronous critical section — std::sync::Mutex is correct
let counter = state.counter.lock().unwrap();
let value = *counter + 1;
drop(counter); // Release before any .await
Holding a std::sync::Mutex guard across .await produces a !Send future that the compiler will reject. This is the compiler protecting you from a deadlock.
If you find yourself reaching for Arc<Mutex<T>> frequently, consider whether a channel-based design or a database-backed approach is more appropriate.
Clone discipline
Developers coming from garbage-collected languages tend to .clone() defensively. In web handlers, most data is already owned, so cloning is less necessary than it first appears.
Clone when you need a second owner. Do not clone to satisfy the borrow checker when restructuring the code would eliminate the need. Arc::clone is cheap (an atomic increment). Cloning a Vec<String> with thousands of elements is not.
For shared read-only data, wrap in Arc rather than cloning. For values that are sometimes borrowed and sometimes owned, use Cow<'a, str>.
Async Rust pitfalls
Axum runs on the Tokio multi-threaded runtime. The runtime spawns one worker thread per CPU core and uses cooperative scheduling: tasks must yield at .await points. Understanding this model prevents the most common async mistakes.
Blocking the runtime
The single most damaging mistake in async Rust web applications. If you block a runtime thread with synchronous work, every other task scheduled on that thread stalls. With 4 worker threads and each request blocking for 100ms, throughput caps at 40 requests per second regardless of how many tasks are queued.
Blocking operations include:
- Synchronous file I/O (
std::fs) - CPU-intensive computation (image processing, password hashing, compression)
std::thread::sleep- Any third-party library call that does not return a future
Wrap blocking work with tokio::task::spawn_blocking:
use tokio::task;
async fn hash_password(password: String) -> Result<String, anyhow::Error> {
task::spawn_blocking(move || {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)?
.to_string();
Ok(hash)
})
.await?
}
spawn_blocking moves the work to a dedicated thread pool that does not interfere with the async runtime.
Holding guards across .await
Any RAII guard (mutex lock, database transaction handle, file handle) held across an .await point blocks the resource for the entire time the task is suspended. Other tasks waiting for that resource stall.
// Wrong: lock held across .await
let mut data = state.cache.lock().unwrap();
let result = fetch_from_db(&state.db).await; // Task suspends here, lock held
data.insert(key, result);
// Right: drop the lock before awaiting
let needs_fetch = {
let data = state.cache.lock().unwrap();
!data.contains_key(&key)
};
if needs_fetch {
let result = fetch_from_db(&state.db).await;
let mut data = state.cache.lock().unwrap();
data.insert(key, result);
}
Scope guards tightly. Drop them before .await.
Send bounds
Axum handlers must return Send futures because the multi-threaded runtime moves tasks between threads. The most common way to produce a !Send future is holding a !Send type (like an Rc or a std::sync::MutexGuard) across an .await. The compiler error messages for this are notoriously unhelpful, but the fix is almost always: restructure so the !Send value is dropped before the .await.
Task starvation
A long-running CPU-bound loop inside an async task starves all other tasks on that thread. Unlike Go’s goroutines, Rust async tasks do not pre-empt. They must voluntarily yield.
For loops that do significant work per iteration, either move the entire operation to spawn_blocking or insert periodic yields:
for item in large_collection {
process(item);
tokio::task::yield_now().await;
}Cancellation safety
When tokio::select! resolves one branch, all other futures are dropped immediately. If a dropped future was partway through writing data or accumulating state, that work is lost. Use cancel-safe primitives (tokio channels, tokio::time::interval) and keep critical state outside futures that participate in select!.
async fn in traits
Native async fn in traits landed in Rust 1.75 (December 2023). Use it where possible. The limitation: native async trait methods are not dyn-compatible. If you need dyn Trait with async methods, the async-trait crate is still required.
Tower middleware
Tower middleware is covered in detail in the Web Server with Axum section. The key patterns for day-to-day use:
Execution order
Requests flow through layers outside-in; responses return inside-out. With Router::layer(), middleware executes bottom-to-top (the last .layer() call runs first on requests). With tower::ServiceBuilder, middleware executes top-to-bottom. This inversion is a common source of confusion.
Three approaches to custom middleware
axum::middleware::from_fn: Write an async function. No Tower boilerplate. Use this for application-specific concerns (auth checks, request logging, header injection).- Implement
Layer+Service: Full Tower machinery. Needed when you must wrap the response body or share middleware across non-Axum services. tower-httpcrates: For standard concerns (tracing, compression, CORS, timeouts, request IDs), always usetower-http. Do not reimplement these.
Clone requirement
Services must be Clone because Axum clones them for concurrent request handling. Wrap middleware state in Arc to make cloning cheap.
Dependency management
Choosing when to pull in a crate and when to implement yourself is a recurring decision. The general principle: use crates for complex, security-sensitive, or system-level concerns; implement trivial operations yourself.
Use a crate when
- Security is involved: Cryptography, TLS, password hashing, authentication. Getting these wrong has real consequences, and the ecosystem crates (
argon2,rustls,jsonwebtoken) are well-audited. - The problem is complex and well-solved: Serialisation (
serde), async runtime (tokio), HTTP (hyper/axum), database access (sqlx). These represent thousands of hours of work and extensive testing. - Unsafe code is required: Crates that isolate
unsafebehind a safe API (database drivers, system interfaces) are doing work you should not duplicate.
Implement yourself when
- The operation is trivial: A few lines of string manipulation or data transformation do not justify a dependency. If you need one function from a crate that brings in 30 transitive dependencies, write the function.
- It is glue code between your types: Conversion traits, domain-specific validation, serialisation adapters between your own types belong in your codebase.
- The crate is heavier than your need: Check
cargo treeto see what a crate pulls in. Prefer lighter alternatives when they exist (futures-liteoverfutures,ureqoverreqwestif you do not need async).
Evaluating crates
Check maintenance status (last release, open issues), transitive dependency count (cargo tree -d), and reverse dependencies on crates.io. Use --no-default-features and enable only what you need. Run cargo-deny in CI to check licenses, known advisories, and duplicate versions. For high-security applications, cargo-vet provides formal dependency auditing.
The rust-deps skill in Claude Code provides crate-specific guidance when you need it.
Performance
Rust web application performance is covered in depth in Web Application Performance. The Rust-specific patterns that matter most:
Where Rust helps automatically
- No garbage collection pauses: Consistent p99 latency under sustained load. Memory is freed deterministically at scope boundaries.
- Low per-request overhead: No interpreter, no JIT warmup. The first request is as fast as the millionth.
- Fast serialisation: Serde significantly outperforms JSON libraries in most other languages.
Where Rust does not help
The database is almost always the bottleneck in CRUD applications. Rust’s speed does not compensate for missing indexes, N+1 query patterns, or an undersised connection pool. Run EXPLAIN ANALYZE before optimising Rust code.
Common Rust-specific performance mistakes
- Excessive cloning in hot paths. Use
Arcfor shared read-only data,Cow<'a, str>for borrow-or-own scenarios, andVec::with_capacity()when the size is known. - Blocking the async runtime (see above). A single blocking call degrades throughput for all concurrent requests.
- Connection pool exhaustion. SQLx pools have a limited number of connections. Under load, handlers queue waiting. Size the pool appropriately (a starting point: 2x CPU cores) and monitor pool wait times.
Profiling tools
tokio-console: Real-time async task visualisation. Invaluable for diagnosing task starvation and blocked tasks.cargo flamegraph: CPU profiling with flame graph output.dhat: Heap allocation profiling.
Measure before optimising. The most common performance problems are architectural (blocking the runtime, slow queries, pool sizing) rather than language-level (clone costs, allocation patterns).
Code organisation
Project structure is covered in Project Structure. The conventions relevant to daily coding:
Module organisation within a crate
src/
├── handlers/ # Axum handler functions, one file per resource
├── models/ # Domain types, DTOs
├── db/ # Database access functions
├── errors.rs # Crate-specific error types
├── lib.rs # Public API re-exports
└── main.rs # Binary entry point (if applicable)Route organisation
Each feature module exposes a routes() function. Compose them with Router::nest():
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/", get(list_contacts).post(create_contact))
.route("/{id}", get(show_contact).put(update_contact))
.route("/{id}/delete", post(delete_contact))
}// In main router assembly
let app = Router::new()
.nest("/contacts", contacts::routes())
.nest("/invoices", invoices::routes())
.with_state(state);When to split into a new crate
Split when you have a clear boundary: a different domain, a different dependency set, or when compilation time for the crate becomes painful. A single crate with well-organised modules is preferable to many tiny crates with unclear boundaries.
Clippy lints and formatting
Workspace-level lint configuration
Configure lints once in the workspace root Cargo.toml. Each member crate opts in with [lints] workspace = true, giving a single source of truth for the entire project.
# Root Cargo.toml
[workspace.lints.clippy]
# Enable pedantic as warnings — selectively suppress noisy lints per crate
pedantic = { level = "warn", priority = -1 }
# Deny patterns that indicate bugs or incomplete code
unwrap_used = "deny"
expect_used = "deny"
panic = "deny"
dbg_macro = "deny"
todo = "deny"
print_stdout = "deny"
print_stderr = "deny"
# Async safety
await_holding_lock = "deny"
large_futures = "warn"
# Prevent suppressing lints with #[allow(...)]
allow_attributes = "deny"
[workspace.lints.rust]
unsafe_code = "deny"# Each member crate's Cargo.toml
[lints]
workspace = trueWhy deny unwrap, expect, and panic
These three cause the process to abort on failure. In a request handler, that takes down the entire server. Denying them forces proper error handling with ? and explicit error types. This is especially valuable when AI coding agents generate code, as they frequently reach for .unwrap() as the path of least resistance.
The strict deny applies everywhere, including tests and startup. In tests, replace .unwrap() with .expect("test: reason") or return Result from the test function. In main(), use .expect("fatal: reason") with a #[expect(clippy::expect_used)] annotation (see below).
Prefer #[expect] over #[allow]
When you genuinely need to suppress a lint, use #[expect(clippy::lint_name)] instead of #[allow(clippy::lint_name)]. The difference: #[expect] triggers a warning if the lint it suppresses is no longer produced. This means suppression annotations do not silently outlive their usefulness.
// Good: if the unwrap is later removed, the compiler warns that this
// #[expect] is unnecessary, prompting you to clean it up
#[expect(clippy::expect_used, reason = "fatal if database URL is missing")]
fn main() {
let db_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
}
// Bad: #[allow] stays forever, silently suppressing the lint
// even after the code it was meant to cover has changed
#[allow(clippy::expect_used)]
fn main() { /* ... */ }
The allow_attributes = "deny" lint in the workspace configuration enforces this. Any #[allow(...)] produces a compiler error, requiring #[expect(...)] instead. This is particularly effective when working with AI coding agents, which tend to silence errors with #[allow] rather than fixing the underlying issue.
Commonly suppressed pedantic lints
The pedantic group is worth enabling but includes lints that produce noise in web application code. Suppress these per crate as needed:
# In a member crate's Cargo.toml if needed
[lints.clippy]
module_name_repetitions = "allow" # contacts::ContactHandler is natural
must_use_candidate = "allow" # Noisy for handler return types
missing_errors_doc = "allow" # Useful for libraries, excessive for app code
missing_panics_doc = "allow" # Redundant when panics are deniedclippy.toml
Create a clippy.toml at the workspace root for threshold-based configuration:
cognitive-complexity-threshold = 15
type-complexity-threshold = 200
too-many-lines-threshold = 100rustfmt
Use default rustfmt settings. The value of consistent formatting across the Rust ecosystem outweighs individual preferences. A minimal rustfmt.toml if needed:
edition = "2021"
max_width = 100
use_field_init_shorthand = trueCI integration
Run both in CI to enforce standards:
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
The -D warnings flag promotes all warnings to errors, so lint violations fail the build.
Gotchas
- Axum’s
#[debug_handler]produces clear compiler errors when handler signatures are wrong. Use it during development, remove it before release. - Turbofish syntax (
::<Type>) is needed more often than you might expect with SQLx queries. Whenquery_ascannot infer the output type, be explicit:query_as::<_, Contact>(...). - Feature flag accumulation: Cargo features are additive and cannot be disabled by dependents. If crate A enables
tokio/fulland crate B only needstokio/rt, the workspace getstokio/full. Audit features withcargo tree -f '{p} {f}'. - Compilation times: Rust compilation is slow. Use
cargo-watchfor incremental rebuilds, themoldlinker on Linux (or the default linker on macOS, which is already fast), and split your workspace into crates so only changed code recompiles. - Error messages from trait bounds: When Axum rejects a handler, the error often refers to missing trait implementations deep in Tower’s type system. Enable
#[debug_handler]first. If the error persists, check that all extractors are in the right order and that the return type implementsIntoResponse. impl IntoResponsehides the type: Returningimpl IntoResponsefrom handlers is ergonomic but means you cannot name the return type elsewhere. If you need to store handlers in a collection or return them from a function, useaxum::response::Responseas the concrete type.