Graceful HTTP Server Shutdown in Go
Stopping a web server with Ctrl+C looks harmless during development, but production deployments need a more careful shutdown process. If a process exits immediately, active HTTP requests can be interrupted, clients may receive connection errors, and in-flight work can be left unfinished.
Go’s standard library already provides the pieces needed for a clean shutdown. The main tools are os/signal, context, and http.Server.Shutdown.
This guide shows a practical pattern for shutting down an HTTP server when the process receives SIGINT or SIGTERM.
Why graceful shutdown matters
A typical deployment platform stops an application in two stages:
- It sends a termination signal such as
SIGTERM. - It waits for a limited amount of time before forcefully killing the process.
A graceful shutdown lets the application use that window to stop accepting new connections and finish requests that are already running.
For an HTTP API, that usually means:
- listen for operating-system shutdown signals;
- tell the HTTP server to stop accepting new work;
- give active requests a deadline to finish;
- force the server closed if that deadline expires.
Complete example
The following program uses only the Go standard library. Go 1.27 is the current stable major release at the time of writing, and the APIs used here are also available in earlier recent Go versions.
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
stopCtx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer stop()
errCh := make(chan error, 1)
go func() {
log.Printf("server listening on %s", server.Addr)
errCh <- server.ListenAndServe()
}()
select {
case <-stopCtx.Done():
log.Println("shutdown signal received")
case err := <-errCh:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
return
}
shutdownCtx, cancel := context.WithTimeout(
context.Background(),
10*time.Second,
)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
if closeErr := server.Close(); closeErr != nil {
log.Printf("forced close failed: %v", closeErr)
}
}
err := <-errCh
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server stopped unexpectedly: %v", err)
}
log.Println("server stopped cleanly")
}Save the file as main.go, then run it:
go run main.goIn another terminal, verify the endpoint:
curl http://localhost:8080/healthThe response should be:
okNow send Ctrl+C to the server process. On Linux or in a container environment, a deployment system will commonly send SIGTERM instead.
Step 1: use an explicit http.Server
It is tempting to start a small service with this one-liner:
log.Fatal(http.ListenAndServe(":8080", mux))That is convenient, but an explicit http.Server is more useful in production because it gives you access to methods such as Shutdown and lets you configure timeouts.
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}ReadHeaderTimeout limits how long the server waits for a client to send request headers. It is a sensible baseline protection for an internet-facing HTTP server.
Step 2: convert OS signals into a context
signal.NotifyContext creates a context that is canceled when one of the selected signals arrives:
stopCtx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer stop()os.Interrupt covers the interrupt signal commonly generated by Ctrl+C. syscall.SIGTERM is the standard termination signal used by many process managers, containers, and orchestration platforms.
Using a context keeps the shutdown trigger compatible with the rest of Go’s cancellation model.
Step 3: run the server without blocking shutdown handling
ListenAndServe blocks while the server is running, so it needs to run in a goroutine if the main goroutine is also responsible for handling shutdown signals.
errCh := make(chan error, 1)
go func() {
errCh <- server.ListenAndServe()
}()The channel is buffered so the server goroutine can report its result even if the main goroutine is briefly busy handling the shutdown path.
The program then waits for either a signal or an unexpected server exit:
select {
case <-stopCtx.Done():
log.Println("shutdown signal received")
case err := <-errCh:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
return
}Handling both cases is important. If the server fails to bind to its port, for example, the program should report that error instead of waiting forever for a shutdown signal.
Step 4: give active requests a deadline
A graceful shutdown should still have an upper bound. A request handler might be stuck on a slow dependency, a database call, or another external service.
Create a separate timeout context for the shutdown operation:
shutdownCtx, cancel := context.WithTimeout(
context.Background(),
10*time.Second,
)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
}server.Shutdown closes listeners, stops accepting new connections, and waits for active connections to become idle. If the context deadline expires first, Shutdown returns an error.
The best timeout depends on the service. Ten seconds is reasonable for a small example, but a production value should fit both your normal request duration and your deployment platform’s termination window.
Step 5: force close only as a fallback
If graceful shutdown exceeds its deadline, the application can fall back to server.Close():
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
if closeErr := server.Close(); closeErr != nil {
log.Printf("forced close failed: %v", closeErr)
}
}Unlike Shutdown, Close does not wait for active connections to finish. That is why it should be a fallback rather than the normal shutdown mechanism.
Why http.ErrServerClosed is not a failure
After Shutdown closes the server’s listeners, ListenAndServe returns http.ErrServerClosed.
That error describes the expected shutdown path, so it should not be logged as an application failure:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server stopped unexpectedly: %v", err)
}A common mistake is to wrap ListenAndServe directly in log.Fatal. During a normal graceful shutdown, that can make an expected server close look like a crash.
Testing graceful shutdown locally
A basic manual test is enough to verify the signal path:
go run main.goThen call the health endpoint:
curl http://localhost:8080/healthFinally, press Ctrl+C in the server terminal.
For a Linux process, you can also send SIGTERM explicitly:
kill -TERM <pid>The process should stop accepting new connections and exit after its active requests have completed or the shutdown timeout has expired.
Common pitfalls
Using the request context for server shutdown
Do not use a random request’s context as the parent for the shutdown timeout. A request context may already be canceled when its client disconnects.
Use a fresh context instead:
shutdownCtx, cancel := context.WithTimeout(
context.Background(),
10*time.Second,
)Forgetting SIGTERM
Handling only Ctrl+C can appear correct during local development but fail to trigger cleanup in a container or process manager. Listen for both the interrupt signal and SIGTERM when targeting Unix-like production systems.
Allowing shutdown to wait forever
Passing a context without a deadline to Shutdown can make a deployment hang indefinitely if a handler never finishes. Always choose a finite timeout that matches your operational environment.
Closing dependencies too early
If the application uses a database connection pool, message producer, or other shared dependency, do not close it before active HTTP handlers finish. A practical order is:
- stop accepting new HTTP traffic;
- wait for active handlers through
server.Shutdown; - close application dependencies;
- exit the process.
Production checklist
Before deploying a Go HTTP service, verify that:
- the server uses an explicit
http.Server; SIGINTandSIGTERMare handled;- shutdown has a finite timeout;
http.ErrServerClosedis treated as an expected result;- long-running handlers respect request contexts where possible;
- databases, queues, and other resources are closed after HTTP traffic drains;
- the shutdown timeout is shorter than the platform’s forced-termination window.
Conclusion
Graceful shutdown is a small amount of code that prevents avoidable failures during deployments and restarts. Go’s standard library makes the pattern straightforward: receive termination signals through signal.NotifyContext, stop the server with http.Server.Shutdown, bound the wait with a timeout, and use Close only when graceful shutdown cannot finish in time.
Once this pattern is part of your service template, rolling deployments and routine restarts become much safer for clients and in-flight requests.