A long-running HTTP server has to stop eventually: a deploy, a config change, a crash, or a Ctrl+C during local dev. If you stop it the wrong way, in-flight requests die mid-write, payment confirmations never reach the client, and database transactions are abandoned half-done. Graceful shutdown is the pattern that lets the server stop accepting new work, finish what it already started, and exit cleanly.
http.ListenAndServe Isn't EnoughMost early Go HTTP examples look like this:
http.ListenAndServe blocks the main goroutine forever. There is no handle to the server, so there's no way to tell it "stop accepting connections, but finish the ones you have." When the process receives a kill signal, Go's runtime tears everything down right away. Any handler that was halfway through writing a response, charging a card, or updating stock just stops where it was.
For the kinds of work an e-commerce backend does, that's a real problem:
POST /orders, the server charges the card, then dies before writing the 201 Created response. The customer's money is gone but they never got an order ID.inventory table. The connection dies; the database eventually rolls it back, but other requests wait on those locks in the meantime.What we want is: stop the listener so no new connection is accepted, let the handlers that are already running finish (within reason), close auxiliary resources, then exit.
http.Server Struct and ShutdownThe fix is to build the server explicitly:
Now you have a handle. Run the listener in a goroutine, and keep main free to listen for signals:
The check for http.ErrServerClosed is important. After you call srv.Shutdown(ctx), ListenAndServe returns that exact sentinel error to signal a clean stop. Treat it as success. Any other error is a real failure (port in use, permission denied, listener crashed).
What srv.Shutdown(ctx) actually does, in order:
nil when everything is done, or ctx.Err() if the context is canceled first.Connections that have been "hijacked" (WebSockets, HTTP/2 server pushes you've taken over) are not tracked by Shutdown. You have to close those yourself. For a plain JSON API, this isn't a concern.
In RUNNING, the listener accepts new connections and handlers run normally. As soon as Shutdown is called, the server moves to DRAINING: no new connections, but existing handlers keep running. From there it either reaches STOPPED (everything finished in time) or FORCED (the shutdown context expired and the rest were dropped).
The kill signal usually comes from outside the process. On Linux, kill <pid> sends SIGTERM by default; Kubernetes does the same before falling back to SIGKILL after a grace period. Hitting Ctrl+C in a terminal sends SIGINT. You can catch both, but you can't catch SIGKILL (signal 9), the process just dies. That's why graceful shutdown only buys you anything when the orchestrator gives you advance notice.
The traditional way uses signal.Notify:
Since Go 1.16 there's a cleaner version that wraps the signal channel into a context:
signal.NotifyContext returns a context that's canceled when one of the listed signals arrives. This is nicer because the same ctx can be passed into other components (cache loaders, background workers) that need to know "we're shutting down." Prefer it for new code.
Cost: The buffered channel size of 1 in signal.Notify(c, ...) is intentional. Signals are dropped if the channel is full when delivered, so you must give it at least one slot. Don't use an unbuffered channel here.
After the signal fires, you give yourself a bounded window to drain:
The deadline is a trade-off. Too short and you cut off a long-running checkout that was almost done. Too long and the deploy is held up waiting for a single stuck connection, which makes rolling releases sluggish. 10 to 30 seconds is a common range. If you run behind Kubernetes, set the deadline shorter than terminationGracePeriodSeconds (default 30s), so your code controls the shutdown rather than the platform sending SIGKILL partway through.
Here's a complete program. It runs an order-placement endpoint that sleeps for 2 seconds to simulate payment processing, schedules its own SIGTERM after 2 seconds to keep the example self-contained, and shuts down cleanly with a 5-second deadline.
Walk through what happened. The server starts. After 500 ms a fake client hits /orders, and the handler begins its 2-second payment step. At the 2-second mark, the time.AfterFunc goroutine sends SIGTERM to the process. The signal context fires, main calls srv.Shutdown with a 5-second deadline. The listener closes immediately, but the in-flight handler is allowed to keep running. About 1.5 seconds later the handler finishes writing order placed, the client receives a clean 200 OK, and Shutdown returns. Total drain time was well under the 5-second budget.
If you changed the handler sleep to time.Sleep(10 * time.Second), Shutdown would return context.DeadlineExceeded after 5 seconds and the response would never reach the client. That's the cost-of-deadline trade-off in action.
Cost: Every second you add to the shutdown deadline is a second your deploy can't finish. In a busy service, this directly shapes how fast you can roll out a fix. Pick the smallest deadline that still lets typical requests complete.
A real service almost never has just an HTTP server. There's a database pool, maybe a queue consumer pulling order events, and a background worker that retries failed payment captures. They all need to stop in a sensible order.
The rule of thumb: stop accepting new work first, then close the things that in-flight work depends on. For a typical order API:
srv.Shutdown). This drains in-flight requests.Closing the database pool first is a classic bug. If the pool closes while a request is still mid-transaction, that request crashes with a confusing "use of closed connection" error, and the customer sees a 500 even though the server was trying to be helpful.
You can sequence them by hand:
Or run them in parallel using errgroup.WithContext when the order doesn't matter (for example, two independent HTTP servers on different ports):
errgroup.WithContext gives you a context that cancels as soon as any of the goroutines returns an error, so a failure in one shutdown signals the rest to give up too. For sequential dependencies (HTTP, then queue, then DB), keep them sequential. Don't use goroutines when the order is what makes the cleanup correct.
A short tour of the shutdown bugs that show up the most.
Bug 1: Ignoring `Shutdown`'s return value.
This makes Shutdown non-blocking and then immediately exits, killing everything anyway. Shutdown is meant to be called synchronously so main can wait for it before letting the program return.
Bug 2: Forgetting to check `http.ErrServerClosed`.
After a clean stop, this prints listen: http: Server closed and calls log.Fatalf, which calls os.Exit(1). Your "successful" deploys all exit non-zero. Compare against the sentinel:
Bug 3: No deadline on the shutdown context.
context.Background() never cancels, so if any handler hangs forever (a goroutine stuck on a dead network call), Shutdown waits forever. The orchestrator eventually loses patience and sends SIGKILL. Always pair shutdown with context.WithTimeout.
Bug 4: Not passing the request context through to slow calls.
A handler that calls db.QueryContext(context.Background(), ...) instead of db.QueryContext(r.Context(), ...) won't get any benefit from the shutdown context propagating. Use r.Context() for downstream calls in handlers. When the client disconnects or the server shuts down, that context is canceled and the query can return early.
Bug 5: Holding global state in `init()` and closing it too early.
A package-level var db = mustConnect() is convenient but couples the database's lifetime to the binary's lifetime. There's no clean way to close it on shutdown unless you call db.Close() explicitly, in the right order, from main. Construct shutdown-aware dependencies in main and pass them down. It's more code, but the shutdown order becomes obvious.
10 quizzes