When building HTTP services in Go

One of the recurring challenges I face is making sure a service stops cleanly when it receives a termination signal. Abruptly killing a process can leave in‑flight requests half‑processed, corrupt logs, or cause downstream systems to retry unnecessarily. Over the years I’ve settled on a pattern that uses Go’s context.Context to coordinate shutdown across the server, its handlers, and any background workers.

The problem with a naive shutdown

If you simply call http.ListenAndServe and then wait for a signal, the server stops accepting new connections but does not wait for existing ones to finish. Any long‑running request is terminated mid‑flight. This can be unacceptable for APIs that perform file uploads, database transactions, or external calls.

Leveraging context for coordinated cancellation

The net/http package already accepts a context.Context via http.Server.BaseContext (since Go 1.8) and, more importantly, the http.Server.Shutdown method expects a context that is cancelled when we want to stop. By creating a cancellable context that is triggered by OS signals, we can pass that same context to handlers, database drivers, or any long‑running goroutine.

Implementation

Below is a production‑ready snippet that sets up an HTTP server, installs a signal handler, and performs a graceful shutdown.

package main

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

func main() {
	// 1. Create a root context that will be cancelled when we want to shut down.
	rootCtx, cancel := context.WithCancel(context.Background())
	defer cancel() // ensure resources are released if main exits early

	// 2. Set up a signal channel to listen for SIGINT and SIGTERM.
	sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

	// 3. Define our handler – it receives the root context via middleware.
	mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		// Propagate the request‑specific context; it will be cancelled when rootCtx is done.
		ctx := r.Context()
		select {
		case <-ctx.Done():
			log.Println("request cancelled, aborting work")
			http.Error(w, "request cancelled", http.StatusGatewayTimeout)
			return
		default:
			// Simulate work that respects cancellation.
			doWork(ctx, w)
		}
	})

	// 4. Create the server, injecting the root context as BaseContext.
	server := &http.Server{
		Addr:    ":8080",
		Handler: mux,
		BaseContext: func(_ net.Listener) context.Context { return rootCtx },
	}

	// 5. Start the server in a goroutine so we can wait for signals.
	go func() {
		log.Println("server listening on", server.Addr)
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("server failed to start: %v", err)
		}
	}()

	// 6. Block until a signal is received.
	<-sigChan
	log.Println("shutdown signal received, initiating graceful shutdown")

	// 7. Create a shutdown context with a timeout to avoid hanging forever.
	shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer shutdownCancel()

	// 8. Ask the server to shut down; it will close listeners and then wait
	//    for active connections to finish (or for the shutdown context to cancel).
	if err := server.Shutdown(shutdownCtx); err != nil {
		log.Fatalf("graceful shutdown timed out or failed: %v", err)
	}

	log.Println("server stopped cleanly")
}

// doWork represents a unit of work that respects the incoming context.
func doWork(ctx context.Context, w http.ResponseWriter) {
	// Example: a loop that could be cancelled early.
	for i := 0; i < 5; i++ {
		select {
		case <-ctx.Done():
			return // exit early if the request/context is cancelled
		case <-time.After(500 * time.Millisecond):
			// pretend we are doing something useful
		}
	}
	w.Write([]byte("work completed\n"))
}

Why this works

The core idea is hierarchy of contexts. The rootCtx lives for the entire lifetime of the process. When a signal arrives we cancel it, which propagates to:

  • The server’s BaseContext, causing http.Server.Shutdown to unblock once the timeout expires or all connections drain.
  • Each request’s context.Context (accessed via r.Context()), allowing handlers and any downstream libraries to abort early.
  • Any manually spawned goroutine that receives rootCtx as a parent, ensuring background jobs also stop.

Because http.Server.Shutdown does not forcibly close connections, it gives existing requests a chance to finish. The timeout we provide (30 seconds in the example) protects us from a stuck connection that would otherwise block shutdown indefinitely.

Practical tips and gotchas

  1. Never cancel the root context before calling Shutdown. If you do, the server may stop accepting new connections but will immediately abort ongoing ones.
  2. Propagate contexts consistently. Any function that performs I/O should accept a ctx context.Context parameter and honor ctx.Done().
  3. Use signal.Notify with a buffered channel to avoid missing signals if the signal arrives before we start listening.
  4. Consider separate contexts for different subsystems (e.g., database, cache) if you need different shutdown behaviours, but always derive them from the root context so a single cancel reaches everything.
  5. Test the flow. Simulate a long‑running request, send SIGTERM, and verify the request completes before the process exits.

Conclusion

Using context.Context for graceful shutdown turns a potentially messy cleanup process into a predictable, deterministic sequence. It gives you control over both network‑level shutdown (via http.Server.Shutdown) and application‑level work cancellation, all with a few lines of idiomatic Go. I’ve adopted this pattern in every service I maintain, and it has saved countless hours of debugging half‑processed requests and leaked resources. Give it a try in your next project – the peace of mind is worth the small upfront effort.