Right, so you’ve got your server running. It’s handling requests, serving cat pictures, whatever. Now imagine you need to stop it. You hit Ctrl+C. What happens? If you’re not careful, it drops everything and vanishes like a thief in the night. Active connections are severed mid-download, database writes are abandoned, and you’re left with a corrupted state and a bunch of very confused users. Not cool.

We do things properly here. We do graceful shutdown. This means we tell the server, “Hey, finish up what you’re doing, but no new stuff, and then we can go.” The net/http package gives us the tools for this, but you have to wire it up yourself. It’s not magic, it’s just good manners.

The Core Mechanics: context and server.Shutdown()

The entire dance revolves around two things: the context.Context for cancellation and the http.Server.Shutdown() method. Here’s the basic, no-frills setup.

package main

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

func main() {
    // Your fancy router setup
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(5 * time.Second) // Simulate a long request
        fmt.Fprintf(w, "Hello, slow world!\n")
    })

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

    // Run the server in a goroutine so it doesn't block our shutdown logic
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            fmt.Printf("ListenAndServe error: %v\n", err)
        }
    }()

    // Wait for an interrupt signal (Ctrl+C)
    stop := make(chan os.Signal, 1)
    signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
    <-stop // Block here until we get the signal

    fmt.Println("\nShutting down gracefully...")

    // Create a context with a timeout for the shutdown
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel() // Important: release context resources ASAP

    if err := srv.Shutdown(ctx); err != nil {
        fmt.Printf("Server forced to shutdown: %v\n", err)
        // If Shutdown fails, you might want to Close() forcefully
        srv.Close()
    }

    fmt.Println("Server exited")
}

Here’s the play-by-play:

  1. We start the server in a goroutine. Notice we check for http.ErrServerClosed. This is the good error. It’s how Shutdown() tells ListenAndServe() to stop.
  2. We create a channel stop and tell the signal package to notify us on os.Interrupt (Ctrl+C) or SIGTERM (e.g., from kill).
  3. We block, waiting for that signal. The user is now in control of when to stop.
  4. Upon signal, we create a context with a timeout. This is your safety net. It says, “I’ll wait at most 10 seconds for graceful shutdown before I panic and call the whole thing off.”
  5. We call srv.Shutdown(ctx). This is the maestro. It immediately stops accepting new connections and then waits for all existing connections to become idle. Only then does it close them.
  6. Finally, we defer cancel() because it’s good practice to free those context resources the moment we’re done, even if the timeout hasn’t been reached.

Why the Context Timeout is Non-Negotiable

You absolutely must set a timeout on your shutdown context. Why? Because you cannot wait forever. What if you have a stubborn connection that’s holding onto a websocket or is just genuinely slow? You can’t let your shutdown process hang indefinitely, preventing your process from restarting or your orchestration system (like Kubernetes) from marking the pod as dead.

The timeout is your get-out-of-jail-free card. After, say, 10 seconds, Shutdown() will return a context.DeadlineExceeded error. At that point, you’ve done your best. You log the error loudly, and you might follow up with a more forceful srv.Close(), which just kills everything. It’s the difference between asking everyone to leave the party and turning all the lights off and setting off the fire alarm.

Handling Long-Running Requests and Background Work

This is the big gotcha. Shutdown() is brilliant at managing connections, but it’s blissfully unaware of what your handlers are doing on those connections. If your handler is in the middle of a 10-minute calculation or waiting on a remote API, Shutdown() will patiently wait for that request to finish writing its response and become idle.

You need to cooperate. Inside any long-running handler, you must listen to the request’s Context().

func longRunningHandler(w http.ResponseWriter, r *http.Request) {
    // Get the request context
    ctx := r.Context()

    select {
    case <-time.After(30 * time.Second):
        // This is the happy path: the work completed
        w.Write([]byte("Work done!"))
    case <-ctx.Done():
        // The client disconnected OR the server is shutting down.
        // Stop what you're doing immediately.
        fmt.Println("Request cancelled:", ctx.Err())
        http.Error(w, "Operation cancelled", http.StatusServiceUnavailable)
        return
    }
}

When Shutdown() is called, it internally cancels the base context of the server. This cancellation propagates to every single active request’s context. Your handler’s r.Context().Done() channel will close, triggering the case <-ctx.Done(): branch. This is your signal to drop everything, clean up, and return. This cooperation is what makes a shutdown truly graceful.

The Final Gotcha: Don’t Forget signal.Notify

It’s a small line but a crucial one: signal.Notify(stop, os.Interrupt, syscall.SIGTERM). If you don’t set this up, your default signal channel will just kill the program with extreme prejudice, bypassing your entire graceful shutdown routine. This line says, “I’ll handle these signals myself, thank you very much.” Also, note we use a buffered channel (1). The OS signal sender doesn’t block, and if your channel is full, it might drop the signal. A buffer of 1 is the standard way to avoid this tiny but critical race condition.