Axum Request Timeouts Limits and Body Size Caps Step by Step
Protect an Axum service from slow and oversized requests: TimeoutLayer, DefaultBodyLimit, header limits and what a reverse proxy should add on top.

A web service that accepts anything without bounds will, sooner or later, be asked to accept too much: a client that never finishes sending its body, a ten-gigabyte upload to a JSON endpoint, a thousand slow requests at once. None of those is a bug in your handler. Each is a resource you gave away without setting a price. Three limits, applied at the edge of the router, cover almost all of it: how long, how big and how many at once. This post sets each in Axum, sends requests that hit it and shows the status the client sees, and then covers how to choose the numbers. Everything below was run against axum 0.8.9, tower-http 0.6 and tokio 1 on Rust 1.99.
How long: a deadline on every request
Without a timeout, a handler that awaits a slow database call or an unresponsive upstream holds the connection, the task and everything it allocated until the other side gives up, which may be never. Put a deadline on the whole router. tower-http provides one that answers with a plain response, so there is no error type to convert (the fallible Tower version needs the extra plumbing described in Tower services and layers):
use std::time::Duration;
use axum::{Router, http::StatusCode, routing::get};
use tower_http::timeout::TimeoutLayer;
async fn slow() -> &'static str {
tokio::time::sleep(Duration::from_secs(2)).await;
"finished"
}
let app = Router::new()
.route("/slow", get(slow))
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
Duration::from_millis(500),
));curl -i -m 5 localhost:3000/slowHTTP/1.1 408 Request TimeoutThe handler was waiting two seconds, the layer gave it 500 ms, and the client got a 408 with an empty body after half a second. The timeout drops the handler's future, which is cancellation: everything the handler had not yet finished is abandoned at the .await where it was suspended. That is fine for read-only work and dangerous for a handler that must be all-or-nothing, such as one that debits an account and then sends an email. A future dropped between the two steps does only the first. Make such handlers cancellation-safe by moving the multi-step work into a spawned task (tokio::spawn is not cancelled when the request future is) or into a database transaction that rolls back; select and cancellation safety explains the hazard in depth.
Pick one deadline for the router and shorter ones for the cheap routes, and layer them accordingly: a router-wide 30 seconds with a 2 second timeout on /health is reasonable. The deadline must be shorter than whatever sits in front of you. If the load balancer times out at 60 seconds and you allow 90, the client has gone and your server keeps working on a result nobody will read. Remember that this timer covers handling, not slow reading: a client that sends one byte a second can hold a connection through the header and body phase on the server side, which Hyper's header read timeout addresses and which a reverse proxy in front of you, configured with request and header timeouts, handles better than the application can.
How big: caps on the body
Two mechanisms, with different reach. DefaultBodyLimit is a setting that Axum's buffering extractors (Json, Form, String, Bytes) consult; it defaults to 2 MB and you can change it per route. RequestBodyLimitLayer from tower-http sits in front of everything and rejects as soon as the declared Content-Length exceeds the limit, before any bytes are read, and counts bytes for requests that do not declare a length. Use the second as your hard ceiling and the first to tune individual routes. When you add RequestBodyLimitLayer, disable the default so the two do not disagree:
use axum::{extract::DefaultBodyLimit, routing::post};
use tower_http::limit::RequestBodyLimitLayer;
async fn echo_len(body: String) -> String {
format!("{} bytes", body.len())
}
let app = Router::new()
.route("/echo", post(echo_len))
.layer(RequestBodyLimitLayer::new(1024))
.layer(DefaultBodyLimit::disable());curl -s -w ' [%{http_code}]\n' -X POST localhost:3000/echo -d 'hello'
curl -s -w ' [%{http_code}]\n' -X POST localhost:3000/echo --data-binary @2000-bytes.txt
curl -s -w ' [%{http_code}]\n' -X POST localhost:3000/echo -H 'transfer-encoding: chunked' --data-binary @2000-bytes.txt5 bytes [200]
length limit exceeded [413]
Failed to buffer the request body: length limit exceeded [413]The second and third requests show the two paths. A request with a Content-Length of 2000 is refused before the handler runs, with the layer's own message. A chunked request has no declared length, so the extractor reads until it passes 1024 bytes and then fails with the extractor's message and the same 413. Both are correct, and the difference in wording is only where the check ran. This is why a limit that merely trusts Content-Length is not a limit: a hostile client simply omits it. Pick the number from the largest legitimate payload your route accepts, plus a margin. A login form is a few hundred bytes, a JSON order a few kilobytes, a profile photo a few megabytes; the upload route gets its own higher cap and the rest of the router keeps the low one, as in form data and file uploads. JSON has its own amplification risk, deeply nested structures that are cheap to send and costly to parse; a small byte limit contains it.
How many at once: shed load early
Timeouts and size caps bound one request. A burst of many legitimate requests needs a different control, because a service that accepts everything and works through it slowly makes every client wait and eventually every client time out. The kinder behavior is to serve what you can and refuse the rest immediately, so clients and load balancers can retry elsewhere. A semaphore in middleware does that with a few lines, and it is easy to verify, unlike Tower's own concurrency limit, whose behavior depends on how Axum clones services per route:
use std::sync::Arc;
use axum::{
extract::{Request, State},
middleware::{self, Next},
response::Response,
};
use tokio::sync::Semaphore;
async fn shed(
State(permits): State<Arc<Semaphore>>,
req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let _permit = permits.try_acquire().map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
Ok(next.run(req).await)
}
let permits = Arc::new(Semaphore::new(2));
let limited = Router::new()
.route("/work", get(slow))
.layer(middleware::from_fn_with_state(permits, shed));for i in 1 2 3 4 5; do curl -s -o /dev/null -w "req$i %{http_code} %{time_total}s\n" localhost:3000/work & done; waitreq4 503 0.000500s
req3 503 0.000609s
req5 503 0.000392s
req1 200 2.004901s
req2 200 2.004781stry_acquire never waits. The two requests that got permits ran for the full two seconds, and the other three were refused in about half a millisecond. The permit is held in _permit until next.run finishes, then dropped, which releases the slot (the underscore-prefixed name matters: let _ = ... would drop it at once and limit nothing). Use acquire with a short tokio::time::timeout instead when you would rather queue briefly than refuse, which smooths small bursts. Choose the permit count from what the service can actually do at once: for a CPU-bound handler, about the number of cores; for one that waits on a database, the database connection pool size, since more concurrent requests than connections just means a queue inside the pool (pool sizing covers the arithmetic). The 503 is the right status here, and a Retry-After header tells well-behaved clients when to come back.
This is global load shedding. Limiting by client (so one noisy caller cannot take every permit) is rate limiting, a separate problem with its own tools, covered in rate limiting with Tower Governor and Redis. Keep both: the semaphore protects the process, the rate limit protects fairness.
Order the layers so each one can do its job
The layers interact. Put the timeout outside the body limit so a slow upload that eventually trips the size cap still ends on a deadline. Put the load shedder outside both so a refused request costs nothing. Put request logging outside all of them so the refusals are recorded. With ServiceBuilder, which reads outermost first, that order is: tracing, load shed, timeout, body limit, then your routes; the reading rule is in middleware with Tower layers. Test each limit as the sections above do, with a request that must hit it, because a limit that silently does nothing is worse than none: you believe you are protected. A test that sends 2 KB to a 1 KB limit and expects 413 runs in a millisecond and belongs in your suite (testing with Tower oneshot shows how, with no network).
Choosing the numbers
| Limit | Start from | Adjust when |
|---|---|---|
| Request timeout | p99 latency times 3, rounded up | a route legitimately runs long; give it its own layer |
| Body size | largest real payload times 2 | an upload route needs more; raise only there |
| Concurrent requests | cores for CPU work, pool size for database work | load tests show headroom; raise in steps |
| Header read timeout | 5 to 10 seconds | clients on very slow mobile links; check proxy settings first |
Measure the first row, do not guess it: if your p99 is 180 ms, a 30-second timeout only delays the failure of a stuck request, and a 600 ms deadline catches a hung dependency fast. Re-measure after a significant change, since timeouts tuned to last year's latency either fire constantly or never.
What these limits do not cover
They do not stop a distributed flood: a thousand clients each within the limits still saturate you, which is the job of a CDN or the network edge. They do not replace authentication-aware throttling, so an attacker with a free account is as welcome as anyone. And inside the application, remember the unbounded things the layers cannot see: a query that returns a million rows, a loop over a user-supplied list, a regular expression with catastrophic backtracking. Bound those where they happen. The full set of defensive layers for a service, arranged in the right order, is in an Axum production service.


