{} The Go Reference

Data · Web · Intermediate

Graceful Shutdown

Catching SIGINT/SIGTERM with signal.NotifyContext, draining in-flight requests via Server.Shutdown, a shutdown timeout, and closing resources in order.

Data Intermediate ⏱ 4 min read Complete

🚪 Analogy

Graceful shutdown is closing a shop for the night. You lock the front door so no new customers enter (stop the listener), but you let the people already inside finish checking out (drain in-flight requests) — you don’t shove them out mid-purchase. Only once the floor is empty do you switch off the lights and lock the safe (close the database). And you give it a deadline: if someone’s still browsing at midnight, you usher them out.

Signal to context

A modern Go server turns an OS signal into a cancelled context with signal.NotifyContext. You wait on ctx.Done(), then begin an orderly shutdown:

graph LR
SIG["SIGINT / SIGTERM"] --> CTX["signal.NotifyContext<br/>cancels ctx"]
CTX --> STOP["1. stop accepting<br/>(Server.Shutdown)"]
STOP --> DRAIN["2. drain in-flight<br/>(wait, up to timeout)"]
DRAIN --> CLOSE["3. close DB &<br/>resources"]
func main() {
	// Cancel ctx on the first SIGINT or SIGTERM.
	ctx, stop := signal.NotifyContext(context.Background(),
		os.Interrupt, syscall.SIGTERM)
	defer stop()

	srv := &http.Server{Addr: ":8080", Handler: mux}

	// Run the server in its own goroutine so main can wait on the signal.
	go func() {
		// ListenAndServe returns ErrServerClosed on graceful shutdown —
		// that's expected, not a failure.
		if err := srv.ListenAndServe(); err != nil &&
			!errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("listen: %v", err)
		}
	}()
	log.Println("server up on :8080")

	<-ctx.Done() // block until a signal arrives
	log.Println("shutdown signal received")
	stop()       // restore default signal handling (a 2nd Ctrl-C now kills)

	gracefulShutdown(srv, db)
}

Drain with a timeout, then close in order

Server.Shutdown(ctx) stops new connections and waits for active requests — but you must bound the wait with a timeout so a stuck handler can’t hang the process forever. Then close the rest, outermost-first:

func gracefulShutdown(srv *http.Server, db *sql.DB) {
	// Give in-flight requests up to 15s to finish.
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	// 1 + 2: stop accepting, drain in-flight (returns when drained OR ctx expires).
	if err := srv.Shutdown(ctx); err != nil {
		log.Printf("forced shutdown: %v", err) // deadline hit; some requests cut off
	}

	// 3: now that no requests are running, close downstream resources.
	if err := db.Close(); err != nil {
		log.Printf("db close: %v", err)
	}
	log.Println("shutdown complete")
}

The ordering is the whole point: closing db before srv.Shutdown returns would yank the database out from under requests still finishing. Stop the door, empty the floor, then turn off the lights.

Simulate the shutdown sequence

You can model this whole dance with a context and a worker that selects on ctx.Done() — no real server, no signals, fully deterministic. Quick requests drain; a slow one is aborted when the deadline hits:

shutdown.go — editable & runnable
package main

import (
"context"
"fmt"
"sort"
"time"
)

// worker simulates an in-flight request. It races its own work against the
// shutdown deadline: whichever fires first decides the outcome.
func worker(ctx context.Context, id int, work time.Duration, done chan<- string) {
select {
case <-time.After(work):
	done <- fmt.Sprintf("request %d: completed", id)
case <-ctx.Done():
	done <- fmt.Sprintf("request %d: aborted (%v)", id, ctx.Err())
}
}

func main() {
// In a real server this ctx is cancelled by signal.NotifyContext on
// SIGINT/SIGTERM, then Server.Shutdown drains within a timeout. Here we
// simulate only the shutdown DEADLINE with a timeout: no signals, no
// network, fully deterministic.
const drainTimeout = 100 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), drainTimeout)
defer cancel()

fmt.Println("1. stop accepting new connections")

// Two quick requests finish within the window; one slow request outlives
// the deadline and is aborted. Wide gaps keep this deterministic.
type req struct {
	id   int
	work time.Duration
}
reqs := []req{
	{1, 10 * time.Millisecond},
	{2, 20 * time.Millisecond},
	{3, 10 * time.Second}, // never finishes before the deadline
}

done := make(chan string, len(reqs))
for _, r := range reqs {
	go worker(ctx, r.id, r.work, done)
}

fmt.Println("2. drain in-flight requests:")
results := make([]string, 0, len(reqs))
for range reqs {
	results = append(results, <-done)
}
sort.Strings(results) // deterministic order regardless of scheduling
for _, r := range results {
	fmt.Println("   -", r)
}

fmt.Println("3. close database and other resources")
fmt.Println("shutdown complete")
}

⚠️ ErrServerClosed is normal; always bound the drain

Two things trip people up. (1) ListenAndServe and Serve return http.ErrServerClosed after a graceful Shutdown — that is the success signal, so check errors.Is(err, http.ErrServerClosed) and don’t treat it as a crash. (2) Never call Shutdown with a context that has no deadline: a single wedged handler would block forever. Wrap it in context.WithTimeout, and after the deadline accept that some requests get cut off — that’s the bargain. Run the real signal/server code locally; the Playground above models the sequence safely in-process.

See also

  • contextsignal.NotifyContext and the cancellation that drives shutdown.
  • HTTP server — the http.Server whose Shutdown drains requests.
  • SQL transactions — in-flight transactions roll back on context cancel.
  • project layout & DI — where the resources you close in order are wired up.

Next: head back to the Networking & Web overview, or dig into the tool behind all this — context.

Check your understanding

Score: 0 / 5

1. What's the modern way to turn SIGINT/SIGTERM into a cancellable context?

signal.NotifyContext (Go 1.16+) returns a context that is cancelled the first time one of the listed signals is received, plus a stop func to release the handler. You wait on ctx.Done(), then begin shutdown — cleaner than wiring a raw signal channel yourself.

2. What does http.Server.Shutdown(ctx) do?

Shutdown gracefully drains: it closes the listeners so no new connections are accepted, then waits for active requests to complete. Pass a context.WithTimeout so draining can't hang forever — if the deadline passes, Shutdown returns and you can force-close. Note ListenAndServe returns http.ErrServerClosed, which is the expected, non-error signal.

3. What's the correct ordering during shutdown?

Order matters: if you close the database before in-flight requests finish, those requests fail mid-flight. Stop the listener (Server.Shutdown) so no new work arrives, let active handlers complete, and only then close the DB pool, flush logs, and release other resources — outermost-first, like unwinding the stack.

4. Why run ListenAndServe in a goroutine and block on <-ctx.Done() in main?

ListenAndServe doesn't return until the server is shut down, so if main called it directly there'd be nowhere to wait for the signal. Run it in a goroutine; main blocks on <-ctx.Done() (the signal context), then drives the graceful Shutdown.

5. Why call stop() (NotifyContext's second return) right after the signal arrives?

After the first signal cancels the context you've started draining. Calling stop() releases the signal handler so the default behavior returns — meaning an impatient operator can hit Ctrl-C again to terminate immediately if the graceful drain is taking too long. (defer stop() also covers the normal exit path.)

Comments

Sign in with GitHub to join the discussion.