The connection pileup
You deploy a Go web server. It handles a few hundred requests per second without breaking a sweat. Then a slow database query or a flaky upstream API sneaks in. Connections pile up. Memory climbs. The server starts dropping clients with 408 Request Timeout or hangs until the OS kills it. Timeouts are not just a performance tweak. They are a circuit breaker for your entire application.
Think of a timeout like a waiter with a stopwatch. The customer orders. The waiter starts the timer. If the kitchen takes too long, the waiter cancels the order, clears the table, and moves on to the next guest. Without that timer, the table stays occupied forever, new guests get turned away, and the restaurant collapses under its own backlog. In Go, the net/http package gives you two layers of stopwatches: server-level timeouts that protect the whole process, and request-level contexts that protect individual handlers.
Timeouts are circuit breakers, not performance knobs.
How Go enforces deadlines
The standard library separates request handling into two distinct phases. The read phase covers everything from the initial TCP handshake to the complete parsing of headers and the request body. The write phase begins the moment your handler finishes and starts streaming the response back to the client. Go measures these phases independently because they fail for different reasons.
A slow client might open a connection and send headers one byte at a time. That triggers the read timeout. A fast client might request a heavy report, but their network drops packets right as the response begins streaming. That triggers the write timeout. The server tracks both clocks using non-blocking timers. When a timer expires, the underlying connection gets closed. The handler function does not stop automatically. It keeps executing in the background until it explicitly checks the request context or finishes its work.
Set the boundaries before the first request arrives.
A minimal server with boundaries
Here is the simplest way to attach hard deadlines to an HTTP server. The configuration lives on the http.Server struct, which means it applies globally to every incoming connection.
package main
import (
"log"
"net/http"
"time"
)
// main starts a server with explicit read and write deadlines.
func main() {
// ReadTimeout covers the entire request reading phase, including headers and body.
// It protects against slow clients or connection floods that drain goroutines.
readTimeout := 5 * time.Second
// WriteTimeout starts after the handler returns.
// It prevents a slow client from holding the connection open after the response is ready.
writeTimeout := 10 * time.Second
server := &http.Server{
Addr: ":8080",
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
Handler: http.HandlerFunc(rootHandler),
}
// ListenAndServe blocks until the server is stopped or returns an error.
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
// rootHandler responds immediately to demonstrate the timeout boundaries.
func rootHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok\n"))
}
The server starts listening. Every new TCP connection inherits those two timers. If a client takes longer than five seconds to send the full request, the server closes the socket. If the handler finishes but the client takes longer than ten seconds to consume the response, the server closes the socket. The timers reset for each new request on that connection. Keep-alive connections reuse the same underlying TCP socket, but the deadline counters restart fresh for every HTTP transaction.
The server drops the connection. Your handler keeps running until it checks the context.
What happens when the clock runs out
When a timeout fires, Go closes the network connection. The operating system sends a SIGPIPE or ECONNRESET to the writing goroutine. If your handler tries to write to http.ResponseWriter after the connection is already dead, you will see a runtime panic in your logs: http: superfluous response.WriteHeader call or broken pipe. The panic is caught by the server's recovery handler, but it still clutters your logs and wastes CPU cycles.
The safer pattern is to check the request context before doing expensive work. The r.Context() method returns a context.Context that the server automatically cancels when the client disconnects or a timeout fires. Always pass context.Context as the first parameter to functions that perform I/O, and name it ctx. This is a hard convention in the Go ecosystem. Functions that accept a context should respect cancellation and deadlines.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
// fetchHandler simulates a long operation that respects context cancellation.
func fetchHandler(w http.ResponseWriter, r *http.Request) {
// Derive a child context with a strict deadline for this specific operation.
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
// Simulate a long-running task that checks for cancellation every tick.
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
// The parent server or client gave up. Stop working immediately.
http.Error(w, "operation cancelled", http.StatusGatewayTimeout)
return
case <-time.After(500 * time.Millisecond):
// Continue processing in small chunks to stay responsive.
}
}
fmt.Fprintln(w, "done")
}
The select statement blocks until either the context is cancelled or the timer fires. When ctx.Done() closes, the loop exits cleanly. The defer cancel() call releases the resources tied to the child context. This pattern prevents goroutine leaks and stops your application from doing useless work after the client has already given up.
Cancel early. Clean up resources. Let the garbage collector do the rest.
When HTTP/2 breaks the timer
HTTP/2 multiplexes multiple requests over a single TCP connection. It splits data into frames, reorders them for priority, and compresses headers. It is faster in theory. In practice, some load balancers, reverse proxies, and buggy HTTP/2 implementations send malformed frames or stall streams. Go's HTTP/2 server expects frames to arrive in a specific order. When they do not, the server's internal timers desynchronize. You start seeing random 408 Request Timeout errors on requests that should have finished in milliseconds.
The compiler cannot catch this at build time. You will only notice it in production logs or monitoring dashboards. The standard workaround is to disable HTTP/2 entirely and force HTTP/1.1. You can do this at runtime with the GODEBUG environment variable:
# Disable HTTP/2 for both client and server to bypass frame parsing bugs.
export GODEBUG=http2client=0,http2server=0
Starting with Go 1.23, you can bake this configuration directly into your go.mod file. The godebug directive applies to every build without requiring environment variables or deployment scripts.
# go.mod
module example.com/myserver
go 1.23
godebug (
# Disable HTTP/2 server-side to prevent timeout drops from buggy proxies.
http2server=0
# Disable HTTP/2 client-side to avoid upstream frame parsing failures.
http2client=0
)
The directive tells the compiler to patch the HTTP transport at build time. The server will negotiate HTTP/1.1 exclusively. You lose multiplexing and header compression, but you gain predictable, linear connection handling. The tradeoff is usually worth it when your infrastructure includes third-party load balancers that do not implement HTTP/2 correctly.
HTTP/2 is fast until it isn't. Disable it when the network lies.
Picking the right timeout strategy
Timeouts are not one size fits all. The right choice depends on where the failure happens and what you are trying to protect.
Use server-level ReadTimeout and WriteTimeout when you need a hard ceiling for all incoming connections. Use context.WithTimeout inside handlers when you want fine-grained control over specific operations like database calls or external API requests. Use the godebug directive to disable HTTP/2 when you encounter unexplained timeout drops caused by buggy proxies or load balancers. Stick to HTTP/1.1 timeouts when your traffic is simple and you want predictable, linear connection handling. Use http.TimeoutHandler when you want a quick wrapper around an existing handler without rewriting the context logic. Use graceful shutdown with server.Shutdown(ctx) when you need to drain active requests during a deployment instead of killing them instantly.
Match the timeout scope to the failure mode.