Stopping Go HTTP Servers Without Dropping Requests: A context.WithTimeout Approach
Learn how to combine context.WithTimeout with http.Server.Shutdown to stop Go HTTP services gracefully, avoid context leaks, and balance shutdown deadlines against in-flight request latency.
29 Jul 2026, 20:24 UTC

You deploy a Go HTTP service, roll out an update, and the old process receives SIGTERM. Within milliseconds, every in-flight request dies. Clients see connection resets; a payment API call that was 90% complete vanishes. The standard fix is a graceful shutdown, but the engineering decision is not just whether to shut down gracefully—it is how long to wait before forcing the exit.
context.WithTimeout gives you a bounded wait. http.Server.Shutdown drains active requests. Together they let you stop accepting new work, finish what is running, and exit on a deadline.
The gap between Shutdown and a deadline
Go's http.Server.Shutdown stops the listener immediately and then waits for active requests to complete. It is graceful, but it is also unbounded. If one handler blocks for ten minutes because a downstream database is slow, Shutdown blocks for ten minutes. In a container orchestrator, that can look like a hung pod and trigger a hard kill anyway.
The fix is to wrap the shutdown in a deadline. context.WithTimeout returns a context that is automatically canceled after a duration you choose. When that deadline passes, Shutdown returns an error and your process exits, even if requests are still pending.
Bounding the wait with context.WithTimeout
The mechanics are straightforward. You create a context rooted at context.Background() with a timeout, pass it to Shutdown, and defer the returned CancelFunc. That deferred call is not optional. The context package allocates a timer and a goroutine to enforce the deadline. If you skip cancel(), those resources stay alive until the deadline expires. In a long-running service that creates many short-lived contexts, forgetting to cancel produces a context leak.
A minimal shutdown pattern
The following pattern belongs in your main package. It assumes you are running the binary directly or under a process supervisor that sends SIGINT or SIGTERM. No special file permissions are required.
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
srv := &http.Server{Addr: ":8080"}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Server forced to shutdown:", err)
}
log.Println("Server exiting")
}
Expected check: when you send SIGTERM to the process, the log should show Shutting down server... followed by Server exiting within ten seconds. If you see Server forced to shutdown, the deadline fired before requests finished.
Per-request deadlines are a separate tree
A common mistake is to reuse the shutdown context inside handlers. Do not. The shutdown context controls process lifetime. A handler that derives its own timeout from the incoming request context enforces a per-request deadline without affecting the overall service timeout. For example, a handler can call context.WithTimeout(r.Context(), 2*time.Second) to cap database work for that request alone. The two deadlines are independent: the shutdown context does not shorten the handler's budget, and the handler's timeout does not extend the shutdown wait.
Trade-offs and limitations
The central trade-off is timeout length. A value that is too short—say, one second—will terminate active requests prematurely. Clients may see 503 responses or incomplete payloads. A value that is too long—say, five minutes—slows deployments and can cause health checks to fail while the old process lingers.
There is also a correctness limit: Shutdown does not cancel the contexts of in-flight requests. It only waits for them. If your handler ignores its request context and blocks on an unbounded channel receive, the shutdown deadline is your only escape hatch.
Finally, context.WithTimeout is not a request queue. New connections are refused as soon as Shutdown begins. If your load balancer has not yet removed the instance from rotation, those new connections will fail. Graceful shutdown requires coordination outside the process, such as deregistering from service discovery before sending the signal.
How to verify the behavior
Write a test that starts an http.Server on a random port, launches a request that sleeps briefly, and then calls Shutdown with a context.WithTimeout of five seconds. Assert that Shutdown returns nil and that the response completed. Run the test from your module root with:
go test -race ./...
The race detector checks for data races in your shutdown path. It does not emit context-leak warnings, so pair the test with a code review that confirms every context.WithTimeout call has a matching defer cancel(). If the test fails with a deadline exceeded error, your handler is slower than the timeout you chose.
Start with a shutdown timeout of ten to thirty seconds, measure your slowest endpoint under load, and adjust from there. Always defer the cancel function, keep per-request deadlines in their own context tree, and treat the shutdown timeout as a contract with your deployment pipeline, not just a Go implementation detail.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.