Why does ListenAndServe return http.ErrServerClosed as soon as Server.Shutdown begins?
answer
- closing the listener is step one of three
- one return means stopped accepting, not stopped serving
- the classic one-liner is log.Fatal on that error
- main must join the other goroutine
- errors.Is against a package-level sentinel
basics
~20 sThe first thing Server.Shutdown does is close the listeners, and that is what makes ListenAndServe return. It reports that accepting has stopped, not that the drain is done — the drain ends later, when Shutdown itself returns.
solid answer
~40 s`ListenAndServe` blocks in an accept loop. `Shutdown` closes the listeners as its very first step, the accept loop fails, and the server translates that into the sentinel `http.ErrServerClosed` instead of a raw socket error. So that return means "I stopped accepting", and the drain is still running inside the concurrent `Shutdown` call. Two consequences follow. First, `errors.Is(err, http.ErrServerClosed)` is the expected outcome of a clean stop, so it must not be logged as a failure or passed to `log.Fatal` — doing that exits the process mid-drain and truncates every in-flight report download, which is exactly what you were trying to avoid. Second, the program has to wait for `Shutdown` to return before exiting, usually by closing a channel after the `Shutdown` call and receiving from it in `main`.
code
go · 17 linesstop := make(chan struct{}) // closed when the deploy tooling asks us to drain
idle := make(chan struct{})
go func() {
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
_ = srv.Close()
}
close(idle)
}()
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("serve: %v", err)
}
<-idle // the drain is genuinely overgo deeper
Recognise http.ErrServerClosed as the normal, expected outcome of stopping a server, and know that logging it as fatal is a bug rather than a nuisance.
Explain the ordering: listeners close first, so the serve call unblocks before any request has drained, and the program has to wait on the Shutdown call instead.
Show the full wiring — sentinel check, a channel joining the shutdown goroutine, escalation on Shutdown's error — and say which return value each decision is made from.
Push for this shape to live in a shared service skeleton rather than being retyped per repo, since the failure is silent: deploys look clean while requests are being cut.
## Two returns, two meanings A Go HTTP service has two places where "the server stopped" surfaces, and they mean different things at different times. `ListenAndServe` (or `Serve`, or `ListenAndServeTLS`) sits in an accept loop. When `Shutdown` runs, its first action is to close the listeners so that no new connection can arrive. The accept call fails, and rather than handing you a raw "use of closed network connection" error, the server recognises that it is shutting down and returns the package-level sentinel `http.ErrServerClosed`. That happens within microseconds of the `Shutdown` call starting — long before any in-flight request has finished. `Shutdown` itself returns much later: after the listeners are closed, after the idle connections are closed, and after the last active connection has gone idle — or after its context deadline expires, whichever comes first. That return is the one that means the drain is over. ## The bug this shape produces The smallest Go HTTP program is ```go log.Fatal(http.ListenAndServe(":8080", mux)) ``` and people keep that shape when they add graceful shutdown. Now the sequence is: something asks the service to drain, a goroutine calls `srv.Shutdown(ctx)`, the listener closes, `ListenAndServe` returns `http.ErrServerClosed`, `log.Fatal` prints it and calls `os.Exit(1)`. The process dies while the drain is still in progress. Every report download that was mid-flight is truncated, and the logs claim the server crashed on a perfectly normal deploy. The graceful shutdown code is present and correct; it simply never gets to finish. The fix has two halves, and candidates usually give only the first. ### Half one: treat the sentinel as success ```go if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { log.Fatalf("serve: %v", err) } ``` Use `errors.Is` rather than `err == http.ErrServerClosed`; the comparison is equivalent today for this sentinel, but `errors.Is` keeps working if the error is ever wrapped on its way to you, and it is the idiom a reviewer expects. Any *other* error — a port already in use, a bad TLS certificate path — is still fatal, and you want that distinction: silently ignoring all errors from `ListenAndServe` produces a process that exits zero having served nothing. ### Half two: block until the drain actually ends Because `ListenAndServe` returns early, the code after it must not be `return` from `main`. Publish the completion of `Shutdown` and wait for it: ```go idle := make(chan struct{}) go func() { <-stop ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { _ = srv.Close() } close(idle) }() if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { log.Fatalf("serve: %v", err) } <-idle ``` Now `main` returns only after `Shutdown` has returned, so the process lives exactly as long as the drain does. If you have other components to stop — a queue consumer, a flush of buffered metrics — the same channel is the join point for them. ## Details worth having ready `http.ErrServerClosed` is returned after `Close` as well as after `Shutdown`; it is the general "this server is finished" sentinel, not a Shutdown-specific one. It is also what you get if you call `ListenAndServe` on a server that was already shut down, which is why an `http.Server` value cannot be recycled. The error is a value, not a type, so `errors.As` is the wrong tool; `errors.Is` is right. And note the asymmetry in what each return tells you: `ListenAndServe` returning `ErrServerClosed` tells you nothing at all about whether the drain succeeded, while `Shutdown`'s return distinguishes a completed drain (nil) from an abandoned one (the context's error). All of your shutdown decisions — whether to escalate to `Close`, whether to log a warning that requests were cut — have to be made from `Shutdown`'s return value, never from the serve call's. Finally, keep the shutdown trigger and the serve call in *different* goroutines. Calling `Shutdown` from inside a handler running on the very server you are shutting down works, but that handler's own connection is active, so `Shutdown` will not return until that handler does — an easy way to build a self-inflicted wait.
- Is it enough to just ignore every error from ListenAndServe?No. A bind failure on an occupied port, or a missing TLS certificate file, comes back through the same return. Ignoring everything gives you a process that exits zero having served nothing. Compare against `http.ErrServerClosed` with `errors.Is` and treat anything else as fatal.
- Does Server.Close also cause ListenAndServe to return http.ErrServerClosed?Yes. The sentinel means the server is finished, not that a graceful drain was requested. `Serve`, `ListenAndServe` and `ListenAndServeTLS` all return it after either `Shutdown` or `Close`, and also if called on a server that was already shut down.
- What goes wrong if a handler calls Shutdown on the server that is serving it?The connection running that handler is active, so the drain cannot complete until the handler returns. `Shutdown` blocks inside the handler, the handler cannot return, and with an unbounded context you deadlock. Trigger the shutdown from a separate goroutine and return from the handler first.
saying these in an interview costs you the question
- Passes ListenAndServe's error straight to log.Fatal
- Thinks ErrServerClosed means the drain has finished
- Returns from main right after ListenAndServe unblocks
- Swallows every error from ListenAndServe, including bind failures
- Calls Server.Shutdown from inside a handler of that same server