Skip to content
Control Plane Labs

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/upload

What 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