Reliable Go HTTP Services: Timeouts, Cancellation, and Shutdown
A practical guide to the parts of a Go HTTP service that prevent stuck requests, wasted work, and rough deployments.
A Go HTTP server can look finished after a handler returns JSON. The problems usually appear around it: a slow client holds a connection, an outgoing request never gets a useful deadline, database work continues after the caller leaves, or a deployment cuts off requests halfway through. None of this needs a large framework. A few explicit boundaries make a small service much easier to trust.
Treat timeouts as separate budgets
There is no single correct HTTP timeout. Reading request headers, handling an authenticated upload, waiting for an upstream API, keeping an idle connection open, and draining during shutdown are different operations. Giving all of them one number either rejects legitimate work or leaves an accidental unbounded path.
On the server, I normally set ReadHeaderTimeout and IdleTimeout first. ReadHeaderTimeout limits how long a client can take to send headers without putting a blanket deadline on a request body. A WriteTimeout can be useful for ordinary JSON endpoints, but it needs care for streaming responses because it is not a per-handler processing deadline. Application-level deadlines belong in the handler or service operation where the expected work is known.
server := &http.Server{
Addr: ":8080",
Handler: routes,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}Let the request context stop the whole operation
Every incoming request already has a context. It is cancelled when the client connection closes, the request is cancelled, or the server finishes serving it. I pass that context as the first argument through service and repository methods, and use context-aware calls such as QueryContext and NewRequestWithContext. If the browser gives up, the database query and upstream request should be allowed to give up too.
I do not store a context on a long-lived service struct, and I do not replace a request context with context.Background halfway down the call stack. Both break cancellation. A local timeout is useful only when this operation needs a shorter budget than its parent; defer cancel immediately so its timer resources are released.
func (s *Service) Quote(ctx context.Context, id string) (Quote, error) {
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
return s.prices.Fetch(ctx, id)
}Reuse an HTTP client and bound outgoing calls
An http.Client and its Transport are designed to be reused. Creating one for every call throws away connection pooling and makes traffic harder to reason about. I usually keep one client per upstream or per transport policy, then put the request-specific deadline on the context. A Client.Timeout can remain as a broad safety net, but it covers the entire exchange, including reading the response body.
After a successful Do call, the response body must be closed. When connection reuse matters, code should also consume the body it expects rather than abandoning unread data. I limit response sizes where an upstream could return something unexpectedly large, and I return errors with enough operation context to identify which dependency failed without logging secrets or full payloads.
- Reuse clients instead of calling http.DefaultClient everywhere
- Put the business deadline on the request context
- Always close a non-nil response body
- Limit bodies when the response size is not inherently bounded
- Distinguish timeout and cancellation in metrics
Graceful shutdown has an order
On SIGTERM, the service should stop advertising readiness, stop accepting new traffic, and give active requests a bounded period to finish. http.Server.Shutdown closes listeners and idle connections, then waits for active connections. ListenAndServe returns http.ErrServerClosed during this normal path, so treating that value as a fatal error creates noisy deployments.
The process must stay alive until Shutdown returns. Long-lived or hijacked connections such as WebSockets need their own close signal because Shutdown does not wait for them. In Kubernetes I also allow for the time it takes readiness changes to reach the load balancer, but I choose that delay from the actual platform path rather than copying an arbitrary sleep from a blog post.
signalCtx, stop := signal.NotifyContext(
context.Background(), os.Interrupt, syscall.SIGTERM,
)
defer stop()
serveErr := make(chan error, 1)
go func() { serveErr <- server.ListenAndServe() }()
select {
case err := <-serveErr:
return err
case <-signalCtx.Done():
}
ready.Store(false)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
return fmt.Errorf("shut down HTTP server: %w", err)
}
if err := <-serveErr; err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("serve HTTP: %w", err)
}
return nilTest the unhappy paths on purpose
A cancellation path that has never run in a test is mostly a hope. With httptest, I can make an upstream wait until its request context is cancelled, simulate a slow response, and verify that the handler returns without leaving background work behind. Repository fakes can observe ctx.Done instead of sleeping for real time.
For shutdown, I start a request that blocks on a channel, trigger Shutdown, release the request, and assert that shutdown completes. I also test the deadline-expired case. These tests catch the common mistake where main returns as soon as ListenAndServe stops, killing the process before active handlers have drained.
Use this in practice
- Set connection-level server limits deliberately
- Carry request contexts into database and HTTP calls
- Reuse configured outbound clients
- Close and appropriately consume response bodies
- Stop readiness before draining
- Test both successful and timed-out shutdown
