Graceful shutdown for HTTP servers in Go
1. Overview
When deploying a new version or scaling down, your process receives SIGTERM. If the server exits immediately, in-flight requests are interrupted. Clients get connection reset, and orders might be only partially written.
This article guides you on how to properly shut down an HTTP server in Go. The server will stop accepting new connections, wait for in-flight requests to complete within a limited time window, then close resources in the correct order. All examples use only the standard library and require Go 1.22 or later.
2. Problems with abrupt server shutdown
Let’s start with the most familiar approach:
package main
import (
"fmt"
"log"
"net/http"
"time"
)
func main() {
http.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second) // simulating slow database query
fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
Run the server and call curl localhost:8080/orders/42. While the request is pending, press Ctrl+C in the server’s terminal. The client sees:
curl: (52) Empty reply from server
Go doesn’t install signal handlers for SIGINT or SIGTERM by default. When the signal is received, the runtime terminates the process immediately. No defer statements are executed and no requests are waited for.
3. Graceful shutdown with http.Server.Shutdown
http.Server has a built-in Shutdown(ctx) method. This method does three things in order:
- Closes all listeners, so the server stops accepting new connections.
- Closes idle connections.
- Waits for active connections to become idle, then closes them. If
ctxexpires first, the method returns the context’s error.
To use this method, you need to catch OS signals yourself.
3.1. Catching signals with signal.NotifyContext
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second)
fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
serverErr := make(chan error, 1)
go func() {
log.Println("listening on", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
serverErr <- err
}
}()
select {
case err := <-serverErr:
log.Fatalf("server error: %v", err)
case <-ctx.Done():
log.Println("shutdown signal received")
}
stop() // receiving Ctrl+C a second time will kill the process immediately
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown failed: %v, forcing close", err)
_ = srv.Close()
}
log.Println("server stopped")
}
Key points in this code:
signal.NotifyContextreturns a context. This context is canceled when the process receivesSIGINT(Ctrl+C) orSIGTERM(the signal sent by Docker and Kubernetes).ListenAndServeruns in a separate goroutine. After you callShutdown, it returnshttp.ErrServerClosedimmediately. This is expected, so we filter it out.- The
serverErrchannel has a buffer of 1. It helps catch startup errors, like when the port is already in use. This waymainwon’t hang waiting for a signal that never comes. stop()is called right after receiving the signal. This function restores the default behavior, so if you press Ctrl+C again, the process exits immediately. This is very useful when shutdown hangs.shutdownCtxusescontext.Background()as the base instead ofctx. The reason is thatctxis already canceled, so using it would causeShutdownto return an error immediately.- If 10 seconds pass and requests are still running,
srv.Close()forcefully closes all connections.
Repeat the test from section 2: call curl, then press Ctrl+C while the request is pending. Server logs:
2026/10/24 10:00:00 listening on :8080
^C2026/10/24 10:00:01 shutdown signal received
2026/10/24 10:00:03 server stopped
The client receives the complete response:
order 42
The server waits about 2 more seconds for the request to finish, then exits.
Note: A very common mistake is swapping the roles of the two goroutines. Some people call
ListenAndServeinmainandShutdownin another goroutine. BecauseListenAndServereturns immediately whenShutdownstarts,mainwill exit whileShutdownis still waiting for requests. The process exits and requests are still interrupted. The rule is thatmainmust wait forShutdownto return.
4. What Shutdown doesn’t handle automatically
4.1. Hijacked connections: WebSocket, HTTP/1.1 upgrades
Shutdown doesn’t track hijacked connections like WebSockets. The server doesn’t wait for them either. To close these connections, register a callback with RegisterOnShutdown:
type Hub struct {
mu sync.Mutex
conns map[*websocketConn]struct{}
}
func (h *Hub) CloseAll() {
h.mu.Lock()
defer h.mu.Unlock()
for c := range h.conns {
c.SendCloseFrame("server shutting down")
c.Close()
}
}
// in main:
hub := &Hub{conns: make(map[*websocketConn]struct{})}
srv.RegisterOnShutdown(hub.CloseAll)
websocketConn here represents the connection type of the WebSocket library you’re using. The callback runs in a separate goroutine as soon as Shutdown starts. Shutdown doesn’t wait for this callback to finish, so if you need to wait, synchronize with sync.WaitGroup.
4.2. Long-running handlers: streaming, long polling
A handler that streams data indefinitely, like Server-Sent Events, will never return to idle. In that case, Shutdown always waits until the timeout. You can use BaseContext to cancel all request contexts when the server starts shutting down:
appCtx, cancelApp := context.WithCancel(context.Background())
srv := &http.Server{
Addr: ":8080",
Handler: mux,
BaseContext: func(net.Listener) context.Context {
return appCtx
},
}
srv.RegisterOnShutdown(cancelApp)
mux.HandleFunc("GET /orders/stream", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return // server is shutting down or client disconnected
case t := <-ticker.C:
fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
w.(http.Flusher).Flush()
}
}
})
Every request context is created from appCtx. When Shutdown runs, cancelApp is called and all r.Context() are canceled. The stream handler sees Done() and returns. Then Shutdown completes early.
Note: Don’t apply this to all handlers. If you cancel the context of a request writing an order to the database, the write operation itself will be canceled. The goal of “waiting for requests to finish” is no longer achieved. Only use
BaseContextthis way when all handlers can safely stop in the middle.
4.3. Background goroutines and auxiliary resources
Shutdown only manages HTTP connections. Email workers, Kafka consumers, and database connection pools are your responsibility. The closing order should be the reverse of the initialization order:
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("http shutdown: %v", err)
}
cancelWorkers() // signal background goroutines to stop
workersDone := make(chan struct{})
go func() { wg.Wait(); close(workersDone) }()
select {
case <-workersDone:
case <-shutdownCtx.Done():
log.Println("workers did not stop in time")
}
if err := db.Close(); err != nil {
log.Printf("db close: %v", err)
}
The HTTP server is shut down first because handlers still need the database. The database is closed last. The entire process shares the same shutdownCtx, so the total time doesn’t exceed the 10-second budget.
5. Running on Kubernetes
When a pod is deleted, Kubernetes does two things in parallel: sending SIGTERM to the container and removing the pod from the Service’s endpoint list. Removing the endpoint takes a few seconds to propagate to kube-proxy and the ingress. If the server closes the listener immediately upon receiving SIGTERM, requests still being routed to the pod will be rejected.
The fix is to have the readiness probe fail first, wait a bit, then call Shutdown:
var shuttingDown atomic.Bool
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
if shuttingDown.Load() {
http.Error(w, "shutting down", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
// after receiving the signal:
<-ctx.Done()
shuttingDown.Store(true)
time.Sleep(5 * time.Second) // wait for the load balancer to remove the pod
srv.Shutdown(shutdownCtx)
You also need to set terminationGracePeriodSeconds larger than the total wait time plus shutdown timeout. For this example, that’s 5 + 10 = 15 seconds:
spec:
terminationGracePeriodSeconds: 30
containers:
- name: order-service
image: order-service:1.4.0
readinessProbe:
httpGet:
path: /readyz
port: 8080
periodSeconds: 2
When the grace period ends, Kubernetes sends SIGKILL. This signal cannot be caught, and all your shutdown logic is ignored.
Note: If you run the binary through a shell script, like
CMD ./start.sh, the shell is PID 1 and might not forwardSIGTERMto the Go process. Use the exec formCMD ["/app/server"]or callexec /app/serverat the end of the script.
6. Testing shutdown behavior
You can write a test to confirm that Shutdown waits for in-flight requests to complete. This test needs only the testing package:
package main
import (
"context"
"io"
"net"
"net/http"
"testing"
"time"
)
func TestShutdownWaitsForInFlightRequest(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
started := make(chan struct{})
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
time.Sleep(500 * time.Millisecond)
io.WriteString(w, "order 42")
})}
go srv.Serve(ln)
type result struct {
body string
err error
}
resCh := make(chan result, 1)
go func() {
resp, err := http.Get("http://" + ln.Addr().String() + "/orders/42")
if err != nil {
resCh <- result{err: err}
return
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
resCh <- result{body: string(b), err: err}
}()
<-started // ensure the request has entered the handler
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
t.Fatalf("Shutdown returned error: %v", err)
}
res := <-resCh
if res.err != nil {
t.Fatalf("request failed: %v", res.err)
}
if res.body != "order 42" {
t.Fatalf("got body %q, want %q", res.body, "order 42")
}
if _, err := http.Get("http://" + ln.Addr().String()); err == nil {
t.Fatal("expected new connections to be refused after shutdown")
}
}
Key points in the test:
net.Listenon port0lets the OS pick an available port. This way tests can run in parallel without conflicts.- The
startedchannel ensuresShutdownis only called when the request is actually in the handler. - The test checks three things:
Shutdownreturnsnil, the client receives the full body, and new connections are rejected.
go test -v -run TestShutdown
=== RUN TestShutdownWaitsForInFlightRequest
--- PASS: TestShutdownWaitsForInFlightRequest (0.50s)
PASS
The test takes about 0.5 seconds, exactly the time the handler sleeps. This shows that Shutdown waited for the request.
7. Common mistakes
- Using an already-canceled context for
Shutdown. Passing thectxfromsignal.NotifyContextdirectly causesShutdownto returncontext canceledimmediately. - Treating
http.ErrServerClosedas an error. Callinglog.Fatalwith this error exits the process before shutdown completes. - Not setting a timeout. Using
context.Background()without a deadline lets a stuck handler keep the process alive until Kubernetes sendsSIGKILL. - Forgetting
ReadHeaderTimeout. Slow clients can keep connections in the active state, and shutdown always runs until timeout. - Using
os.Exitorlog.Fatalafter starting shutdown. Both skip alldeferstatements, so databases and files are not closed.
8. Conclusion
Graceful shutdown in Go requires three components: signal.NotifyContext to catch SIGTERM, srv.Shutdown with a timeout context, and main waiting for Shutdown to return. From that foundation, you add the parts that Shutdown doesn’t handle automatically. Those are RegisterOnShutdown for WebSockets, BaseContext for streaming handlers, and closing workers and databases in reverse order of initialization. When running on Kubernetes, have the readiness probe fail and wait a few seconds before closing the listener, and set terminationGracePeriodSeconds large enough. Finally, keep a test like the one in section 6 to catch when someone accidentally breaks the shutdown flow.