Shutdown Doesn't Hang on Hijacked Connections, Your Code Does
A common bug report goes: "http.Server.Shutdown hangs forever because of my WebSocket connections." It is a good story, and it is wrong in an instructive way. Shutdown never waits for hijacked connections at all. If your process hangs, something else is holding it open.
So the useful question is what, and how to get a bounded shutdown. Here is the mechanism, the real culprits, and a small pattern that fixes it.
What Shutdown actually tracks
The server keeps a set of connections it owns. Shutdown closes the listeners, then polls that set, closing connections that are idle and waiting for active ones to finish their current request. It returns when the set is empty or the context expires.
When a handler calls Hijack, the connection leaves that set and its state becomes StateHijacked. The documentation says it plainly: Shutdown does not attempt to close or wait for hijacked connections.
That has two consequences, and neither is a hang:
- Shutdown can return while your WebSocket sessions are still running.
- Nobody closes those connections for you, so they die abruptly when the process exits.
Where the hang really comes from
If the program does not exit, look at these, roughly in order of likelihood:
- Your own
sync.WaitGroup, waiting for handler goroutines that are blocked reading a connection nobody will close. - A
Shutdowncall givencontext.Background()while a non-hijacked handler (server-sent events, a slow download) never finishes. - A handler that is blocked before it hijacks, so the server still counts it as active.
The first is the classic. Someone reads the docs, realises Shutdown ignores their sockets, adds a WaitGroup to be responsible, and then never tells the sockets to stop. The wait is now infinite, and Shutdown gets the blame.
Quick detour: why the handler goroutine outlives the request
Hang on, why does a hijacked handler keep running at all? Because ServeHTTP returning does not close a hijacked connection; the server has handed over ownership. Most WebSocket libraries run their read loop inside the handler, so the goroutine lives as long as the socket does.
Also, hijacking only works on HTTP/1.x. An HTTP/2 response writer does not implement http.Hijacker, so check the type assertion rather than assuming it.
A pattern with a deadline
The fix is to give hijacked connections a shutdown signal, and to bound the wait. RegisterOnShutdown is the hook: it runs in its own goroutine when Shutdown starts.
package main
import (
"context"
"errors"
"log"
"net"
"net/http"
"os"
"os/signal"
"sync"
"time"
)
type app struct {
done chan struct{}
wg sync.WaitGroup
}
func (a *app) hijacked(w http.ResponseWriter, r *http.Request) {
// Add before Hijack: until then the server still counts us as
// active, so Shutdown cannot have finished and Wait cannot race Add.
a.wg.Add(1)
defer a.wg.Done()
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijack unsupported", http.StatusInternalServerError)
return
}
conn, _, err := hj.Hijack()
if err != nil {
log.Print(err)
return
}
defer conn.Close()
// Unblocks any pending Read when shutdown begins.
stop := make(chan struct{})
defer close(stop)
go func() {
select {
case <-a.done:
conn.SetDeadline(time.Now())
case <-stop:
}
}()
serve(conn)
}
func serve(conn net.Conn) {
buf := make([]byte, 4096)
for {
if _, err := conn.Read(buf); err != nil {
return
}
}
}
func main() {
a := &app{done: make(chan struct{})}
mux := http.NewServeMux()
mux.HandleFunc("/live", a.hijacked)
srv := &http.Server{Addr: ":8080", Handler: mux}
srv.RegisterOnShutdown(func() { close(a.done) })
go func() {
err := srv.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)
<-sig
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Print("shutdown: ", err)
}
waited := make(chan struct{})
go func() { a.wg.Wait(); close(waited) }()
select {
case <-waited:
case <-time.After(5 * time.Second):
log.Print("hijacked connections did not drain")
}
}
Setting a deadline in the past is a blunt but reliable way to wake a blocked Read. In a real protocol you would send a close frame first, then set a short deadline for the peer to respond.
Checking it works
Open a raw connection with nc, hijack it via an Upgrade-style request of your choosing, then send SIGINT. Without the RegisterOnShutdown hook you will see the "did not drain" line after five seconds. With it, the process exits straight away.
One caveat: Shutdown returns context.DeadlineExceeded if ordinary handlers outlast the timeout. That is a separate problem from hijacking, and it still leaves those connections open, so call srv.Close() afterwards if you really want them gone.
The wider lesson is that Shutdown is only responsible for what it can see. Anything you take away from the server with Hijack is yours to stop, and yours to wait for, with a timeout.