Graceful Shutdown in Go HTTP Servers
Implement graceful shutdown for Go HTTP servers: handling SIGTERM and SIGINT, draining active requests, shutdown timeouts, and Kubernetes readiness.
The problem
When a Go process receives SIGKILL, or is killed without any signal
handling at all, every in-flight HTTP request is dropped mid-response. A
client mid-checkout gets a connection reset. A background write that had
already been committed to the database but not yet acknowledged to the
caller now looks like a failure to retry. None of this is a Go-specific
problem — it's what happens whenever a process managing live connections
disappears without warning.
net/http.Server gives you a way to shut down deliberately: stop accepting
new connections, let in-flight requests finish within a bounded window, then
exit. Getting this wired up correctly is a small amount of code with an
outsized effect on reliability during deploys.
Why it matters
Deploys, autoscaling, and node maintenance all end the same way for a running process: something sends it a termination signal and expects it to exit soon after. In a containerized environment this happens constantly — every rolling deploy terminates the previous generation of pods.
- Without graceful shutdown, every deploy produces a burst of failed requests for whatever was in flight at the moment the old process died.
- Health checks and load balancers need time to notice a pod is going away and stop routing to it — if the process exits before that propagates, requests get routed to a socket that no longer accepts connections.
- Background work (queue consumers, in-memory batching, outbox flushing) needs an explicit signal to stop pulling new work and flush what it has.
How it works
http.Server.Shutdown(ctx) stops the listener from accepting new
connections, then waits for active requests to complete before returning.
If the context passed to Shutdown is canceled or its deadline passes
first, Shutdown returns immediately and leaves any still-active
connections to be closed however the caller decides.
The standard pattern: listen for SIGTERM and SIGINT with
signal.NotifyContext, run the server in a goroutine, and call Shutdown
once a signal arrives, bounded by a timeout.
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &http.Server{
Addr: ":8080",
Handler: newRouter(),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done()
stop() // stop listening for further signals; a second SIGTERM should force-kill
log.Println("shutdown signal received, draining connections")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
_ = srv.Close() // force-close remaining connections
}
}ListenAndServe always returns a non-nil error; after Shutdown is called
it returns http.ErrServerClosed, which is the expected, successful exit
path — not something to treat as a startup failure.
Draining more than just HTTP connections
A real service usually has more than one thing that needs to stop cleanly: a database connection pool, a background queue consumer, an in-memory metrics flusher. Wire their shutdown into the same signal so nothing keeps running after the HTTP server has already stopped accepting traffic.
func run(ctx context.Context, db *sql.DB, consumer *queue.Consumer, srv *http.Server) error {
consumerDone := make(chan struct{})
go func() {
consumer.Run(ctx) // returns when ctx is canceled
close(consumerDone)
}()
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("listen: %v", err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown http server: %w", err)
}
select {
case <-consumerDone:
case <-shutdownCtx.Done():
log.Println("queue consumer did not stop before shutdown deadline")
}
return db.Close()
}The consumer's own ctx — the same one canceled by the signal — is what
tells it to stop pulling new work; Shutdown only owns the HTTP listener.
Order matters: stop accepting new HTTP requests and new queue work before
closing the database pool. Closing db while a request handler is still
mid-query turns a clean shutdown into a wave of 500s.
Production considerations
In Kubernetes, SIGTERM and "stop receiving traffic" are not the same
event. A pod is removed from Service endpoints asynchronously with the
SIGTERM being sent to the container — there's no guarantee the endpoint
update has propagated to every kube-proxy and load balancer before your
process starts draining. A preStop hook that sleeps for a few seconds
before the container's main process receives SIGTERM is the usual fix,
giving the network layer time to stop sending new traffic first:
lifecycle:
preStop:
exec:
command: ["sleep", "5"]
terminationGracePeriodSeconds: 30terminationGracePeriodSeconds must exceed your shutdown timeout,
including the preStop delay. If the grace period expires first,
Kubernetes sends SIGKILL regardless of how much of your Shutdown call
had completed.
Readiness, not liveness, controls traffic. If your process also exposes
a /ready endpoint, flip it to unready as the first step of shutdown —
before calling Shutdown — so any readiness-probe-driven load balancer
stops routing new requests as early as possible, independent of the
Kubernetes endpoint propagation delay.
Common mistakes
- No shutdown handling at all — relying on the orchestrator to just kill the process, which is exactly the failure mode this pattern avoids.
- Shutdown timeout longer than the grace period. The process gets
SIGKILLed mid-Shutdown, which is no better than not calling it. - Treating
http.ErrServerClosedas a fatal error in the goroutine runningListenAndServe, which turns every clean shutdown into a logged crash. - Closing shared resources (DB pool, cache client) before
Shutdownreturns, which fails in-flight requests that were otherwise going to complete successfully within the drain window. - Not handling a second signal. If
Shutdownhangs longer than expected, an operator sending a secondSIGTERM/SIGINTshould be able to force an immediate exit rather than waiting out the full grace period.
Testing
Shutdown's contract — it waits for active requests, then returns — is
straightforward to test directly with httptest, using a handler that
blocks until told to proceed:
func TestGracefulShutdown_WaitsForActiveRequest(t *testing.T) {
release := make(chan struct{})
started := make(chan struct{})
srv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
<-release
w.WriteHeader(http.StatusOK)
}),
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
go srv.Serve(ln)
go func() {
resp, err := http.Get("http://" + ln.Addr().String())
if err != nil {
t.Errorf("request failed: %v", err)
return
}
resp.Body.Close()
}()
<-started
shutdownDone := make(chan error, 1)
go func() {
shutdownDone <- srv.Shutdown(context.Background())
}()
select {
case <-shutdownDone:
t.Fatal("Shutdown returned before the in-flight request completed")
case <-time.After(50 * time.Millisecond):
}
close(release)
if err := <-shutdownDone; err != nil {
t.Fatalf("Shutdown returned error: %v", err)
}
}The test asserts the negative case explicitly — Shutdown must still be
blocked while a handler is mid-flight — which is the behavior that actually
matters in production.
Summary
Graceful shutdown means listening for SIGTERM/SIGINT, calling
http.Server.Shutdown with a bounded timeout so new connections stop and
in-flight ones get to finish, and draining any other long-running work
(queue consumers, background jobs) on the same signal before closing shared
resources like a database pool. In Kubernetes, the shutdown timeout,
preStop delay, and terminationGracePeriodSeconds all have to agree with
each other, or the orchestrator will SIGKILL the process before your own
shutdown logic finishes.
funcRelated()[]Article
Kubernetes for Go Developers: Deploying a Production-Ready Go API
What actually happens when Kubernetes sends your Go process SIGTERM, and the Deployment, Service, probe, and resource config a production Go API needs.
Building Background Workers in Go with Goroutines and Channels
A production-oriented worker pool in Go: bounded concurrency, graceful shutdown, panic recovery, and why 'go process(job) for every job' is dangerous.
Advanced Go Concurrency: Worker Pools, Backpressure and Bounded Concurrency
Why unbounded goroutines don't scale: semaphore-based bounded concurrency, backpressure, errgroup, goroutine leaks, and a real benchmark comparing the two.