Graceful shutdown for HTTP servers in Go

1. Overview

When deploying a new version or scaling down, your process receives SIGTERM. If the server exits immediately, in-flight requests are interrupted. Clients get connection reset, and orders might be only partially written.

This article guides you on how to properly shut down an HTTP server in Go. The server will stop accepting new connections, wait for in-flight requests to complete within a limited time window, then close resources in the correct order. All examples use only the standard library and require Go 1.22 or later.

2. Problems with abrupt server shutdown

Let’s start with the most familiar approach:

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"
)

func main() {
	http.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(3 * time.Second) // simulating slow database query
		fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
	})
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Run the server and call curl localhost:8080/orders/42. While the request is pending, press Ctrl+C in the server’s terminal. The client sees:

curl: (52) Empty reply from server

Go doesn’t install signal handlers for SIGINT or SIGTERM by default. When the signal is received, the runtime terminates the process immediately. No defer statements are executed and no requests are waited for.

3. Graceful shutdown with http.Server.Shutdown

http.Server has a built-in Shutdown(ctx) method. This method does three things in order:

  1. Closes all listeners, so the server stops accepting new connections.
  2. Closes idle connections.
  3. Waits for active connections to become idle, then closes them. If ctx expires first, the method returns the context’s error.

To use this method, you need to catch OS signals yourself.

3.1. Catching signals with signal.NotifyContext

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(3 * time.Second)
		fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
	})

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	serverErr := make(chan error, 1)
	go func() {
		log.Println("listening on", srv.Addr)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			serverErr <- err
		}
	}()

	select {
	case err := <-serverErr:
		log.Fatalf("server error: %v", err)
	case <-ctx.Done():
		log.Println("shutdown signal received")
	}
	stop() // receiving Ctrl+C a second time will kill the process immediately

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Printf("graceful shutdown failed: %v, forcing close", err)
		_ = srv.Close()
	}
	log.Println("server stopped")
}

Key points in this code:

  • signal.NotifyContext returns a context. This context is canceled when the process receives SIGINT (Ctrl+C) or SIGTERM (the signal sent by Docker and Kubernetes).
  • ListenAndServe runs in a separate goroutine. After you call Shutdown, it returns http.ErrServerClosed immediately. This is expected, so we filter it out.
  • The serverErr channel has a buffer of 1. It helps catch startup errors, like when the port is already in use. This way main won’t hang waiting for a signal that never comes.
  • stop() is called right after receiving the signal. This function restores the default behavior, so if you press Ctrl+C again, the process exits immediately. This is very useful when shutdown hangs.
  • shutdownCtx uses context.Background() as the base instead of ctx. The reason is that ctx is already canceled, so using it would cause Shutdown to return an error immediately.
  • If 10 seconds pass and requests are still running, srv.Close() forcefully closes all connections.

Repeat the test from section 2: call curl, then press Ctrl+C while the request is pending. Server logs:

2026/10/24 10:00:00 listening on :8080
^C2026/10/24 10:00:01 shutdown signal received
2026/10/24 10:00:03 server stopped

The client receives the complete response:

order 42

The server waits about 2 more seconds for the request to finish, then exits.

Note: A very common mistake is swapping the roles of the two goroutines. Some people call ListenAndServe in main and Shutdown in another goroutine. Because ListenAndServe returns immediately when Shutdown starts, main will exit while Shutdown is still waiting for requests. The process exits and requests are still interrupted. The rule is that main must wait for Shutdown to return.

4. What Shutdown doesn’t handle automatically

4.1. Hijacked connections: WebSocket, HTTP/1.1 upgrades

Shutdown doesn’t track hijacked connections like WebSockets. The server doesn’t wait for them either. To close these connections, register a callback with RegisterOnShutdown:

type Hub struct {
	mu    sync.Mutex
	conns map[*websocketConn]struct{}
}

func (h *Hub) CloseAll() {
	h.mu.Lock()
	defer h.mu.Unlock()
	for c := range h.conns {
		c.SendCloseFrame("server shutting down")
		c.Close()
	}
}

// in main:
hub := &Hub{conns: make(map[*websocketConn]struct{})}
srv.RegisterOnShutdown(hub.CloseAll)

websocketConn here represents the connection type of the WebSocket library you’re using. The callback runs in a separate goroutine as soon as Shutdown starts. Shutdown doesn’t wait for this callback to finish, so if you need to wait, synchronize with sync.WaitGroup.

4.2. Long-running handlers: streaming, long polling

A handler that streams data indefinitely, like Server-Sent Events, will never return to idle. In that case, Shutdown always waits until the timeout. You can use BaseContext to cancel all request contexts when the server starts shutting down:

appCtx, cancelApp := context.WithCancel(context.Background())

srv := &http.Server{
	Addr:    ":8080",
	Handler: mux,
	BaseContext: func(net.Listener) context.Context {
		return appCtx
	},
}
srv.RegisterOnShutdown(cancelApp)

mux.HandleFunc("GET /orders/stream", func(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/event-stream")
	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()
	for {
		select {
		case <-r.Context().Done():
			return // server is shutting down or client disconnected
		case t := <-ticker.C:
			fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
			w.(http.Flusher).Flush()
		}
	}
})

Every request context is created from appCtx. When Shutdown runs, cancelApp is called and all r.Context() are canceled. The stream handler sees Done() and returns. Then Shutdown completes early.

Note: Don’t apply this to all handlers. If you cancel the context of a request writing an order to the database, the write operation itself will be canceled. The goal of “waiting for requests to finish” is no longer achieved. Only use BaseContext this way when all handlers can safely stop in the middle.

4.3. Background goroutines and auxiliary resources

Shutdown only manages HTTP connections. Email workers, Kafka consumers, and database connection pools are your responsibility. The closing order should be the reverse of the initialization order:

if err := srv.Shutdown(shutdownCtx); err != nil {
	log.Printf("http shutdown: %v", err)
}

cancelWorkers() // signal background goroutines to stop
workersDone := make(chan struct{})
go func() { wg.Wait(); close(workersDone) }()

select {
case <-workersDone:
case <-shutdownCtx.Done():
	log.Println("workers did not stop in time")
}

if err := db.Close(); err != nil {
	log.Printf("db close: %v", err)
}

The HTTP server is shut down first because handlers still need the database. The database is closed last. The entire process shares the same shutdownCtx, so the total time doesn’t exceed the 10-second budget.

5. Running on Kubernetes

When a pod is deleted, Kubernetes does two things in parallel: sending SIGTERM to the container and removing the pod from the Service’s endpoint list. Removing the endpoint takes a few seconds to propagate to kube-proxy and the ingress. If the server closes the listener immediately upon receiving SIGTERM, requests still being routed to the pod will be rejected.

The fix is to have the readiness probe fail first, wait a bit, then call Shutdown:

var shuttingDown atomic.Bool

mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
	if shuttingDown.Load() {
		http.Error(w, "shutting down", http.StatusServiceUnavailable)
		return
	}
	w.WriteHeader(http.StatusOK)
})

// after receiving the signal:
<-ctx.Done()
shuttingDown.Store(true)
time.Sleep(5 * time.Second) // wait for the load balancer to remove the pod
srv.Shutdown(shutdownCtx)

You also need to set terminationGracePeriodSeconds larger than the total wait time plus shutdown timeout. For this example, that’s 5 + 10 = 15 seconds:

spec:
  terminationGracePeriodSeconds: 30
  containers:
    - name: order-service
      image: order-service:1.4.0
      readinessProbe:
        httpGet:
          path: /readyz
          port: 8080
        periodSeconds: 2

When the grace period ends, Kubernetes sends SIGKILL. This signal cannot be caught, and all your shutdown logic is ignored.

Note: If you run the binary through a shell script, like CMD ./start.sh, the shell is PID 1 and might not forward SIGTERM to the Go process. Use the exec form CMD ["/app/server"] or call exec /app/server at the end of the script.

6. Testing shutdown behavior

You can write a test to confirm that Shutdown waits for in-flight requests to complete. This test needs only the testing package:

package main

import (
	"context"
	"io"
	"net"
	"net/http"
	"testing"
	"time"
)

func TestShutdownWaitsForInFlightRequest(t *testing.T) {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}

	started := make(chan struct{})
	srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		close(started)
		time.Sleep(500 * time.Millisecond)
		io.WriteString(w, "order 42")
	})}
	go srv.Serve(ln)

	type result struct {
		body string
		err  error
	}
	resCh := make(chan result, 1)
	go func() {
		resp, err := http.Get("http://" + ln.Addr().String() + "/orders/42")
		if err != nil {
			resCh <- result{err: err}
			return
		}
		defer resp.Body.Close()
		b, err := io.ReadAll(resp.Body)
		resCh <- result{body: string(b), err: err}
	}()

	<-started // ensure the request has entered the handler
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		t.Fatalf("Shutdown returned error: %v", err)
	}

	res := <-resCh
	if res.err != nil {
		t.Fatalf("request failed: %v", res.err)
	}
	if res.body != "order 42" {
		t.Fatalf("got body %q, want %q", res.body, "order 42")
	}

	if _, err := http.Get("http://" + ln.Addr().String()); err == nil {
		t.Fatal("expected new connections to be refused after shutdown")
	}
}

Key points in the test:

  • net.Listen on port 0 lets the OS pick an available port. This way tests can run in parallel without conflicts.
  • The started channel ensures Shutdown is only called when the request is actually in the handler.
  • The test checks three things: Shutdown returns nil, the client receives the full body, and new connections are rejected.
go test -v -run TestShutdown
=== RUN   TestShutdownWaitsForInFlightRequest
--- PASS: TestShutdownWaitsForInFlightRequest (0.50s)
PASS

The test takes about 0.5 seconds, exactly the time the handler sleeps. This shows that Shutdown waited for the request.

7. Common mistakes

  • Using an already-canceled context for Shutdown. Passing the ctx from signal.NotifyContext directly causes Shutdown to return context canceled immediately.
  • Treating http.ErrServerClosed as an error. Calling log.Fatal with this error exits the process before shutdown completes.
  • Not setting a timeout. Using context.Background() without a deadline lets a stuck handler keep the process alive until Kubernetes sends SIGKILL.
  • Forgetting ReadHeaderTimeout. Slow clients can keep connections in the active state, and shutdown always runs until timeout.
  • Using os.Exit or log.Fatal after starting shutdown. Both skip all defer statements, so databases and files are not closed.

8. Conclusion

Graceful shutdown in Go requires three components: signal.NotifyContext to catch SIGTERM, srv.Shutdown with a timeout context, and main waiting for Shutdown to return. From that foundation, you add the parts that Shutdown doesn’t handle automatically. Those are RegisterOnShutdown for WebSockets, BaseContext for streaming handlers, and closing workers and databases in reverse order of initialization. When running on Kubernetes, have the readiness probe fail and wait a few seconds before closing the listener, and set terminationGracePeriodSeconds large enough. Finally, keep a test like the one in section 6 to catch when someone accidentally breaks the shutdown flow.

golanghttpgraceful-shutdownkubernetesconcurrency