How to Implement Graceful Shutdown in a Go HTTP Server with Context and Signal Handling

How to Implement Graceful Shutdown in a Go HTTP Server with Context and Signal Handling

by | Jul 14, 2026 | Uncategorized | 0 comments

If you have ever deployed a Go HTTP service to Kubernetes, ECS, or a bare VM, you already know the pain: a deploy rolls out, your pod gets a SIGTERM, and suddenly users see 502s, half-written database rows, or dropped jobs. The fix has a name, and it is graceful shutdown.

In this tutorial, we will walk through a complete, production-ready pattern for implementing graceful shutdown in a Go HTTP server. We will use context.Context, handle SIGTERM and SIGINT, drain in-flight requests, and stop background workers cleanly. No fluff, just code you can paste into your project today.

Why Graceful Shutdown Matters

When your process receives a termination signal, the default behavior is to die immediately. That means:

  • In-flight HTTP requests are killed mid-response.
  • Background workers leave jobs in an inconsistent state.
  • Open database transactions are rolled back or, worse, left dangling.
  • Load balancers still route traffic to the dying instance for a few seconds.

A graceful shutdown ensures the server stops accepting new connections, finishes serving the ones already in progress, releases resources, and only then exits. This is what production systems expect.

server shutdown

The Building Blocks

Go gives us everything we need in the standard library:

Component Role
http.Server.Shutdown(ctx) Stops accepting new connections and waits for active ones to finish.
signal.NotifyContext Cancels a context when SIGINT or SIGTERM is received.
context.WithTimeout Sets a hard deadline so a stuck request cannot block shutdown forever.
sync.WaitGroup / errgroup.Group Coordinates background workers.

Minimal Graceful Shutdown Example

Let us start with the smallest useful version. This handles signals and shuts the HTTP server down cleanly with a timeout.

package main

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

func main() {
    ctx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(2 * time.Second) // simulate work
        w.Write([]byte("hello"))
    })

    srv := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    go func() {
        log.Println("server listening on :8080")
        if err := srv.ListenAndServe(); err != nil &&
            !errors.Is(err, http.ErrServerClosed) {
            log.Fatalf("listen: %v", err)
        }
    }()

    <-ctx.Done()
    log.Println("shutdown signal received")

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

    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Printf("forced shutdown: %v", err)
    }
    log.Println("server stopped cleanly")
}

What Just Happened

  1. signal.NotifyContext wires SIGINT and SIGTERM into a cancellable context.
  2. The server runs in its own goroutine so the main goroutine can wait.
  3. When a signal arrives, ctx.Done() unblocks.
  4. We create a fresh shutdownCtx with a 15 second deadline and call srv.Shutdown.
  5. Shutdown closes listeners, then waits for in-flight requests up to the deadline.

Adding Background Workers to the Mix

Real services rarely consist of just an HTTP server. You probably have a message consumer, a metrics flusher, or a cron-style ticker. These need to shut down cleanly too, otherwise you lose data or duplicate jobs.

Here is a pattern using errgroup that coordinates the HTTP server and one or more background workers under a single shutdown context.

package main

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

    "golang.org/x/sync/errgroup"
)

func runWorker(ctx context.Context) error {
    ticker := time.NewTicker(2 * time.Second)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            log.Println("worker: draining and exiting")
            // flush state, commit offsets, close connections here
            time.Sleep(500 * time.Millisecond)
            return nil
        case t := <-ticker.C:
            log.Printf("worker tick at %s", t.Format(time.RFC3339))
        }
    }
}

func main() {
    rootCtx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    mux := http.NewServeMux()
    mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })

    srv := &http.Server{Addr: ":8080", Handler: mux}

    g, gCtx := errgroup.WithContext(rootCtx)

    g.Go(func() error {
        log.Println("http: listening on :8080")
        if err := srv.ListenAndServe(); err != nil &&
            !errors.Is(err, http.ErrServerClosed) {
            return err
        }
        return nil
    })

    g.Go(func() error {
        return runWorker(gCtx)
    })

    g.Go(func() error {
        <-gCtx.Done()
        log.Println("shutdown initiated")
        shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
        defer cancel()
        return srv.Shutdown(shutdownCtx)
    })

    if err := g.Wait(); err != nil && !errors.Is(err, context.Canceled) {
        log.Fatalf("exit with error: %v", err)
    }
    log.Println("all components stopped cleanly")
}

Why errgroup Works So Well Here

  • If any goroutine returns an error, gCtx is cancelled and the others wind down.
  • The shutdown goroutine only triggers once the parent context is done.
  • g.Wait() blocks until every component has actually stopped.
server shutdown

Choosing the Right Shutdown Timeout

The timeout you pass to srv.Shutdown is one of the most important tuning knobs. Set it too low and you cut off legitimate requests. Set it too high and your orchestrator will SIGKILL the pod before you finish.

Environment Typical grace period Recommended shutdown timeout
Kubernetes (default terminationGracePeriodSeconds) 30s 20 to 25s
AWS ECS / Fargate 30s default, up to 120s 25s or aligned with stopTimeout
Bare metal / systemd Configurable via TimeoutStopSec Match systemd value minus 5s

Rule of thumb: always leave a few seconds of buffer below your platform's hard kill timeout.

The Kubernetes Readiness Probe Trick

Even with a perfect shutdown sequence, you may still see brief 502s during rolling deploys. The reason: Kubernetes sends SIGTERM at roughly the same moment it stops sending traffic. There is a tiny race window where the pod is shutting down but still receiving new requests.

The fix is to flip your readiness probe to failing before stopping the server, then sleep briefly so the kube-proxy iptables rules update:

var ready atomic.Bool
ready.Store(true)

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

// On shutdown:
<-rootCtx.Done()
ready.Store(false)
time.Sleep(5 * time.Second) // let load balancers notice
_ = srv.Shutdown(shutdownCtx)

Common Mistakes to Avoid

  • Calling os.Exit right after Shutdown. Background workers may still be flushing. Wait for them.
  • Reusing the cancelled root context for Shutdown. It is already done, so Shutdown returns instantly. Always create a fresh timeout context.
  • Forgetting hijacked connections. WebSockets and long-poll endpoints are not closed by Shutdown. Register them via srv.RegisterOnShutdown and close them yourself.
  • Ignoring http.ErrServerClosed. This is the expected return value after a clean shutdown, not an error to log as fatal.
  • No timeout at all. A single hanging request will block forever. Always pass a deadline.
server shutdown

Handling WebSockets and Long-Lived Connections

Because Shutdown does not close hijacked connections, you need to coordinate explicitly. Track active WebSocket connections in a set and close them when shutdown begins:

srv.RegisterOnShutdown(func() {
    wsHub.CloseAll() // your own method to close active sockets
})

This callback runs when Shutdown is called, before it starts waiting for active requests.

Putting It All Together: A Production Checklist

  1. Use signal.NotifyContext for SIGINT and SIGTERM.
  2. Run the HTTP server in its own goroutine.
  3. Coordinate workers and the server with errgroup.
  4. Always pass a fresh timeout context to Shutdown.
  5. Flip readiness to false before stopping the listener.
  6. Close hijacked connections explicitly via RegisterOnShutdown.
  7. Align timeouts with your orchestrator's grace period.
  8. Log clearly at each phase so you can debug shutdown issues in production.

FAQ

What is the difference between Close and Shutdown in Go?

Close immediately terminates all active connections, including in-flight requests. Shutdown stops accepting new connections and waits for active ones to finish, up to the context deadline. Always prefer Shutdown in production.

Does http.Server.Shutdown work with HTTPS?

Yes. Whether you use ListenAndServe or ListenAndServeTLS, Shutdown behaves the same way.

What signal does Kubernetes send when terminating a pod?

Kubernetes sends SIGTERM first, then waits up to terminationGracePeriodSeconds (default 30s) before sending SIGKILL. Your shutdown timeout must fit within that window.

Do I need a third-party library for graceful shutdown in Go?

No. Since Go 1.8 the standard library has everything you need: http.Server.Shutdown, context, and signal.NotifyContext (added in Go 1.16). Libraries can help, but they are not required.

How do I gracefully shut down a gRPC server alongside HTTP?

gRPC servers expose GracefulStop() which behaves similarly to http.Server.Shutdown. Run it inside the same errgroup shutdown goroutine so both servers stop in parallel.

Can I test graceful shutdown locally?

Yes. Start your server, send a slow request with curl, then press Ctrl+C in the server terminal. The request should complete normally and the process should exit only after the response is sent.

Conclusion

Graceful shutdown in a Go HTTP server is not just a nice-to-have, it is what separates a hobby project from a production service. With context.Context, signal handling, and a coordinated errgroup, you can shut down cleanly every time, whether on your laptop or on a Kubernetes cluster handling thousands of requests per second.

Drop the code snippets above into your project, tune the timeouts to match your platform, and your next deploy will be invisible to users. That is the real win.