What are the trade-offs between axum::Router::fallback and fallback_service for handling unmatched routes?

Router::fallback accepts a handler function that can use extractors and return a response, while fallback_service accepts any Service implementation for more control over request handling, including access to the original Request body. The key distinction is that fallback is simpler and integrates with axum's extractor ecosystem, whereas fallback_service provides lower-level access to the raw HTTP request but requires more boilerplate and doesn't automatically benefit from axum's extractor conveniences.

Fallback Handlers for Unmatched Routes

use axum::{
    Router,
    routing::get,
    response::IntoResponse,
    http::StatusCode,
};
 
async fn not_found() -> impl IntoResponse {
    (StatusCode::NOT_FOUND, "Custom 404: Page not found")
}
 
let app = Router::new()
    .route("/", get(|| async { "Home" }))
    .route("/users", get(|| async { "Users" }))
    .fallback(not_found);  // Handles all unmatched routes
 
// Any route not "/" or "/users" calls not_found()
// GET /unknown -> not_found()
// GET /users/123 -> not_found()
// POST /anything -> not_found()

Fallback handlers catch all requests that don't match any defined route.

The fallback Method

use axum::{
    Router,
    routing::get,
    response::IntoResponse,
    http::StatusCode,
    extract::Path,
};
 
// fallback accepts async handler functions
async fn fallback_handler() -> impl IntoResponse {
    (StatusCode::NOT_FOUND, "404 - Not Found")
}
 
async fn fallback_with_path(Path(path): Path<String>) -> impl IntoResponse {
    (StatusCode::NOT_FOUND, format!("Path '{}' not found", path))
}
 
async fn fallback_with_status() -> impl IntoResponse {
    // Can use any extractors like normal handlers
    (StatusCode::NOT_FOUND, "Not found")
}
 
let app = Router::new()
    .route("/", get(|| async { "Home" }))
    .fallback(fallback_handler);

fallback takes an async handler function that can use extractors.

The fallback_service Method

use axum::{
    Router,
    routing::get,
    body::Body,
    http::{Request, Response, StatusCode},
};
use tower::ServiceExt;
use std::convert::Infallible;
 
// fallback_service accepts any Service
async fn fallback_service(
    req: Request<Body>,
) -> Result<Response<Body>, Infallible> {
    // Full access to the original request
    let path = req.uri().path();
    let method = req.method();
    
    Ok(Response::builder()
        .status(StatusCode::NOT_FOUND)
        .body(format!("{} {} not found", method, path).into())
        .unwrap())
}
 
let app = Router::new()
    .route("/", get(|| async { "Home" }))
    .fallback_service(tower::service_fn(fallback_service));

fallback_service takes a Service that receives the full Request<Body>.

Key Difference: Extractors vs Raw Request

use axum::{
    Router,
    routing::get,
    response::IntoResponse,
    extract::{Path, Extension, Query},
    http::{Request, StatusCode},
    body::Body,
};
use serde::Deserialize;
 
#[derive(Deserialize)]
struct Params {
    page: Option<i32>,
}
 
// fallback: Can use extractors
async fn fallback_with_extractors(
    Path(path): Path<String>,
    Query(params): Query<Params>,
) -> impl IntoResponse {
    (
        StatusCode::NOT_FOUND,
        format!("Path: {}, Page: {:?}", path, params.page),
    )
}
 
// fallback_service: Raw request, extract manually
async fn fallback_service_raw(
    req: Request<Body>,
) -> Result<Response<Body>, Infallible> {
    let path = req.uri().path().to_string();
    
    // Must parse query string manually
    let query = req.uri().query().unwrap_or("");
    
    Ok(Response::builder()
        .status(StatusCode::NOT_FOUND)
        .body(format!("Path: {}, Query: {}", path, query).into())
        .unwrap())
}
 
// fallback is simpler when you want extractors
// fallback_service when you need raw request access

fallback integrates with extractors; fallback_service gives raw request access.

Access to Request Body

use axum::{
    Router,
    routing::post,
    body::Body,
    http::{Request, Response, StatusCode},
    response::IntoResponse,
};
use tower::ServiceExt;
 
// fallback: Body is consumed by extractors, limited access
async fn fallback_handler() -> impl IntoResponse {
    // Cannot access request body here directly
    // Would need a body extractor, but it's already consumed
    (StatusCode::NOT_FOUND, "Not found")
}
 
// fallback_service: Full access to request body
async fn fallback_with_body(
    req: Request<Body>,
) -> Result<Response<Body>, Infallible> {
    // Can read the body if needed
    let method = req.method();
    let path = req.uri().path();
    
    // Note: To read body, you'd need to collect it
    // This example just uses headers
    
    Ok(Response::builder()
        .status(StatusCode::NOT_FOUND)
        .body(format!("{} {} not found", method, path).into())
        .unwrap())
}
 
let app = Router::new()
    .route("/api", post(|| async { "API" }))
    .fallback_service(tower::service_fn(fallback_with_body));

fallback_service provides access to the request body; fallback handlers receive the body through extractors.

Using Tower Services Directly

use axum::{
    Router,
    routing::get,
    body::Body,
    http::{Request, Response, StatusCode},
};
use tower::{ServiceBuilder, Service};
use std::task::{Context, Poll};
use std::future::Ready;
 
// Custom Tower service for fallback
struct NotFoundService;
 
impl Service<Request<Body>> for NotFoundService {
    type Response = Response<Body>;
    type Error = Infallible;
    type Future = Ready<Result<Response<Body>, Infallible>>;
 
    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        Poll::Ready(Ok(()))
    }
 
    fn call(&mut self, req: Request<Body>) -> Self::Future {
        let path = req.uri().path().to_string();
        let method = req.method().to_string();
        
        let response = Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body(format!("{} {} not found", method, path).into())
            .unwrap();
        
        std::future::ready(Ok(response))
    }
}
 
let app = Router::new()
    .route("/", get(|| async { "Home" }))
    .fallback_service(NotFoundService);

fallback_service can accept any Service implementation, including custom services.

Integration with Tower Middleware

use axum::{
    Router,
    routing::get,
    body::Body,
    http::{Request, Response, StatusCode},
};
use tower::{
    ServiceBuilder,
    service_fn,
    layer::Layer,
    ServiceExt,
};
use tower_http::{trace::TraceLayer, compression::CompressionLayer};
 
// fallback_service can have middleware applied
let fallback = ServiceBuilder::new()
    .layer(TraceLayer::new_for_http())
    .layer(CompressionLayer::new())
    .service(service_fn(|req: Request<Body>| async {
        Ok(Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body("Not found".into())
            .unwrap())
    }));
 
let app = Router::new()
    .route("/", get(|| async { "Home" }))
    .fallback_service(fallback);

fallback_service integrates with Tower middleware via ServiceBuilder.

Simplicity Comparison

use axum::{
    Router,
    routing::get,
    response::IntoResponse,
    http::{Request, StatusCode},
    body::Body,
};
use tower::service_fn;
 
// fallback: Simple for basic handlers
async fn simple_fallback() -> impl IntoResponse {
    (StatusCode::NOT_FOUND, "Not found")
}
 
let app1 = Router::new()
    .route("/", get(|| async { "Home" }))
    .fallback(simple_fallback);  // One line, easy
 
// fallback_service: More boilerplate for same result
async fn service_fallback(
    req: Request<Body>,
) -> Result<Response<Body>, Infallible> {
    Ok(Response::builder()
        .status(StatusCode::NOT_FOUND)
        .body("Not found".into())
        .unwrap())
}
 
let app2 = Router::new()
    .route("/", get(|| async { "Home" }))
    .fallback_service(service_fn(service_fallback));  // More code

For simple cases, fallback is more concise.

Use Case: JSON Error Responses

use axum::{
    Router,
    routing::get,
    response::IntoResponse,
    http::StatusCode,
    Json,
};
use serde_json::json;
 
// fallback: Easy JSON response
async fn json_fallback() -> impl IntoResponse {
    (
        StatusCode::NOT_FOUND,
        Json(json!({
            "error": "not_found",
            "message": "The requested resource was not found"
        })),
    )
}
 
let app = Router::new()
    .route("/api", get(|| async { "API" }))
    .fallback(json_fallback);
 
// fallback_service: Manual JSON construction
async fn json_service_fallback(
    req: Request<Body>,
) -> Result<Response<Body>, Infallible> {
    let body = json!({
        "error": "not_found",
        "message": format!("Path {} not found", req.uri().path())
    }).to_string();
    
    Ok(Response::builder()
        .status(StatusCode::NOT_FOUND)
        .header("content-type", "application/json")
        .body(body.into())
        .unwrap())
}

fallback integrates with Json extractor; fallback_service requires manual construction.

Use Case: Request Inspection

use axum::{
    Router,
    routing::get,
    body::Body,
    http::{Request, Response, StatusCode, Method},
};
use tower::service_fn;
 
// fallback_service: Full request access
async fn inspect_fallback(
    req: Request<Body>,
) -> Result<Response<Body>, Infallible> {
    let method = req.method();
    let path = req.uri().path();
    let headers = req.headers();
    
    // Log the full request details
    tracing::info!(
        method = ?method,
        path = %path,
        headers = ?headers,
        "Unhandled request"
    );
    
    // Return different responses based on method
    match method {
        &Method::GET => Ok(Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body("Page not found".into())
            .unwrap()),
        &Method::POST => Ok(Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body("Endpoint not found".into())
            .unwrap()),
        _ => Ok(Response::builder()
            .status(StatusCode::METHOD_NOT_ALLOWED)
            .body("Method not allowed".into())
            .unwrap()),
    }
}
 
let app = Router::new()
    .route("/", get(|| async { "Home" }))
    .fallback_service(service_fn(inspect_fallback));

fallback_service enables detailed request inspection for logging or conditional responses.

Method-Based Fallback Handling

use axum::{
    Router,
    routing::{get, post},
    response::IntoResponse,
    http::StatusCode,
};
 
// fallback: Can route based on method using extractors
async fn method_fallback(
    method: axum::http::Method,
) -> impl IntoResponse {
    match method {
        axum::http::Method::GET => {
            (StatusCode::NOT_FOUND, "Page not found")
        }
        axum::http::Method::POST => {
            (StatusCode::NOT_FOUND, "Endpoint not found")
        }
        _ => {
            (StatusCode::METHOD_NOT_ALLOWED, "Method not allowed")
        }
    }
}
 
let app = Router::new()
    .route("/", get(|| async { "Home" }))
    .fallback(method_fallback);

fallback can still use extractors like Method for conditional handling.

Sharing State with Fallbacks

use axum::{
    Router,
    routing::get,
    response::IntoResponse,
    http::StatusCode,
    extract::State,
};
use std::sync::Arc;
 
struct AppState {
    version: String,
}
 
// fallback: State extractor works automatically
async fn fallback_with_state(
    State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
    (
        StatusCode::NOT_FOUND,
        format!("Not found (API version: {})", state.version),
    )
}
 
let state = Arc::new(AppState {
    version: "1.0".to_string(),
});
 
let app = Router::new()
    .route("/", get(|| async { "Home" }))
    .fallback(fallback_with_state)
    .with_state(state);

fallback integrates with state extraction; fallback_service requires manual state handling.

State with fallback_service

use axum::{
    Router,
    routing::get,
    body::Body,
    http::{Request, Response, StatusCode},
    extract::State,
};
use std::sync::Arc;
use tower::service_fn;
 
struct AppState {
    version: String,
}
 
// fallback_service: Must handle state manually
async fn fallback_service_with_state(
    state: Arc<AppState>,
) -> impl Fn(Request<Body>) -> Result<Response<Body>, Infallible> {
    move |req: Request<Body>| {
        let path = req.uri().path();
        Ok(Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body(format!("{} not found (v{})", path, state.version).into())
            .unwrap())
    }
}
 
let state = Arc::new(AppState {
    version: "1.0".to_string(),
});
 
let app = Router::new()
    .route("/", get(|| async { "Home" }))
    .fallback_service(service_fn({
        let state = state.clone();
        move |req| {
            let state = state.clone();
            async move {
                Ok(Response::builder()
                    .status(StatusCode::NOT_FOUND)
                    .body(format!("Not found (v{})", state.version).into())
                    .unwrap())
            }
        }
    }))
    .with_state(state);

fallback_service requires more boilerplate to access state.

Serving Static Files as Fallback

use axum::{
    Router,
    routing::get,
    body::Body,
    http::{Request, Response, StatusCode},
};
use tower::ServiceBuilder;
use tower_http::services::ServeDir;
 
// fallback_service: Serve static files for unmatched routes
// This is a common pattern for SPAs (Single Page Applications)
let app = Router::new()
    .route("/api", get(|| async { "API" }))
    .fallback_service(
        ServeDir::new("static")
            .not_found_service(ServeDir::new("static/index.html"))
    );
 
// Or use ServeDir with fallback to index.html for SPA routing:
let app = Router::new()
    .route("/api/users", get(|| async { "Users" }))
    .fallback_service(
        ServiceBuilder::new()
            .service(ServeDir::new("dist").fallback(ServeDir::new("dist/index.html")))
    );

fallback_service works well with ServeDir for static file serving.

SPA Routing Pattern

use axum::{
    Router,
    routing::get,
    body::Body,
    http::{Request, Response, StatusCode},
};
use tower::service_fn;
use tower_http::services::ServeDir;
 
// For SPAs, serve index.html for unmatched routes
// This allows client-side routing to handle the path
 
async fn spa_fallback(
    req: Request<Body>,
) -> Result<Response<Body>, Infallible> {
    // API routes should return 404
    if req.uri().path().starts_with("/api") {
        return Ok(Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body("API endpoint not found".into())
            .unwrap());
    }
    
    // Other routes serve index.html for client-side routing
    // In practice, use ServeDir or read from disk
    Ok(Response::builder()
        .status(StatusCode::OK)
        .header("content-type", "text/html")
        .body("<html>...SPA...</html>".into())
        .unwrap())
}
 
let app = Router::new()
    .route("/api/users", get(|| async { "Users" }))
    .fallback_service(service_fn(spa_fallback));

SPA routing requires fallback_service for request path inspection.

Error Handling Differences

use axum::{
    Router,
    routing::get,
    response::IntoResponse,
    http::StatusCode,
    body::Body,
};
use tower::service_fn;
 
// fallback: Handler errors convert to responses
async fn error_fallback() -> impl IntoResponse {
    // Handler returns IntoResponse directly
    // Errors are handled by axum's error handling
    (StatusCode::NOT_FOUND, "Not found")
}
 
// fallback_service: Must handle errors in service
async fn error_service_fallback(
    req: Request<Body>,
) -> Result<Response<Body>, Infallible> {
    // Service returns Result<Response, Error>
    // For Infallible, cannot return Err
    // Must always produce a valid response
    
    Ok(Response::builder()
        .status(StatusCode::NOT_FOUND)
        .body("Not found".into())
        .unwrap())
}

fallback errors can be handled by axum; fallback_service must produce a response.

Error Types

use axum::{
    Router,
    routing::get,
    response::IntoResponse,
    http::StatusCode,
    body::Body,
};
use tower::service_fn;
 
// fallback_service: Can use custom error types
// But then must implement error handling
 
// With Infallible (no errors possible):
async fn infallible_fallback(
    req: Request<Body>,
) -> Result<Response<Body>, Infallible> {
    Ok(Response::builder()
        .status(StatusCode::NOT_FOUND)
        .body("Not found".into())
        .unwrap())
}
 
// With Box<dyn Error> (can fail):
async fn fallible_fallback(
    req: Request<Body>,
) -> Result<Response<Body>, Box<dyn std::error::Error + Send + Sync>> {
    // Could return Err, but then what?
    // Tower services can fail, but axum expects a response
    
    Ok(Response::builder()
        .status(StatusCode::NOT_FOUND)
        .body("Not found".into())
        .unwrap())
}
 
// In practice, use Infallible for fallback services
// Always produce a response

In practice, fallback_service should use Infallible to guarantee a response is always produced.

Performance Characteristics

use axum::{
    Router,
    routing::get,
    response::IntoResponse,
    http::StatusCode,
    body::Body,
};
 
// fallback: Overhead from extractor machinery
async fn handler_fallback() -> impl IntoResponse {
    (StatusCode::NOT_FOUND, "Not found")
}
 
// fallback_service: Direct service call
// Slightly less overhead for simple cases
 
// In practice, the difference is negligible:
// - Both go through axum's routing first
// - Both handle the request after no route matches
// - The overhead difference is in the microsecond range
 
// Choose based on features needed, not performance

Performance differences are negligible; choose based on features.

Summary Table

use axum::{Router, routing::get, response::IntoResponse, http::StatusCode, body::Body};
 
fn summary() {
    // | Aspect | fallback | fallback_service |
    // |--------|----------|-------------------|
    // | Input | Handler function | Service implementation |
    // | Extractors | Yes (full support) | No (manual extraction) |
    // | Request body | Via extractors | Direct access |
    // | State | Via State extractor | Manual closure/state |
    // | Middleware | Via layers | Via ServiceBuilder |
    // | Complexity | Simple | More boilerplate |
    // | Flexibility | Good for most cases | Maximum control |
    
    // Use fallback when:
    // - You want extractor conveniences
    // - Simple 404 or error responses
    // - Integration with axum ecosystem
    
    // Use fallback_service when:
    // - Need full request access
    // - Serving static files (ServeDir)
    // - SPA routing (index.html fallback)
    // - Custom Service implementations
}

Synthesis

Quick reference:

use axum::{Router, routing::get, response::IntoResponse, http::StatusCode, body::Body};
use tower::service_fn;
 
async fn quick_reference() {
    // fallback: Handler with extractors
    let app1 = Router::new()
        .route("/", get(|| async { "Home" }))
        .fallback(|| async { (StatusCode::NOT_FOUND, "Not found") });
    
    // fallback_service: Service with raw request
    let app2 = Router::new()
        .route("/", get(|| async { "Home" }))
        .fallback_service(service_fn(|req: Request<Body>| async {
            Ok(Response::builder()
                .status(StatusCode::NOT_FOUND)
                .body("Not found".into())
                .unwrap())
        }));
    
    // Rule of thumb:
    // - Need extractors (State, Path, Query)? -> fallback
    // - Need raw request (body, full URI)? -> fallback_service
    // - Static files (ServeDir)? -> fallback_service
    // - Simple 404? -> fallback
}

Key insight: Router::fallback and fallback_service serve the same fundamental purpose—handling requests that don't match any defined route—but operate at different abstraction levels. fallback accepts a handler function that integrates with axum's extractor system: you can use State, Path, Query, Json, and other extractors just like regular route handlers, making it ideal for simple 404 responses, JSON error objects, or any case where you want the convenience of axum's ecosystem. fallback_service accepts any Service implementation and provides the raw Request<Body>, giving you full access to the request URI, headers, method, and body without any extraction overhead, which is necessary for static file serving with ServeDir, SPA routing where you need to serve index.html for unmatched non-API routes, or cases where you need to inspect the full request before generating a response. The trade-off is that fallback is simpler and more idiomatic for most use cases, while fallback_service requires more boilerplate but enables scenarios that fallback cannot handle cleanly, such as serving static files or implementing custom routing logic at the fallback level.