408 Request Timeout
Client error response defined by RFC 9110 §15.5.9.
Last updated July 27, 2026
Common causes at a glance
- An idle keep-alive connection reaped by the server before a request was sent
- A slow or lossy client uplink stalling a large upload mid-body
- client_header_timeout or client_body_timeout set lower than real traffic needs
- A Content-Length promising more bytes than the client actually sends
Reproduce it
curl -i --limit-rate 1k -X POST --data-binary @payload.json https://www.example.com/uploadWhat 408 Request Timeout means
408 is the server complaining about the client; 504 is a gateway complaining about an upstream. The bytes of the request line, headers, or body did not all arrive within the window the server was prepared to wait: slow uplink, a stalled body, or a client that opened a connection and sent nothing.
That last case produces the 408 most people actually observe, and it is not an error at all. Servers reap idle keep-alive connections by sending 408 before closing, so a pool holding an idle socket gets an unsolicited 408 for a request it never sent. In the access log it is a trickle of 408s with zero bytes received, and chasing it is wasted effort.
What the spec says
RFC 9110 §15.5.9 says the server did not receive a complete request message within the time it was prepared to wait, and that a client with an outstanding request MAY repeat it — on a new connection if the current one is unusable, which in HTTP/1.1 it generally is, because request delimitation has been lost. That permission is why 408 is one of the few 4xx codes safe to retry automatically. The framing rules deciding when a request counts as complete are RFC 9112 §6.3, and truncated messages are §8.
What actually causes it
Separate the two populations first. Keep-alive reaping produces 408s
with no method, no path, and zero bytes; they track your idle timeout rather than any
endpoint, and the fix is to align keepalive_timeout with the client pool’s
idle setting, not to touch application code. Real 408s have a method and a path and
cluster on uploads or high-latency clients. nginx enforces the two phases
separately with client_header_timeout and client_body_timeout,
both in the core module, and
the body timeout applies between reads rather than to the whole body — a client trickling
one byte at a time never trips it, which is how slowloris works.
How to debug it
Check the access log fields first. A 408 with an empty request line is
connection reaping and needs no fix. A 408 with a real method and path is a genuine
inbound stall, and the useful signal is bytes received against the declared
Content-Length. Reproduce a slow client to confirm the timeout value:
curl -i --limit-rate 1k -X POST --data-binary @payload.json \
https://www.example.com/upload
If that returns 408 after roughly your configured body timeout, the setting is confirmed. Rule out 504 by direction: 504 means your gateway waited on a backend, which has a matching slow request in its own logs, while a 408 leaves no trace upstream.
Working examples
curl
# Deliberately slow client to measure the server's inbound timeout.
curl -i --limit-rate 1k -X POST --data-binary @payload.json \
https://www.example.com/upload
# Timing breakdown separates connect/TLS from the upload stall.
curl -o /dev/null -s -w 'connect=%{time_connect} tls=%{time_appconnect} total=%{time_total}\n' \
-X POST --data-binary @payload.json https://www.example.com/upload
# Watch for an unsolicited 408 on an idle keep-alive socket.
curl -sv --keepalive-time 300 https://www.example.com/ 2>&1 | grep -iE '408|close'
Python (requests)
import requests
# The timeout tuple is (connect, read) — neither controls how fast YOU send,
# which is what a 408 is actually complaining about.
resp = requests.post(
"https://www.example.com/upload",
data=open("payload.json", "rb"),
timeout=(5, 30),
)
print(resp.status_code, resp.headers.get("Connection"))
# 408 is one of the few 4xx codes safe to retry, on a FRESH connection.
if resp.status_code == 408:
retry = requests.post(
"https://www.example.com/upload",
data=open("payload.json", "rb"),
timeout=(5, 60),
)
print("retry:", retry.status_code)
Node (fetch)
// AbortController bounds the whole exchange; it does not slow the upload.
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 30_000);
const res = await fetch("https://www.example.com/upload", {
method: "POST",
body: JSON.stringify({ big: "payload" }),
signal: ac.signal,
}).finally(() => clearTimeout(timer));
console.log(res.status, res.headers.get("connection"));
// Retry once on a new connection; the old one's framing is not trustworthy.
if (res.status === 408) {
const retry = await fetch("https://www.example.com/upload", {
method: "POST",
body: "{}",
});
console.log("retry:", retry.status);
}Reference and tooling
The authoritative list of assigned status codes is theIANA HTTP Status Code Registry. For a searchable copy with the semantics summarised, use theHTTP status code reference on this site. To see exactly what a server is sending, theheader inspector shows the raw status line and response headers, and thecurl builder assembles the request flags for you.
Other 4xx codes
- 400 Bad Request
- 401 Unauthorized
- 402 Payment Required
- 403 Forbidden
- 404 Not Found
- 405 Method Not Allowed
- 406 Not Acceptable
- 407 Proxy Authentication Required
- 409 Conflict
- 410 Gone
- 411 Length Required
- 412 Precondition Failed
- 413 Content Too Large
- 414 URI Too Long
- 415 Unsupported Media Type
- 416 Range Not Satisfiable
- 417 Expectation Failed
- 418 (Unused)
- 419 Page Expired
- 421 Misdirected Request
- 422 Unprocessable Content
- 423 Locked
- 424 Failed Dependency
- 425 Too Early
- 426 Upgrade Required
- 428 Precondition Required
- 429 Too Many Requests
- 431 Request Header Fields Too Large
- 444 No Response
- 451 Unavailable For Legal Reasons
- 494 Request Header Too Large
- 495 SSL Certificate Error
- 496 SSL Certificate Required
- 497 HTTP Request Sent to HTTPS Port
- 499 Client Closed Request