TL;DR
http.Server.ListenAndServeis a blocking call, so if we run it inmainnothing after it runs.- Start the server in a goroutine and keep
mainfree for handling shutdown. - Send any server startup errors to a channel so
maincan handle them. - Listen for
SIGINT(Ctrl+C) andSIGTERM(container orchestration) usingsignal.NotifyContext. - Use
selectto wait on multiple channels: shutdown signal and server error channel. - On shutdown signal, create a deadline with
context.WithTimeoutand callserver.Shutdown(ctx)to stop accepting new requests while letting in flight requests finish. - Clean up resources like DB connections after shutdown completes.
- Use
sync.WaitGroupto wait for other background goroutines to finish gracefully. (TODO) - Bonus: Migrate to
errgroupfor better orchestration and automatic context cancellation across goroutines. (TODO)
Example Scenario
Imagine we are eating at a restaurant. We still haven’t finished our food. The restaurant is about to close. The restaurant would stop taking new orders or seating new customers, and a good restaurant will usually wait for us to finish our food before closing and cleaning up. That’s exactly what a graceful shutdown is. We are trying to become a good restaurant. Even so, a good restaurant won’t wait forever. It sets a time limit. If we exceed it, we’ll be asked to leave.
In a typical web service using HTTP, we first stop accepting new incoming requests, then wait for in flight requests to finish within a time limit. We don’t want to wait forever, of course. Once all requests have completed (or the deadline has passed), we can safely shut down the server and clean up resources such as closing DB connections and stopping any other background work.
flowchart TB
%% Graceful shutdown
subgraph G[Graceful shutdown]
direction TB
subgraph GR[Restaurant]
direction TB
r1[Normal service]
r2[Closing time approaching]
r3[Stop seating new customers]
r4[Stop taking new orders]
r5[Let current diners finish eating]
r6{Time limit reached?}
r7[Ask remaining diners to leave]
r8[Clean up and close]
r1 --> r2 --> r3 --> r4 --> r5 --> r6
r6 -- No --> r5
r6 -- Yes --> r7 --> r8
end
subgraph GS[Web service]
direction TB
s1[Normal traffic]
s2[Shutdown signal received]
s3[Stop accepting new incoming HTTP requests]
s4[Drain in flight requests]
s5{Shutdown timeout reached?}
s6[Force close remaining work]
s7[Clean up resources]
s8[Close DB connections]
s9[Stop background operations]
s1 --> s2 --> s3 --> s4 --> s5
s5 -- No --> s4
s5 -- Yes --> s6 --> s7 --> s8 --> s9
end
end
The opposite of this would be a forceful shutdown, where the restaurant immediately kicks customers out even though they haven’t finished their food, interrupts orders being prepared in the kitchen, and starts cleaning up, mopping the floor and stacking chairs, while customers still need them.
flowchart TB
%% Forceful shutdown
subgraph F[Forceful shutdown]
direction TB
subgraph FR[Restaurant]
direction TB
fr1[Closing time approaching]
fr2[Kick customers out immediately]
fr3[Start cleaning now]
fr4[Orders in kitchen get interrupted]
fr1 --> fr2 --> fr3
fr2 --> fr4
end
subgraph FS[Web service]
direction TB
fs1[Shutdown signal received]
fs2[Immediately stop service]
fs3[Drop active requests]
fs4[Close connections now]
fs5[Best effort cleanup]
fs1 --> fs2 --> fs3 --> fs4 --> fs5
end
end
HTTP Server in Go
Go provides the net/http package that we can use to run an HTTP server using the server.ListenAndServe() method. This method is a blocking operation. It contains a never ending loop that waits for and serves each new connection or request. That’s why any code placed after this call will never execute.
func (srv *Server) Serve(l net.Listener) error { // ... setup ... for { // <-- An infinite loop! rw, err := l.Accept() // <-- THIS IS THE KEY BLOCKING CALL! if err != nil { // ... error handling ... } c := srv.newConn(rw) go c.serve(connCtx) // Handle each connection in a new goroutine }}It blocks because it’s an infinite loop with a blocking syscall (
Accept()), not because of channels. It’s the same reason any infinite loop blocks. The function literally never returns unless there’s an error!
Moving to a Background Goroutine
We just learned that server.ListenAndServe() in Go is a blocking operation. Running it directly in the main function (which is the main goroutine) is a very bad idea because:
- We won’t be able to run code after it.
- We won’t be able to listen for external signals for graceful shutdown.
- We won’t be able to run background workers or other concurrent tasks.
- Our deferred cleanup functions won’t execute until the server stops (which may never happen in normal operation).
func main() { defer cleanup() // This NEVER runs (unless server errors out) log.Println("Starting server...") // This blocks forever! server.ListenAndServe() // NONE of this code ever executes: log.Println("Server stopped") // Never prints startBackgroundWorker() // Never runs listenForShutdownSignals() // Never runs}Therefore, the best practice is to run it in a background goroutine. This unblocks the main goroutine so we can run other operations such as handling shutdown.
func main() { go func() { // This still blocks forever! // But now in the scope of its own background goroutine err := server.ListenAndServe() if err != nil { log.Fatal(err) } }() // -- Code after this will execute --}Communicating with Channels
Moving server.ListenAndServe to a background goroutine means the main goroutine never sees any error it returns. If the server fails to start, we usually want the whole program to exit right away, so anything that comes after it should never run.
We need a way to communicate between goroutines: the background goroutine running the server, and the main goroutine. We can accomplish this using channels. A channel is a thread safe queue. Values come out in the same order they went in. We can send values from any number of goroutines and receive them from any number of other goroutines without corrupted data or a race condition.
First, we create a channel to carry our server error in the main goroutine.
func main() { serverErrors := make(chan error, 1)}Then we send a value to that channel from our background goroutine using the <- syntax.
func main() { go func() { err := server.ListenAndServe() if err != nil { serverErrors <- err } }()}Now we introduce a channel receive that waits for a value to arrive. A channel receive is a blocking operation as well, which means the code after it won’t execute until a value is received. We can then use that error to exit the program early.
func main() { // Creating a channel serverErrors := make(chan error, 1) go func() { err := server.ListenAndServe() if err != nil { // Send error to the channel serverErrors <- err } }() // Receiving from the channel // This will block the main goroutine serverError := <-serverErrors // This will not execute until we receive a server error fmt.Println("Blocked unless there is a server error") if serverError != nil { log.Println("Program killed:", serverError) }}The buffered channel with capacity 1
(make(chan error, 1))allows the goroutine to send one error without blocking, even if no receiver is ready yet. This prevents a potential deadlock if the server fails to start immediately, ensuring the error can be sent before the main goroutine reaches the receive statement.
Understanding Context
Before we listen for shutdown signals, let’s understand Go’s context package. It’s central to how graceful shutdown works.
Imagine ordering food on an app and then cancelling the order. Without context, the kitchen keeps cooking, a driver gets assigned, and the whole chain keeps going even though we cancelled. Context is how we tell the whole chain “stop, the user cancelled.”
The equivalent use cases in our program are timeouts and cancellation. For example, a slow database query that might take 30 seconds when we want to give up after 5. Or a client (say, a web browser) that closes the connection before receiving the response. In that case, we might want to cancel expensive internal operations on the server to save resources. Context can also carry request scoped values through the call chain, such as a requestId or userId for each HTTP handler.
When a context is cancelled or reaches its timeout, we are notified through ctx.Done(), which returns a channel that closes when the context is done. Receiving from it (<-ctx.Done()) is a blocking operation that waits until one of those events occurs.
Listening for Shutdown Signals
Now that server.ListenAndServe() no longer blocks the main goroutine, we can handle graceful shutdown. SIGTERM is the polite “please shut down” signal that the OS or orchestrator sends before it gets impatient and sends SIGKILL, which forcibly kills the program. For example, Kubernetes sends SIGTERM to a pod and waits for terminationGracePeriodSeconds (30 seconds by default) before sending SIGKILL. For local development, we’ll also listen for SIGINT, triggered by pressing Ctrl+C in the terminal.
We use signal.NotifyContext to create a context that is automatically cancelled when one of our specified signals is received.
func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop()}
defer stop()resets the signal behavior back to the default after we’re done handling shutdown. This is important because if a second signal arrives during our graceful shutdown process, we want the program to terminate immediately rather than being caught by our handler again.
This root context can also be passed to dependent resources so they are aware of application level cancellation.
func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() // db pool example using pgx driver db, err := pgxpool.New(ctx, "postgres://postgres:password@localhost:5432/mydb") // background goroutine if any go processQueue(ctx)}Waiting on Multiple Channels with Select
We now have two channels to wait on: ctx.Done() for the shutdown signal, and serverErrors for a server startup failure. We can’t just receive from one because either event could happen first. This is exactly what Go’s select statement is for.
select works like a switch but for channel operations. It blocks until one of its cases can proceed, then executes that case.
select {case err := <-serverErrors: // The server failed to start or crashed log.Println("Server error:", err) returncase <-ctx.Done(): // We received a shutdown signal (SIGINT or SIGTERM) log.Println("Shutdown signal received")}If the server fails to start (e.g., port already in use), the first case fires and we exit immediately. If the server starts successfully and we later press Ctrl+C or the orchestrator sends SIGTERM, the second case fires and we begin graceful shutdown.
Shutting Down the Server
When the shutdown signal arrives, we need to tell the server to stop accepting new connections and wait for in flight requests to finish. This is what server.Shutdown(ctx) does. We pass it a context with a timeout to set a deadline. We don’t want to wait forever for slow requests.
case <-ctx.Done(): log.Println("Shutdown signal received") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() err := server.Shutdown(shutdownCtx) if err != nil { log.Println("Graceful shutdown failed:", err) }There are a few important details here:
- We create a new context with
context.Background()rather than reusingctx, becausectxis already cancelled (that’s how we got here). The new timeout gives in flight requests up to 10 seconds to complete. server.Shutdowncloses the listeners, then waits for all active connections to become idle. If the timeout expires before that happens,Shutdownreturnscontext.DeadlineExceededand any remaining connections are abandoned. The server did its best, but some requests may be cut off.- When
Shutdownis called, theListenAndServecall in our background goroutine returnshttp.ErrServerClosed. This is expected during graceful shutdown and is not a real error. We need to filter it in our goroutine so it doesn’t get sent toserverErrors:
go func() { err := server.ListenAndServe() if err != nil && !errors.Is(err, http.ErrServerClosed) { serverErrors <- err }}()Without this filter, serverErrors would receive a value on every graceful shutdown, making it look like the server crashed.
Cleaning Up Resources
After the server has shut down, we can safely clean up resources like database connections. The simplest way to do this in Go is with defer statements, which execute in LIFO (last in, first out) order when the function returns.
func main() { // ... setup ... db, err := pgxpool.New(ctx, connStr) if err != nil { log.Fatal(err) } defer db.Close() // Runs when main returns, after shutdown completes // ... server start, select, shutdown ...}Because defer runs when main returns, and our select + Shutdown sequence happens before that return, we can be confident the DB pool is closed only after the server has finished processing all requests.
Putting It All Together
Here is the complete implementation combining everything we’ve covered: starting the server in a background goroutine, communicating errors via a channel, listening for shutdown signals with context, using select to wait on both, shutting down the server with a timeout, and cleaning up resources.
package mainimport ( "context" "errors" "log" "net/http" "os" "os/signal" "syscall" "time" "github.com/jackc/pgx/v5/pgxpool")func main() { // --------------------------------------------------------------- // Create a context that is cancelled on SIGINT or SIGTERM. // --------------------------------------------------------------- ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() // --------------------------------------------------------------- // Connect to the database. // --------------------------------------------------------------- db, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL")) if err != nil { log.Println("Unable to create connection pool:", err) return } defer db.Close() log.Println("Connected to database") // --------------------------------------------------------------- // Register routes. // --------------------------------------------------------------- http.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) { log.Println("Slow request started") time.Sleep(5 * time.Second) w.Write([]byte("done")) log.Println("Slow request finished") })
// --------------------------------------------------------------- // Set up the server. // --------------------------------------------------------------- server := &http.Server{ Addr: ":8080", Handler: http.DefaultServeMux, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } // --------------------------------------------------------------- // Start the server in a background goroutine. // Send any unexpected error to the channel. // --------------------------------------------------------------- serverErrors := make(chan error, 1) go func() { log.Println("Server listening on", server.Addr) err := server.ListenAndServe() // http.ErrServerClosed is expected when Shutdown is called. if err != nil && !errors.Is(err, http.ErrServerClosed) { serverErrors <- err } }() // --------------------------------------------------------------- // Block until we receive a shutdown signal or a server error. // --------------------------------------------------------------- select { case err := <-serverErrors: log.Println("Server error:", err) return case <-ctx.Done(): stop() // Reset signal behavior so a second signal exits immediately. log.Println("Shutdown signal received, draining requests...") // Give in flight requests a deadline to complete. shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() err := server.Shutdown(shutdownCtx) if err != nil { log.Println("Graceful shutdown failed:", err) return } log.Println("Server shut down gracefully") } // --------------------------------------------------------------- // At this point, deferred functions run: // - db.Close() closes the connection pool // - stop() resets signal handling // ---------------------------------------------------------------}Testing It Manually
Let’s prove that this actually works. The idea is simple: register a slow endpoint, start the server, send a request to that endpoint, and hit Ctrl+C while the request is still being processed. If graceful shutdown is working, the server should wait for the request to finish before exiting.
Step 1: Notice the slow endpoint.
In the code above, we registered a /slow handler that takes 5 seconds to respond. This gives us a window to send the shutdown signal while the request is in flight.
Step 2: Start the server.
go run main.goWe should see something like:
Connected to databaseServer listening on :8080Step 3: Send a request to the slow endpoint.
Open a second terminal and run:
curl http://localhost:8080/slowThis will hang for 5 seconds while the server processes the request.
Step 4: Send the shutdown signal.
Go back to the first terminal (where the server is running) and press Ctrl+C while the curl request is still waiting. We should see something like:
Slow request startedShutdown signal received, draining requests...Slow request finishedServer shut down gracefullyNotice the order. The shutdown signal was received, but the server waited for the slow request to finish before shutting down. The curl request in the second terminal should return done with no error.
What happens without graceful shutdown?
If we were to just kill the process (e.g., kill -9 or no shutdown handling at all), the curl request would fail with something like curl: (52) Empty reply from server because the connection gets dropped mid response.
What happens if the request exceeds the shutdown timeout?
Try changing the sleep to 15 seconds (longer than our 10 second shutdown timeout). When we press Ctrl+C, we’ll see:
Slow request startedShutdown signal received, draining requests...Graceful shutdown failed: context deadline exceededThe server gave up waiting after 10 seconds and exited. The curl request will fail because the connection was closed before the response was sent. This is the timeout acting as a safety net so the server never hangs forever waiting for a stuck request.
Wrapping Up
That covers the core of graceful shutdown in Go. To recap what we built: we moved the server out of the main goroutine so it doesn’t block everything, used a channel to catch startup errors, listened for OS signals through context, and used select to react to whichever event comes first. When a shutdown signal arrives, we give in flight requests time to finish before cleaning up resources like the database connection.
This is a solid foundation for any Go service. In a future post, we’ll take it further with sync.WaitGroup for waiting on background goroutines, and then refactor the whole thing using errgroup for cleaner orchestration.