How to Implement Graceful Shutdown Pattern in Go

Implement graceful shutdown in Go by catching OS signals, canceling a context with a timeout, and calling server.Shutdown to finish active requests.

The shop closing analogy

Kubernetes sends a SIGTERM signal to your pod. The process dies instantly. Active HTTP requests drop. Database transactions roll back. Users see 502 errors. This is the default behavior of most programs. Go gives you the tools to do better.

Graceful shutdown means the program finishes its current work before exiting. Think of a restaurant closing for the night. The manager stops seating new customers. The kitchen finishes cooking the orders already in the system. The cashiers count the registers. Only then do they lock the door and leave. Your server should behave the same way.

The pattern relies on three parts. The operating system sends a signal when it wants the process to stop. Go captures that signal in a channel. You use that signal to cancel a context, which tells all your goroutines to wrap up. Finally, you wait for everything to finish and exit cleanly.

Minimal server shutdown

Here is the simplest HTTP server with graceful shutdown. The server starts in the background. The main goroutine waits for a signal. When the signal arrives, the server stops accepting new requests and waits for active ones to finish.

package main

import (
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
)

func main() {
	// Server handles requests on port 8080
	server := &http.Server{
		Addr: ":8080",
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Write([]byte("OK"))
		}),
	}

	// Run server in background so main can listen for signals
	go func() {
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatal(err)
		}
	}()

	// Buffered channel holds one signal to prevent blocking signal.Notify
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

	// Block main goroutine until shutdown signal arrives
	<-quit
	log.Println("Shutting down")
}

The server starts in a goroutine so the main function can block waiting for a signal. The quit channel has a buffer size of one. This prevents a deadlock if the OS sends a signal before signal.Notify finishes registering. The main goroutine blocks on <-quit until the signal arrives.

Trust gofmt. Argue logic, not formatting. Most editors run it on save, so you don't need to worry about indentation style.

Context and timeout

Once the signal arrives, you need to tell the server to stop. The http.Server provides a Shutdown method. You pass it a context with a timeout. The timeout acts as a safety net. If a client hangs and refuses to close the connection, the context expires and forces the shutdown to complete.

import (
	"context"
	"log"
	"time"
)

// ... inside main after <-quit

// Context sets a hard deadline for active requests to finish
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// Shutdown stops new requests and waits for active ones to complete
if err := server.Shutdown(ctx); err != nil {
	log.Fatal(err)
}

The Shutdown method stops accepting new requests. It waits for active requests to finish. If the context expires before they finish, Shutdown returns an error and the process exits. The compiler rejects missing return values with not enough return values if you ignore the error from Shutdown. Handle the error to log why the shutdown failed.

Context is plumbing. Run it through every long-lived call site. Functions that take a context should respect cancellation. The convention is to pass context.Context as the first parameter and name it ctx.

Realistic background workers

Real services do more than serve HTTP. They run background workers, sync data, or maintain database connections. All of these need to stop when the server shuts down. You propagate the context to these workers. When the context cancels, the workers exit their loops.

import (
	"context"
	"log"
	"sync"
	"time"
)

var wg sync.WaitGroup

// worker runs until context is canceled
func worker(ctx context.Context, id int) {
	defer wg.Done()
	for {
		select {
		case <-ctx.Done():
			// Context canceled, exit worker gracefully
			log.Printf("Worker %d stopping", id)
			return
		default:
			// Simulate periodic work
			time.Sleep(100 * time.Millisecond)
		}
	}
}

The worker uses a select statement to listen for context cancellation. When ctx.Done() receives a value, the worker returns. The sync.WaitGroup tracks active workers. wg.Add increments the counter before starting a goroutine. defer wg.Done decrements it when the goroutine returns. wg.Wait blocks until the counter reaches zero.

Here is how you integrate the workers into the main function. You start the workers, wait for the signal, cancel the context, and wait for the workers to finish.

func main() {
	// ... server setup ...

	// Context controls lifecycle of all background workers
	ctx, cancel := context.WithCancel(context.Background())

	// Start workers and track them with WaitGroup
	for i := 0; i < 3; i++ {
		wg.Add(1)
		go worker(ctx, i)
	}

	// ... signal handling ...
	<-quit

	// Cancel context to trigger worker exit
	cancel()

	// Wait for all workers to finish before exiting
	wg.Wait()

	// Shutdown HTTP server
	shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer shutdownCancel()
	if err := server.Shutdown(shutdownCtx); err != nil {
		log.Fatal(err)
	}
}

The cancel function signals all workers to stop. wg.Wait ensures the main goroutine doesn't exit until all workers have returned. The HTTP server shuts down last. This order prevents new requests from arriving while workers are still cleaning up.

Don't leave goroutines behind. Cancel the context or wait for them.

Pitfalls and errors

Goroutine leaks happen when a goroutine waits on a channel that never closes. If a worker blocks on a read without checking context, the process hangs forever. The runtime panics with fatal error: all goroutines are asleep - deadlock! if the main goroutine exits while others are still running. Always provide a cancellation path for every goroutine.

Forgetting to buffer the signal channel causes a runtime panic. If the OS sends a signal before signal.Notify runs, the send blocks. Since signal.Notify runs in the main goroutine, the program deadlocks. The compiler cannot catch this. The runtime reports fatal error: all goroutines are asleep.

Calling Shutdown without a timeout risks hanging indefinitely. A misbehaving client can hold a connection open forever. The context timeout forces the shutdown to complete. The compiler rejects undefined variables with undefined: ctx if you forget to declare the context.

The if err != nil check is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You see every error handling site explicitly.

Timeouts save processes from hanging forever.

Decision matrix

Use http.Server.Shutdown when you have an HTTP server and need to finish active requests. Use context.WithTimeout when you need a hard deadline to prevent hanging on slow clients. Use os/signal with a buffered channel when you need to catch OS termination signals. Use sync.WaitGroup when you need to wait for background goroutines to finish. Use context.WithCancel when you have multiple goroutines that need to stop together. Use immediate os.Exit when data loss is acceptable and speed is the only priority.

Where to go next