Blog

Graceful Shutdowns in Go (Part 1): A Deep Dive into Clean Exits

/4 min read/
GoReliabilityProduction

What I learned building a Go HTTP server that shuts down cleanly, covering signals, contexts, and a production-ready shutdown flow.

Gracefully shutting down a server might seem like a small detail, but in real-world production systems it’s critical. I recently built a small Go HTTP server and decided to get this part right, and learned a lot in the process.

This post is for Go developers who have shipped an HTTP service but haven’t fully thought through what actually happens when the process stops.

Scope

  • Handling SIGINT and SIGTERM in Go

  • Wiring OS signals into context cancellation

  • Gracefully shutting down an HTTP server

This post focuses on single-process Go services. Distributed shutdown coordination, orchestration systems, and multi-node rollouts are intentionally out of scope.

Why Graceful Shutdown Matters

The Naive Approach

A typical Go server often looks like this, blocking the main goroutine until the process is killed:

log.Fatal(http.ListenAndServe(":8080", handler))

When someone presses Ctrl+C, the process exits immediately. Active connections are dropped and any in-flight work like database writes or file operations may be lost. Fine for experiments, not acceptable for production.

What I Wanted to Achieve

  • Start the server in a background goroutine

  • Listen for SIGINT and SIGTERM

  • Stop accepting new requests immediately on shutdown

  • Let active requests finish with a timeout

  • Clean up background resources safely

Signals and Context Cancellation

Go’s context package pairs naturally with os/signal. signal.NotifyContext gives you a context that is cancelled when the process receives a shutdown signal, plus a cleanup function that must be deferred to avoid leaking signal handlers.

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

The Final Implementation

// ServeWithGracefulShutdown starts the HTTP server and ensures
// graceful shutdown when a termination signal is received.
func ServeWithGracefulShutdown(ctx context.Context, srv *HTTPServer) {
    shutdownCtx, cleanupSignalHandler :=
        signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
    defer cleanupSignalHandler()

    srv.StartAsync()

    <-shutdownCtx.Done()
    log.Println("Shutdown signal received. Preparing to stop the server...")

    gracefulShutdownCtx, cancel :=
        context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()

    if err := srv.GracefullyStop(gracefulShutdownCtx); err != nil {
        log.Fatalf("Failed to gracefully shut down server: %v", err)
    }

    log.Println("Server shutdown complete.")
}

Step-by-Step Breakdown

  1. Create a signal-aware context that cancels on SIGINT and SIGTERM

  2. Start the HTTP server in a background goroutine

  3. Block until the shutdown context is cancelled

  4. Wrap shutdown in a context.WithTimeout

  5. Call http.Server.Shutdown to stop accepting new requests

Gotchas and Things That Surprised Me

  • Forgetting to defer the cleanup function from NotifyContext

  • Long-running handlers must respect request contexts or shutdown will hang

  • Using context.Background() inside handlers bypasses shutdown cancellation

Key Takeaways

  • Graceful shutdown is about coordination, not just stopping a server.

  • Signals should funnel into a single cancellation path.

  • http.Server.Shutdown stops new requests and drains existing ones.

  • Timeouts protect you from hanging shutdowns.

Resources