Graceful Shutdown for Go HTTP Servers: A Decision Guide
A decision guide for stopping Go HTTP servers without dropping in-flight requests or leaking goroutines. Compare Shutdown, Close, and signal.NotifyContext with a bounded drain pattern.
19 Jan 2026, 02:50 UTC

The Decision: How to Stop Without Dropping Traffic
When you need to stop a Go HTTP server, the goal is usually to finish requests that are already in flight, stop accepting new ones, and let background goroutines exit cleanly. The core decision is which standard-library shutdown primitive to use, and whether you need an explicit signal context to coordinate work outside the request path.
The useful takeaway: for most production services, combine signal.NotifyContext with http.Server.Shutdown and a bounded drain timeout, then wait for background workers with a sync.WaitGroup. Use http.Server.Close only when dropping active requests is acceptable.
Constraints That Change the Answer
Before picking a mechanism, check these constraints:
- Hijacked connections: If you serve WebSockets, HTTP/2 server push, or any protocol that calls the
Hijackerinterface,Shutdownwill not close those connections. - Handler cooperation: If handlers block on external I/O without selecting on
r.Context().Done(),Shutdownwill wait until your timeout expires. - Background tasks: Goroutines that outlive a single request need explicit cancellation and waiting.
- Go version:
signal.NotifyContextrequires Go 1.16 or later.
Comparing the Standard-Library Options
| Approach | Closes listener | Drains active requests | Closes hijacked connections | Cancels handler contexts | Best for |
|---|---|---|---|---|---|
Server.Shutdown | Yes | Yes | No | No | Standard HTTP/1 and HTTP/2 graceful drain |
Server.Close | Yes | No | Yes | No | Tests, fatal paths, acceptable request loss |
signal.NotifyContext + Shutdown | Yes | Yes | No | Only if propagated | Production services with background workers |
| Manual signal channel | Yes | Yes | No | No | Go versions before 1.16 |
Trade-offs in Practice
Shutdown is polite but not omniscient
http.Server.Shutdown closes the listener immediately, then waits for active connections to become idle. It does not close hijacked connections, and it does not cancel the context of an in-flight handler. If a handler is sleeping or blocked on a database call that ignores the request context, Shutdown blocks until you supply a timeout context and that context expires.
Close is for when you can lose requests
http.Server.Close closes the listener and all active network connections immediately. It is appropriate for test teardown or fatal error paths, but it will truncate in-flight responses.
Signal contexts coordinate, they do not cancel handlers
signal.NotifyContext gives you a root context.Context that is cancelled when the process receives SIGINT or SIGTERM. You can use this context to stop background goroutines and, if you wire it through middleware, to signal handlers. Shutdown itself does not cancel this context, nor does it cancel request contexts automatically.
Background workers need explicit coordination
A defer or signal handler that calls Shutdown is not enough. You must also cancel the context that background goroutines select on, and wait for them to finish before the process exits. Use sync.WaitGroup or golang.org/x/sync/errgroup.
Concrete Implementation
The following program (save as main.go) demonstrates the recommended production pattern: a signal-derived root context, a background worker, a bounded drain period, and a wait for workers. Run it with go run main.go from your module root. Binding to port 8080 is unprivileged; if you change the address to a port below 1024, you will need elevated permissions.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
func main() {
// Root context cancelled on SIGINT or SIGTERM (Go 1.16+)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
var wg sync.WaitGroup
// Background worker tied to the root context
wg.Add(1)
go func() {
defer wg.Done()
t := time.NewTicker(time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
fmt.Println("background worker stopping")
return
case <-t.C:
fmt.Println("background tick")
}
}
}()
mux := http.NewServeMux()
mux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) {
// Handler honours its request context for client disconnects.
// To react to server shutdown, propagate the root context here.
select {
case <-time.After(3 * time.Second):
w.Write([]byte("completed"))
case <-r.Context().Done():
http.Error(w, "request cancelled", http.StatusGatewayTimeout)
}
})
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Printf("listen error: %v\n", err)
}
}()
<-ctx.Done()
fmt.Println("signal received, draining")
// Bound drain time; if exceeded, Shutdown returns context.DeadlineExceeded
drainCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(drainCtx); err != nil {
fmt.Printf("graceful shutdown failed: %v\n", err)
// Force close only if graceful drain exceeded the deadline
srv.Close()
}
wg.Wait()
fmt.Println("server and workers stopped")
}
What the example does
signal.NotifyContextcreatesctx, which is cancelled when the OS deliversSIGINTorSIGTERM.- The background goroutine selects on
ctx.Done()so it exits when the signal arrives. srv.ListenAndServe()runs in a separate goroutine so the main goroutine can block on<-ctx.Done().- After the signal,
srv.Shutdown(drainCtx)stops the listener and waits up to ten seconds for active connections to become idle. - If the drain deadline expires,
Shutdownreturns an error and the code callssrv.Close()to force-close stragglers. wg.Wait()blocks until the background worker returns.
Note that the /slow handler selects on r.Context().Done(), which covers client disconnects. If you need handlers to abort work specifically because the server is shutting down, you must propagate the signal context into the handler chain, for example through middleware that replaces the request context.
Verifying the Behavior
- Slow-handler drain test: Start the server. In another terminal, run
curl http://localhost:8080/slow. While the request is in flight, sendSIGTERMto the server process withkill -TERM <pid>or pressCtrl+C. Confirm the server waits for the handler to finish, printsserver and workers stopped, and exits with status 0. Check the exit code withecho $?immediately after the process ends. - Race detector: Add a test that starts the server and a background goroutine, then triggers shutdown. Run
go test -race ./.... The race detector will flag data races, and the test should assert that the background goroutine has returned before the test completes. - Documentation check: Run
go doc net/http.Server.Shutdownto confirm the documented behavior for your installed Go version.
Limitations and Risks
- Hijacked connections:
Shutdowndoes not close WebSocket or other hijacked connections. Those handlers must implement their own shutdown signal, typically by selecting on a context you control. - Blocking handlers: If a handler blocks on external I/O and never selects on
r.Context().Done(),Shutdownwill wait until the drain timeout expires, then returncontext.DeadlineExceeded. The example falls back toClosein that case. - Goroutine leaks: Forgetting
wg.Wait()or failing to cancel the root context will leak background goroutines past process exit in tests. - Version floor: The example uses
signal.NotifyContext, which requires Go 1.16+. On earlier versions, replace it with a manualsignal.Notify(make(chan os.Signal, 1), ...)channel and a custom cancellable context.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.