Home

Graceful Shutdown in Go: A Deep Dive

14 min read
Table of contents
Title card reading 'Graceful Shutdown in Go' over the kicker 'Essential Go · Backend Engineering', with the Go gopher mascot.

1. Why graceful shutdown exists

A server process rarely dies of natural causes. Something tells it to stop: you press Ctrl+C on your laptop, systemd restarts the unit, or — most commonly in production — a container orchestrator like Kubernetes sends a SIGTERM because it's rolling out a new version, rescheduling the pod, or scaling down.

The naive response to "stop" is to exit immediately. That's a bad idea, because at the instant the signal arrives your server is almost never idle. There are HTTP requests halfway through writing a response, database transactions mid-flight, gRPC calls streaming data, WebSocket clients connected. If you exit instantly you get:

Graceful shutdown is the disciplined alternative: stop accepting new work, let existing work finish, then exit — but don't wait forever. That last clause matters. Orchestrators give you a grace period (Kubernetes defaults to 30 seconds) and then send an un-ignorable SIGKILL. A graceful shutdown that hangs indefinitely just gets killed anyway, so "graceful" always means "graceful, bounded by a timeout."

Every pattern in this article is a variation on that single sentence. What changes is the mechanism each server type gives you to drain in-flight work.


2. The building blocks

Before the patterns, four Go primitives that show up in all of them.

Channels as the coordination bus

A channel is a typed pipe between goroutines. Graceful shutdown uses channels to answer two questions concurrently: "did the server fail on its own?" and "did someone ask us to stop?" Each question gets a channel.

Buffering matters here for correctness, not just performance. A channel created with make(chan T, 1) has room for one value, so a send into it succeeds immediately even if no one is currently receiving. That single slot prevents two specific bugs we'll see below: leaked goroutines and dropped signals.

OS signals

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)

signal.Notify intercepts the named OS signals and delivers them to your channel instead of letting them kill the process with their default behavior. os.Interrupt is Ctrl+C (SIGINT); syscall.SIGTERM is the polite "please stop" that orchestrators send first.

The channel must be buffered. The signal package sends to it non-blockingly: if the channel is full or unbuffered with no ready receiver at the exact moment the signal fires, the signal is silently dropped. The buffer of 1 guarantees you catch the first signal even if you're momentarily busy.

context.Context as the shutdown broadcast

A context carries a cancellation signal across API boundaries and goroutines. context.WithCancel gives you a context plus a cancel function; calling cancel() closes the context's Done() channel, and every goroutine selecting on ctx.Done() wakes up at once. This turns "shutdown requested" into a one-to-many broadcast — one call, and every listener knows. context.WithTimeout is the same thing with an automatic cancel() after a deadline, which is exactly how you bound the drain.

select — waiting on several things at once

select {
case err := <-serverErrors:   // server died on its own
case sig := <-shutdown:        // someone asked us to stop
}

select blocks until one of its cases can proceed, then runs that one. It's how the main goroutine parks — using zero CPU — until either the server crashes or a shutdown signal arrives, and reacts to whichever happens first.

With those four in hand, the patterns are just different arrangements of the same parts.


3. Pattern A — the HTTP server

This is the canonical shape and the best one to understand first.

serverErrors := make(chan error, 1)

go func() {
    log.Printf("Server listening on %s", server.Addr)
    serverErrors <- server.ListenAndServe()
}()

shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)

select {
case err := <-serverErrors:
    log.Printf("Error starting server: %v", err)

case sig := <-shutdown:
    log.Printf("Server is shutting down due to %v signal", sig)

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        log.Printf("Could not stop server gracefully: %v", err)
        server.Close()
    }
}

Serving in a goroutine

server.ListenAndServe() blocks forever — it only returns on a fatal error or when the server is shut down. If you called it directly on main's goroutine you'd never reach the signal-handling code. So it runs in its own goroutine, and whatever it eventually returns is pushed onto serverErrors.

The make(chan error, 1) buffer is doing real work. Suppose the shutdown path runs first, we've moved past the select, and then ListenAndServe returns (which it does — Shutdown causes it to return http.ErrServerClosed). With an unbuffered channel that send would block forever because no one is receiving, and the goroutine would leak. The one-slot buffer lets the send complete and the goroutine exit cleanly.

The two ways out

The select waits for whichever comes first:

The graceful teardown

server.Shutdown(ctx) is the heart of HTTP graceful shutdown. It immediately stops accepting new connections, then waits for all in-flight requests to complete, then closes idle keep-alive connections and returns nil.

The timeout context is the guardrail. context.WithTimeout(..., 10*time.Second) gives in-flight requests up to ten seconds. If they all finish in time, Shutdown returns nil and you're done cleanly. If ten seconds pass and requests are still running, the context expires, Shutdown returns a non-nil error, and you fall into the if — where server.Close() is the hard stop that rips every remaining connection down. You've been patient; now you're leaving.

defer cancel() releases the context's resources no matter which branch runs. Even though the timeout fires on its own, failing to call cancel() leaks the context — defer makes it unconditional.

A subtlety: once Shutdown is called, ListenAndServe returns http.ErrServerClosed. That's the expected signal that shutdown began — not a real failure. Robust versions of this pattern check for it explicitly (if err != nil && err != http.ErrServerClosed) rather than logging it as an error.


4. Pattern B — long-lived connections and the bare for {}

HTTP request handlers are short: read request, write response, return. WebSockets are the opposite — a single connection stays open for minutes or hours, and messages arrive whenever the client feels like sending them. That changes how a handler is written, and it interacts with shutdown.

func handleRidersWebSocket(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Printf("WebSocket upgrade failed: %v", err)
        return
    }
    defer conn.Close()

    userID := r.URL.Query().Get("userID")
    if userID == "" {
        log.Println("No user ID provided")
        return
    }

    for {
        _, message, err := conn.ReadMessage()
        if err != nil {
            log.Printf("Error reading message: %v", err)
            break
        }
        log.Printf("Received message: %s", message)
    }
}

Why the for has no condition

Go has exactly one loop keyword, for, and it takes several shapes:

for i := 0; i < 10; i++ {}   // classic counted loop
for cond {}                  // condition-only — Go's "while"
for {}                       // no condition — loop forever

for {} is the infinite loop — Go's spelling of while (true). There's no expression to evaluate as false, so it never terminates on its own; only something inside the body (break, return, panic) ends it.

That's the right shape here because a WebSocket has no known message count. You can't write for i := 0; i < N — there is no N. You want to keep reading for as long as the connection lives. conn.ReadMessage() blocks, so the loop isn't spinning the CPU; it sleeps inside ReadMessage and wakes only when a message arrives or the connection breaks. When ReadMessage returns an error — client disconnected, network dropped — you break, control leaves the function, and the deferred conn.Close() cleans up. The loop reads as: "process messages forever, until the connection dies from within."

How this connects to shutdown

Long-lived connections are the hard case for graceful shutdown, because "let in-flight work finish" is ambiguous — a WebSocket that stays open for an hour will never "finish" on its own. In production you typically wire the server's shutdown into these loops so they can be told to stop: pass a context into the handler and select on both the next message and ctx.Done(), or set a read deadline, so that when shutdown begins you send a WebSocket close frame and break out. The takeaway: the bare for {} is correct for the connection's normal life, but a fully graceful server needs a way to interrupt that loop when it's time to drain.


5. Pattern C — the gRPC server

gRPC brings its own server type and its own graceful-stop method, and this example coordinates shutdown a little differently — via context.WithCancel instead of a select over two channels. Worth understanding both because you'll see each in the wild.

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    // 1. Signal watcher goroutine → cancels the context
    go func() {
        sigCh := make(chan os.Signal, 1)
        signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
        <-sigCh
        cancel()
    }()

    lis, err := net.Listen("tcp", GrpcAddr)
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }

    grpcServer := grpcserver.NewServer()
    log.Printf("Starting gRPC server Trip service on port %s", lis.Addr().String())

    // 2. Serve in a goroutine → cancels the context on failure
    go func() {
        if err := grpcServer.Serve(lis); err != nil {
            log.Printf("failed to serve: %v", err)
            cancel()
        }
    }()

    // 3. Block until the context is cancelled, then drain
    <-ctx.Done()
    log.Println("Shutting down the server...")
    grpcServer.GracefulStop()
}

The context is the single source of truth

Instead of a select waiting on two channels, this version makes one context the rendezvous point for every reason to shut down. Two independent goroutines can trigger it, and main waits on the one thing they both feed.

The signal watcher (block 1) creates its own buffered signal channel, blocks on <-sigCh until SIGINT/SIGTERM arrives, and then calls cancel(). Its entire job is to translate "OS asked us to stop" into "cancel the context." Note the buffered make(chan os.Signal, 1) — same reason as always, so the signal isn't dropped.

The serve goroutine (block 2) runs grpcServer.Serve(lis), which blocks while serving. If it ever returns an error (it failed to serve, or the listener broke), that goroutine also calls cancel(). This is the gRPC equivalent of the HTTP serverErrors channel — a server that dies on its own must also trip the shutdown, otherwise main would wait forever for a signal that isn't coming.

main waits on ctx.Done()

<-ctx.Done()

This is the payoff of routing everything through one context. main blocks here doing nothing until the context is cancelled — and it gets cancelled by either trigger: a shutdown signal, or the server failing. Whichever happens, <-ctx.Done() unblocks and execution proceeds to the drain. It plays the same role the select did in the HTTP pattern, but the "OR" is expressed by two goroutines calling the same cancel() rather than by two channel cases.

GracefulStop()

grpcServer.GracefulStop()

This is gRPC's drain. It stops the server from accepting new connections and new RPCs, then blocks until all in-flight RPCs complete, and returns once they're done. Streaming RPCs are allowed to finish, pending unary calls run to completion, and only then does the process fall off the end of main and exit.

Where's the timeout?

Here's the one real weakness of this example, and it's worth calling out as a teaching point: GracefulStop() has no built-in timeout. If some RPC hangs — a stuck stream, a client that never finishes — GracefulStop() blocks forever, and your "graceful" shutdown never completes. In production you bound it yourself. The idiomatic fix is to race GracefulStop against a timer and fall back to the hard Stop():

<-ctx.Done()
log.Println("Shutting down the server...")

stopped := make(chan struct{})
go func() {
    grpcServer.GracefulStop()
    close(stopped)
}()

select {
case <-stopped:
    log.Println("gRPC server stopped gracefully")
case <-time.After(10 * time.Second):
    log.Println("Graceful stop timed out; forcing shutdown")
    grpcServer.Stop() // hard stop — the gRPC analogue of server.Close()
}

Stop() is to GracefulStop() what server.Close() is to server.Shutdown(): the impatient hard kill you fall back to when the deadline passes. Adding this closes the gap between the HTTP pattern (which has its timeout built into Shutdown(ctx)) and the gRPC one (which doesn't).


6. HTTP vs gRPC: two coordination styles, same idea

The two patterns look different but are structurally the same. It's worth seeing the mapping directly.

ConcernHTTP patterngRPC pattern
"Server died on its own"send onto serverErrors channelserve goroutine calls cancel()
"Someone asked us to stop"signal onto shutdown channelwatcher goroutine calls cancel()
Wait for eitherselect over two channels<-ctx.Done() (both call one cancel)
Drain in-flight workserver.Shutdown(ctx)grpcServer.GracefulStop()
Bound the draintimeout context (built in)you must add it (race vs time.After)
Hard fallbackserver.Close()grpcServer.Stop()

Two legitimate idioms for the same problem. The select-over-channels style keeps every branch visible in one place and is great when the branches do different things. The context-cancellation style shines when many goroutines need to hear "we're shutting down" — because cancel() is a broadcast, you can have any number of triggers and any number of listeners without threading channels between them. In a real service you often use both: a context to broadcast shutdown across the whole app, and a select with a timeout to bound each individual drain.


7. Common pitfalls

Unbuffered signal channels. make(chan os.Signal) with no buffer can drop the signal if you're not receiving at the exact instant it fires. Always make(chan os.Signal, 1).

Forgetting the "server died" path. If you only wait for a shutdown signal and the server crashes on startup (port in use), your process blocks forever waiting for a signal that never comes. Both patterns above guard against this — make sure yours does too.

A drain with no timeout. GracefulStop() and a bare server.Shutdown(context.Background()) will both wait indefinitely for stuck work. Always bound the drain with a real deadline and a hard-stop fallback.

Long-lived connections that never drain. WebSockets and server-side streams won't finish on their own. Give their loops a way to be interrupted (a context, a read deadline, a close frame) or your graceful shutdown will always hit the timeout.

Leaking the context. Always defer cancel() on any context you create with WithCancel/WithTimeout, even when the timeout would fire anyway.

Ignoring http.ErrServerClosed. After Shutdown, ListenAndServe returns this sentinel. It means shutdown started — treat it as normal, not as an error to alert on.


8. A production checklist

A server that shuts down cleanly in production usually does all of this:

  1. Catches SIGINT and SIGTERM via a buffered signal channel.
  2. Routes "shutdown requested" and "server failed" into one place (a select or a shared context).
  3. Stops accepting new work immediately on shutdown (Shutdown / GracefulStop both do this first).
  4. Drains in-flight work — finishes open requests, RPCs, and streams.
  5. Bounds the drain with a timeout matched to the orchestrator's grace period (Kubernetes: under 30s), and hard-stops if it's exceeded.
  6. Interrupts long-lived connections explicitly rather than waiting for them to end naturally.
  7. Cleans up other resources after the drain — closes DB pools, flushes buffers, closes message-queue connections — in reverse order of how they were opened.
  8. Logs each phase so you can see, in production, exactly where a slow shutdown is spending its time.

Graceful shutdown is one of those things that's invisible when it works and very visible when it doesn't — every failed deploy with a spike of 502s is usually a shutdown that wasn't graceful. The patterns here are small, but getting them right is a large part of what separates a toy server from one you can safely roll out a hundred times a day.

Graceful Shutdown in Go: A Deep Dive