Go HTTP server timeouts that survive slow and stuck clients
· 10 min read
On this page (9)
A net/http server built as &http.Server{Handler: h}, or started with a bare http.ListenAndServe, has no timeouts at all. A client can connect, send half a request header, and stop. The server keeps that connection open, with a goroutine and a file descriptor behind it, for as long as the client likes. Open a few thousand of those and a small service runs out of descriptors long before it runs out of CPU. This is the slowloris pattern, and it is also what an ordinary flaky mobile client looks like from the server's side.
The fix is four fields and one habit. This post covers what each timeout actually bounds, the numbers we use for a service on one VPS, how to give a single slow endpoint a bigger budget without loosening everything, and how to prove with a test that a stuck client really gets cut off.
A client that sends half a header, then stops
Who needs this
A public API behind Caddy or nginx. You might think the proxy protects you. It protects you from slow clients on the public side, but the hop from proxy to app is its own connection, and a proxy that is misconfigured, bypassed during a debugging session or replaced later will expose the app directly. Timeouts in the app are the layer that is always there.
A service that accepts uploads. Users on bad connections send a 20 MB file at 30 KB/s. Without a read deadline that upload ties up a handler and a connection for ten minutes, and with a few of them in parallel you notice. With a deadline that is too tight, those same users get errors. You want a budget that is generous for this route and strict for the rest.
A webhook receiver or any endpoint on the open internet. Anyone can reach it, including scanners that open connections and idle. The header timeout turns each of those into a few seconds of wasted effort instead of a leaked goroutine. For the receiving side, see receiving webhooks in Go.
A service with one slow report endpoint. Everything answers in 20 ms except /report, which can take 25 seconds. Setting a 30 second WriteTimeout for the whole server to accommodate it makes every other endpoint's worst case 30 seconds. Per-handler deadlines fix that.
The four timeouts and what they bound
srv := &http.Server{
Addr: "127.0.0.1:8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 16 << 10,
}
Those are the defaults of the reference service in our Go handbook: 5, 15, 30 and 60 seconds. They are a starting point for a typical JSON API. Here is what each one does.
Where each timeout starts counting
Rule of thumb: if you set only one timeout, set
ReadHeaderTimeout. It is the one that stops slowloris.
ReadHeaderTimeout
The time allowed to read the request line and the headers, counted from when the connection is accepted (or from the end of the previous request on a keep-alive connection). Legitimate clients send headers in a few milliseconds, so 5 seconds is generous. This is the one that stops slowloris. If you set only one timeout, set this one.
ReadTimeout
The time allowed to read the entire request, headers and body, counted from the same starting point. If a client takes too long to send the body, the handler's reads on r.Body start failing with a timeout error. It is a blunt instrument: it applies to every route, so a single large-upload route forces it up for all of them. We deal with that below.
WriteTimeout
The time allowed to write the response, counted from the end of the request header read (for HTTP/1.x). It must be longer than your slowest legitimate handler plus the time to send its response. If a handler runs past it, the handler is not interrupted. It keeps running, its writes fail, and the client sees a closed connection with no response. This is the most surprising of the four, so read it twice: WriteTimeout does not cancel your handler and does not send a 503. It silently kills the connection.
IdleTimeout
How long to keep an idle keep-alive connection open waiting for the next request. Without it, idle connections live as long as the client or the OS allows. If it is zero, ReadTimeout is used, and if that is zero too, there is no limit. A minute is a fine default. If a proxy sits in front, make it longer than the proxy's own idle timeout toward the app, so the proxy never reuses a connection the app has already closed.
MaxHeaderBytes
Not a timeout, but it belongs in the same block. The default is 1 MiB, which is far more than any real client sends. 16 KiB is plenty and bounds the memory a hostile client can make you buffer per connection.
If ReadHeaderTimeout is zero, the server uses ReadTimeout for the header phase. Setting ReadTimeout alone therefore protects you, but with a coarser number.
Prove it: a test for the slow-header client
A timeout you have not triggered is a timeout you hope you have. Here is the check we use, written with only the standard library:
func TestSlowHeaderClientIsCut(t *testing.T) {
srv := &http.Server{
Handler: http.NotFoundHandler(),
ReadHeaderTimeout: 300 * time.Millisecond,
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
go srv.Serve(ln)
defer srv.Close()
conn, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
start := time.Now()
// A partial request: the header block is never finished.
io.WriteString(conn, "GET / HTTP/1.1\r\nHost: x\r\n")
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
_, err = bufio.NewReader(conn).ReadString('\n') // 408 or a plain close
elapsed := time.Since(start)
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
t.Fatalf("still open after %v: the header timeout did not fire", elapsed)
}
if elapsed < 250*time.Millisecond || elapsed > 1500*time.Millisecond {
t.Fatalf("cut after %v, want about 300ms", elapsed)
}
}
The test needs bufio, errors, io, net, net/http, testing and time in its imports. It dials the real listener, sends half a request, and expects the server to hang up after about 300 ms. If the server answers with a 408 first, the read returns that line and the test still passes, because what we check is that the connection did not stay silent until our own deadline.
In the handbook's recorded run the same check disconnected the client after 295 ms with a 300 ms header timeout. The control, a server created with no timeouts, was still holding the same half-sent request after 600 ms. That control matters: it shows the test can fail, which is what makes a pass worth anything.
You can also try it by hand against a running service:
( printf 'GET /healthz HTTP/1.1\r\nHost: x\r\n'; sleep 30 ) | nc 127.0.0.1 8080
With ReadHeaderTimeout: 5 * time.Second, nc exits after about five seconds. Without the timeout it sits there for the full 30 and the server happily keeps the connection.
Per-handler deadlines with http.ResponseController
Go 1.20 added http.ResponseController, which lets a handler adjust the connection deadlines for its own request. This is how you give the upload route a bigger budget without raising the server-wide numbers:
func upload(w http.ResponseWriter, r *http.Request) {
rc := http.NewResponseController(w)
// Replace the server-wide deadlines for this request only.
_ = rc.SetReadDeadline(time.Now().Add(2 * time.Minute))
_ = rc.SetWriteDeadline(time.Now().Add(2*time.Minute + 10*time.Second))
r.Body = http.MaxBytesReader(w, r.Body, 50<<20) // 50 MiB cap
if _, err := io.Copy(io.Discard, r.Body); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
http.Error(w, "too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "bad upload", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
Three points about it. First, io.Discard stands in for wherever the file goes. Second, a bigger deadline is not a license to skip a size cap: MaxBytesReader bounds how much a client may send, the deadline bounds how long it may take, and you want both. Third, if your handler is wrapped by middleware that replaces the ResponseWriter (a status-recording wrapper, say), the wrapper needs an Unwrap() http.ResponseWriter method or NewResponseController cannot reach the real connection.
For streaming responses such as server-sent events, clear the write deadline instead of raising it: rc.SetWriteDeadline(time.Time{}). A zero time means no deadline. Then bound the stream yourself with the request context and a heartbeat. For a long-poll, keep the wait below WriteTimeout; the reference service caps client waits at half of it, so the response always has time to be written.
Per-handler deadlines with http.TimeoutHandler
ResponseController adjusts connection deadlines. http.TimeoutHandler bounds how long the handler may run and answers for it:
mux := http.NewServeMux()
mux.Handle("GET /report",
http.TimeoutHandler(http.HandlerFunc(report), 10*time.Second, "timeout\n"))
If the handler has not returned after 10 seconds, the client gets a 503 with that body, and the request context passed to the handler is cancelled. That second part is what makes it useful: a handler that passes r.Context() to its database and HTTP calls stops working when the deadline hits.
Caveats worth knowing. The handler goroutine is not killed. If it ignores the context, it keeps running and holding resources until it finishes on its own. TimeoutHandler buffers the response, so it does not work for streaming, and the wrapped writer does not support Flusher or Hijacker. And the WriteTimeout must be longer than the handler timeout, or the connection dies before TimeoutHandler can send its 503.
Inside the handler, use the context deliberately:
func report(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, `SELECT ...`)
// handle err: context.DeadlineExceeded means we ran out of budget
_ = rows
}
The inner 8 seconds is shorter than the outer 10, so the handler can fail in a controlled way and log a useful message before TimeoutHandler gives up on it.
Budgets that nest
Timeouts only help if they are ordered. From outside in:
| Layer | Example value | What it guards |
|---|---|---|
| Proxy read/idle limits (Caddy, nginx) | 10 s header, 2 min idle | Slow clients on the public side |
ReadHeaderTimeout | 5 s | Stuck or hostile header senders |
ReadTimeout | 15 s | Slow bodies on ordinary routes |
WriteTimeout | 30 s | Clients that do not read responses |
TimeoutHandler per route | 10 s | A slow handler on one endpoint |
| Context deadline inside the handler | 8 s | The database or upstream call |
Query-level limits (busy_timeout, statement_timeout) | seconds | The database itself |
Budgets nest, outer is longest
Each row should be shorter than the one outside it, or the outer one fires first and the inner one never gets to explain what went wrong. Caddy, if you use it, takes server timeouts in its global options:
{
servers {
timeouts {
read_header 10s
idle 2m
}
}
}
Our Caddy versus nginx comparison covers the rest of that choice.
Log what the server rejects
When a timeout fires, net/http writes to the server's ErrorLog, which by default is the standard log package writing plain text to stderr. Point it at your structured logger so those lines land in the same stream, at warning level:
srv.ErrorLog = slog.NewLogLogger(logger.Handler(), slog.LevelWarn)
slog.NewLogLogger is in the standard library since Go 1.21. See log/slog for small services for the rest of the logging setup.
Mistakes we see
Only WriteTimeout. It feels like the important one, but it does nothing against a client that never finishes sending headers. ReadHeaderTimeout is the one that matters for slowloris.
ReadTimeout raised to five minutes for one upload route. Now every route accepts a five minute body. Raise the deadline for that route with ResponseController and leave the server-wide value alone.
WriteTimeout shorter than a long-poll or report. The handler runs to completion, the response write fails, and the client sees a reset with nothing in your logs that says why. Make WriteTimeout longer than your slowest legitimate response, then put TimeoutHandler or context deadlines underneath it.
Zero timeouts on the admin or pprof port. A CPU profile legitimately takes 30 seconds or more, so people disable WriteTimeout there. That is fine only if the port is bound to loopback. Our reference service sets admin.WriteTimeout = 0 and listens on 127.0.0.1.
No IdleTimeout. Keep-alive connections from clients that went away stay in the table. Not dramatic, but it is how a descriptor limit gets reached slowly over a week.
Timeouts on the client side forgotten. Outbound calls from a handler need the same care. http.Client{Timeout: ...} or a context on the request, always. A handler waiting forever on a third-party API is a stuck client in the other direction.
FAQ
What is a good default for ReadHeaderTimeout?
Five seconds suits an API used by browsers, apps and other servers. If your clients are on very poor networks you can go to 10, but there is rarely a reason to go higher. Setting it to 1 or 2 seconds works for internal services where every client is a server on the same host.
Does WriteTimeout cancel the handler?
No. It sets a deadline on the connection's writes. The handler keeps running and keeps whatever resources it holds, and its later writes return errors. If you want the handler itself to stop, use a context deadline or http.TimeoutHandler.
Can I rely on Caddy or nginx instead of setting timeouts in Go?
Use both. The proxy handles the public-facing connection, but the Go server is still reachable from localhost, from other containers and from any future config that skips the proxy. Four lines of struct fields cost nothing.
How do I set timeouts for HTTP/2?
If Go terminates TLS itself, the same fields apply to HTTP/2 connections, with the details handled by the http2 machinery inside net/http. Behind a proxy that speaks HTTP/1.1 to your app, which is the common setup on one VPS, none of this changes.
Why did my long download get cut off?
Probably WriteTimeout. It counts from the end of the request headers to the end of the response, so a slow client downloading a big file hits it. Clear or extend it for that handler with rc.SetWriteDeadline.
The Go on One VPS handbook includes this chapter with the tested server constructor, the slow-header test and its no-timeout control, and the list of production bugs where this one comes first: Go on One VPS: Production Handbook.