Graceful Shutdown & Signal Handling
Handle SIGTERM and SIGINT so HTTP servers finish in-flight requests, background workers stop cleanly, and Kubernetes rolling deploys do not drop active connections.
Search across all documentation pages
Handle SIGTERM and SIGINT so HTTP servers finish in-flight requests, background workers stop cleanly, and Kubernetes rolling deploys do not drop active connections.
Orchestrators send SIGTERM before killing a container.
http.Server.Shutdown stops accepting new connections and waits for active requests until a context deadline.
Background goroutines need explicit cancel via context.Context or close channels.
golang.org/x/sync/errgroup coordinates multiple listeners and workers shutting down together.
Quick-reference recipe card - copy-paste ready.
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = server.Shutdown(ctx)When to reach for this:
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"golang.org/x/sync/errgroup"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
mux := http.NewServeMux()
mux.HandleFunc("GET /work", func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(2 * time.Second):
w.Write([]byte("done"))
case <-r.Context().Done():
return
}
})
api := &http.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
admin := &http.Server{Addr: ":9090", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("metrics"))
})}
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error {
slog.Info("api listening", "addr", api.Addr)
if err := api.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
})
g.Go(func() error {
slog.Info("admin listening", "addr", admin.Addr)
if err := admin.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
})
g.Go(func() error {
<-gctx.Done()
slog.Info("shutdown started")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := api.Shutdown(shutdownCtx); err != nil {
slog.Error("api shutdown", "err", err)
}
if err := admin.Shutdown(shutdownCtx); err != nil {
slog.Error("admin shutdown", "err", err)
}
slog.Info("shutdown complete")
return nil
})
if err := g.Wait(); err != nil {
slog.Error("server error", "err", err)
os.Exit(1)
}
}What this demonstrates:
signal.NotifyContext cancels root context on SIGTERMShutdown on each serverr.Context() cancellation during drainListenAndServe blocks until Shutdown closes the listener.Shutdown marks server closed, closes idle connections, and waits for active handlers.r.Context().Done() to exit promptly when clients disconnect.| Step | Action |
|---|---|
| 1 | Stop accepting new HTTP/gRPC work (Shutdown) |
| 2 | Cancel worker context so consumers stop polling |
| 3 | Wait for in-flight handler and worker completion |
| 4 | Close DB pools and clients |
| 5 | Flush telemetry (TracerProvider.Shutdown) |
| Source | Typical value |
|---|---|
| App shutdown context | 10-30s |
Kubernetes terminationGracePeriodSeconds | 30s default |
| Load balancer deregistration delay | Account in total budget |
// Workers tied to shutdown context
func runWorker(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
processOneJob(ctx)
}
}
}os.Exit without Shutdown - Drops active requests on deploy; always drain first.Shutdown on main without goroutine - Cannot handle signal on same thread if not structured correctly.http.ErrServerClosed - Normal after Shutdown; treat as success not failure.| Alternative | Use When | Don't Use When |
|---|---|---|
signal.NotifyContext | Modern Go 1.16+ services | You need legacy signal channel patterns |
| Manual signal channel | Fine-grained control over signals | Simpler NotifyContext suffices |
errgroup | Multiple servers or workers | Single ListenAndServe in tiny binaries |
Close instead of Shutdown | Immediate hard stop (tests only) | Production deploys |
Shutdown drains active requests gracefully.
Close closes connections immediately.
Set readiness to false (or implement preStop hook sleep) before calling Shutdown so endpoints drain.
Often yes - cancel a shared root context on SIGTERM so all subsystems stop together.
Log remaining work, optionally call Close on the server, and exit non-zero for observability.
Long-lived connections need explicit close handling in handlers; default drain may not end them quickly.
Start server in a goroutine, send SIGTERM to self in test, assert handler completes within timeout.
Use grpc.Server.GracefulStop() with a timeout fallback to Stop() for forced termination.
Common for log rotation reload configs; separate from deploy SIGTERM handling.
Call once per server instance; duplicate calls return ErrServerClosed.
It propagates the first error and waits for all goroutines, useful when admin and API servers run together.
Log signal received, drain start, per-subsystem completion, and final exit for deploy correlation.
Short CLIs often exit on context cancel only.
Long-running daemons and servers need full drain patterns.
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).
Reviewed by Chris St. John·Last updated Jul 18, 2026