The Problem

I've spent the last few years building services that must stay responsive while they finish in‑flight work. The moment a deployment rolls out or a container receives a SIGTERM, the process has a narrow window to stop accepting new requests and let the current ones complete. Getting this wrong leads to dropped connections, lost data, and angry on‑call pages.

The Pattern

The standard library gives us everything we need: context.Context for cancellation, signal.Notify for OS signals, and http.Server.Shutdown for a clean HTTP drain. The trick is wiring them together once, in a reusable bootstrap function, so every service gets the same guarantees without copy‑pasting boilerplate.

Implementation

Below is the pattern I drop into every new Go project. It lives in a tiny package called graceful and exports a single Run function that takes the HTTP handler and a couple of time‑outs.

package graceful

import (
	"context"
	"net/http"
	"os"
	"os/signal"
	"time"
)

// Run starts the HTTP server and blocks until a shutdown signal is received.
// It returns an error only if the server fails to start or the graceful shutdown
// exceeds the provided timeout.
func Run(handler http.Handler, addr string, shutdownTimeout time.Duration) error {
	// Create a root context that will be cancelled on SIGINT or SIGTERM.
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
	defer stop()

	// Configure the server with sensible defaults.
	srv := &http.Server{
		Addr:              addr,
		Handler:           handler,
		ReadHeaderTimeout: 5 * time.Second,
		IdleTimeout:       120 * time.Second,
	}

	// Start the server in a goroutine so we can wait for the context.
	errCh := make(chan error, 1)
	go func() {
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			errCh <- err
		}
	}()

	// Block until the context is cancelled (signal received).
	<-ctx.Done()

	// Create a new context with a timeout for the actual shutdown.
	shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
	defer cancel()

	// Ask the server to stop accepting new connections and finish existing ones.
	if err := srv.Shutdown(shutdownCtx); err != nil {
		return err
	}

	// If ListenAndServe returned an error other than ErrServerClosed, surface it.
	select {
	case err := <-errCh:
		return err
	default:
		return nil
	}
}

Why It Works

The code does three things: creates a root context that is cancelled on SIGINT/SIGTERM, starts the server in a goroutine, and then blocks on <-ctx.Done(). When the signal arrives the context is cancelled, server.Shutdown is called with a configurable timeout, and the function returns only after all connections have closed or the timeout expires.

Why does this work better than a naive signal.Notify + os.Exit? First, context propagates cancellation to any downstream work — database queries, external API calls, background workers — without each component knowing about signals. Second, http.Server.Shutdown respects the ReadHeaderTimeout and IdleTimeout you already set, so you don't need a separate drain loop. Third, the whole thing is testable: you can inject a fake signal channel in unit tests and verify the shutdown path.

Variations and Tips

  • If you run multiple servers (e.g., gRPC + HTTP) just create a slice of *http.Server and call Shutdown on each in an errgroup.Group.
  • Pass a custom context.Context into Run when you need to coordinate shutdown across several subsystems (e.g., a message consumer that also respects the same deadline).
  • Set shutdownTimeout based on your longest expected request latency plus a safety margin; 30 seconds is a good starting point for most APIs.
  • Log the shutdown phase (signal received, shutdown started, shutdown completed) at Info level so on‑call engineers can see the timeline in aggregated logs.
  • For containerised workloads, make sure the platform’s terminationGracePeriodSeconds exceeds your shutdownTimeout; otherwise the runtime will SIGKILL the process before graceful drain finishes.
Remember: the goal is not just to stop the process, but to finish the work that is already in flight. A well‑behaved shutdown turns a deployment from a risky event into a non‑event.

Adopting this tiny abstraction has saved me countless late‑night pages and makes every new service start on the right foot. Give it a try in your next Go project — you’ll appreciate the predictability when the next rolling update hits.