Back to blog
Aug 08, 2026
5 min read

Graceful Shutdown in Go

How to shut down Go services cleanly — draining connections, finishing in-flight work, and avoiding the common traps that show up in production.

Most people learn about graceful shutdown the hard way. You deploy a new version, Kubernetes sends SIGTERM, and suddenly you start seeing 500s, incomplete writes, or connections being dropped mid-request. Then you realize your process was just calling os.Exit (or worse, doing nothing and getting hard-killed after the grace period).

This post is about doing it properly.


What “graceful” actually means

When your process receives a termination signal (usually SIGTERM), you want to:

  1. Stop accepting new work
  2. Let in-flight requests finish (within a reasonable deadline)
  3. Close connections and release resources cleanly
  4. Exit with code 0

If you skip any of these, clients will feel it.


The basic pattern

Here’s the skeleton I use in almost every service:

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

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

	go func() {
		log.Println("server starting")
		if err := srv.ListenAndServe(); err != nil && 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("server shutdown error: %v", err)
	}

	log.Println("server stopped")
}

A few things worth calling out:

  • signal.NotifyContext is cleaner than the old signal.Notify + channel pattern.
  • Shutdown stops accepting new connections and waits for active ones to finish.
  • The timeout on the shutdown context is important. Without it, a stuck request can hang your process forever and the orchestrator will eventually SIGKILL you anyway.

It’s not just the HTTP server

In real services you usually have more than one thing that needs draining:

  • HTTP / gRPC servers
  • Background workers / consumers
  • Database connection pools
  • Open streams or long-lived connections

A pattern that scales better is to treat shutdown as a coordinated process:

type App struct {
	server   *http.Server
	consumer *KafkaConsumer
	db       *sql.DB
}

func (a *App) Run(ctx context.Context) error {
	g, gctx := errgroup.WithContext(ctx)

	g.Go(func() error {
		return a.server.ListenAndServe()
	})

	g.Go(func() error {
		return a.consumer.Start(gctx)
	})

	// wait for signal or critical error
	<-gctx.Done()

	return a.shutdown()
}

func (a *App) shutdown() error {
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()

	var errs []error

	if err := a.server.Shutdown(ctx); err != nil {
		errs = append(errs, fmt.Errorf("http: %w", err))
	}
	if err := a.consumer.Stop(ctx); err != nil {
		errs = append(errs, fmt.Errorf("consumer: %w", err))
	}
	if err := a.db.Close(); err != nil {
		errs = append(errs, fmt.Errorf("db: %w", err))
	}

	return errors.Join(errs...)
}

errgroup is useful here because a failure in one component can trigger shutdown of the others.


gRPC specifics

grpc.Server has GracefulStop() and Stop().

go func() {
	<-ctx.Done()
	stopped := make(chan struct{})
	go func() {
		server.GracefulStop()
		close(stopped)
	}()

	select {
	case <-stopped:
	case <-time.After(10 * time.Second):
		server.Stop() // force
	}
}()

GracefulStop waits for RPCs to finish. If something is stuck (streaming RPC that never ends, for example), you need a hard deadline and fall back to Stop().


Common mistakes I still see

1. Ignoring the shutdown deadline

People call Shutdown with context.Background() and then wonder why the pod stays in Terminating for minutes.

2. Not stopping background work

Your HTTP server drains fine, but a goroutine keeps writing to the database after the pool is closed. Or a Kafka consumer keeps processing messages while the process is supposed to be exiting.

3. Logging and metrics during shutdown

If your logger or metrics client also needs a clean flush, do it after the main work is done, or give it its own small budget.

4. Treating SIGINT and SIGTERM differently

In containers you mostly care about SIGTERM. Locally you care about SIGINT (Ctrl+C). Handle both.

5. Forgetting that ListenAndServe returns http.ErrServerClosed

That error is expected during graceful shutdown. Don’t log it as fatal.


How much time should you give?

It depends. A few guidelines that have worked for me:

  • Simple HTTP APIs: 10–15 seconds is usually enough
  • Services with longer requests or streaming: 30 seconds
  • If you have message consumers, make sure the shutdown timeout is longer than your longest expected processing time (or design the consumer to be interruptible)

Also align this with your orchestrator. If Kubernetes terminationGracePeriodSeconds is 30s and your app waits 45s, you’ll still get SIGKILL.


A note on readiness vs liveness

During shutdown you should fail readiness probes as early as possible so the load balancer stops sending traffic. Liveness can stay up until the process actually exits. Mixing these up is a classic source of flaky deploys.


Closing thoughts

Graceful shutdown is not glamorous, but it’s one of those things that separates services that feel solid from services that feel flaky during deploys. Once you have a clean pattern, you mostly stop thinking about it — until someone adds a new background worker and forgets to wire it into the shutdown path.

Make the shutdown path explicit. Treat it as part of the application’s lifecycle, not an afterthought.