Go6 min read

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.

JBy Jeffrey Klaassen van Oorschot

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.

A small server with explicit connection-level limits
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.

A child deadline that still respects client cancellation
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.

Wait for a signal, then finish active requests
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 nil

Test 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

Keep reading

Go

Go 1.26: The Changes I Would Actually Use

A practical look at the Go 1.26 changes that affect ordinary application code: new expressions, modernizers, profiling, module defaults, and the new garbage collector.

5 min readRead article