How to Manage Application Lifecycle in Go (Start, Run, Shutdown)

Use context.Context with signal handlers to propagate cancellation signals for graceful Go application shutdowns.

The kill switch

You deploy a Go service to production. It's processing a batch of database records. The ops team decides to restart the server. They send a signal. The process vanishes instantly. Half the records are corrupted. The database is left in an inconsistent state.

This happens because the application didn't know how to stop gracefully. It just died.

Go programs run as a collection of goroutines. The main function is the boss. When main returns, the Go runtime tears down the entire process. Every goroutine stops instantly. There is no cleanup. No flushing buffers. No closing connections. This is fast, but dangerous for stateful applications.

You need a way to coordinate shutdown. The operating system sends signals when you press Ctrl+C or when a container orchestrator stops a pod. Go needs to catch those signals and translate them into a language goroutines understand. That language is context.Context.

A context is a value that carries deadlines, cancellation signals, and request-scoped values. When you cancel a context, every goroutine holding a reference to it gets notified. It's a broadcast mechanism. The signal handler catches the OS signal. The context broadcasts the stop command. The workers listen and exit.

Context is the kill switch. Signals are the finger on the trigger.

The minimal lifecycle

Here's the skeleton: a signal listener goroutine, a worker loop, and a context to tie them together. The code uses os/signal to catch OS signals and context.WithCancel to propagate the stop command.

package main

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

func main() {
	// context.WithCancel creates a parent context that can be cancelled.
	// The cancel function tells all children to stop.
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	// signal.Notify creates a channel that receives OS signals.
	// We listen for Ctrl+C (SIGINT) and the kill signal (SIGTERM).
	sigs := make(chan os.Signal, 1)
	signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)

	// This goroutine blocks until a signal arrives.
	// When it does, it calls cancel to trigger shutdown.
	go func() {
		<-sigs
		log.Println("Signal received. Shutting down...")
		cancel()
	}()

	// Start the worker with the context.
	// Context is always the first parameter, conventionally named ctx.
	worker(ctx)

	// main blocks here until the worker returns.
	log.Println("Shutdown complete.")
}

// worker performs a task until the context is cancelled.
func worker(ctx context.Context) {
	for {
		select {
		// If the context is cancelled, ctx.Done() returns a closed channel.
		// The case triggers immediately, breaking the loop.
		case <-ctx.Done():
			log.Println("Worker stopping.")
			return
		default:
			log.Println("Working...")
			// Simulate work.
			// In real code, this is a blocking operation like a network request.
			time.Sleep(1 * time.Second)
		}
	}
}

Run this program. It prints "Working..." every second. Press Ctrl+C. The signal handler catches SIGINT. It calls cancel(). The worker sees the context is done. It prints "Worker stopping." and returns. main prints "Shutdown complete." and exits.

Goroutines run until they return. Context tells them when to return.

How the pieces fit together

The lifecycle relies on three mechanisms working in sequence.

First, the signal channel. signal.Notify registers the channel to receive specific OS signals. The channel is buffered to one. This prevents the goroutine from blocking if a signal arrives before the goroutine starts reading. The goroutine blocks on <-sigs. It sleeps until the OS pushes a signal into the channel.

Second, the context cancellation. context.WithCancel returns a context and a cancel function. The context holds an internal channel. When cancel is called, that channel closes. Any goroutine reading from ctx.Done() unblocks immediately. The context doesn't carry the signal itself. It carries the state of cancellation.

Third, the worker loop. The worker uses select to check multiple channels. ctx.Done() is one case. The default case runs if no channel is ready. This allows the worker to do work without blocking, while still checking for cancellation on every iteration.

The convention here is strict. context.Context always goes as the first parameter. The name is almost always ctx. Functions that take a context should respect cancellation. If you ignore the context, you break the contract. Other code will assume your function stops when the context cancels. If it doesn't, you get goroutine leaks.

Trust the context. Pass it down. Check it often.

Realistic example: HTTP server

A minimal worker is easy. A real application usually runs an HTTP server. The server accepts connections, handles requests, and spawns goroutines for each request. When you shut down, you need to stop accepting new connections and wait for active requests to finish.

The net/http package provides http.Server.Shutdown. It stops the server gracefully. It stops new connections and waits for active ones to complete. It takes a context. If the context expires before all connections close, the server forces a shutdown.

Here's the pattern. The server runs in a goroutine. The signal handler cancels the main context. main waits for cancellation. Then it creates a timeout context for the shutdown process itself. This prevents the app from hanging forever if a client connection is stuck.

package main

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

func main() {
	// context.WithCancel creates the main application context.
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	// Create the HTTP server.
	srv := &http.Server{Addr: ":8080"}

	// Start server in a goroutine so it doesn't block main.
	go func() {
		log.Println("Server starting on :8080")
		// Serve blocks until the server stops.
		// http.ErrServerClosed is expected during graceful shutdown.
		if err := srv.ListenAndServe(); err != http.ErrServerClosed {
			log.Fatalf("Server error: %v", err)
		}
	}()

	// Signal handling goroutine.
	sigs := make(chan os.Signal, 1)
	signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
	go func() {
		<-sigs
		log.Println("Signal received. Initiating graceful shutdown...")
		cancel()
	}()

	// Wait for context cancellation.
	// This blocks until the signal handler calls cancel().
	<-ctx.Done()

	// Create a timeout context for the shutdown process itself.
	// This prevents the app from hanging forever if connections are stuck.
	shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer shutdownCancel()

	// Shutdown stops the server gracefully.
	// It stops new connections and waits for active ones to finish.
	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Fatalf("Server forced to shutdown: %v", err)
	}

	log.Println("Server exited.")
}

The if err != nil check is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. If Shutdown returns an error, it means the timeout expired or the server was already closed. You log it and exit.

A server that drops connections on shutdown is a server that breaks trust.

Pitfalls and compiler errors

Lifecycle management introduces specific failure modes. The most common is the goroutine leak.

A goroutine leak happens when a goroutine waits on a channel that never gets closed. Or when a goroutine doesn't check the context and blocks forever. If you spawn a goroutine for every request, and one request hangs, that goroutine stays alive. It holds memory. It holds file descriptors. Over time, the application runs out of resources.

Always have a cancellation path. Every long-lived goroutine must check ctx.Done(). If you use a channel, close it when the work is done. If you use a worker pool, shut down the pool before exiting.

The compiler helps you avoid basic mistakes, but it can't catch logic errors. If you forget to pass the context to a function, the compiler rejects the program with cannot use ctx (type context.Context) as string value in argument if you pass the wrong type, or undefined: ctx if you reference a variable that doesn't exist. If you import syscall but don't use it, you get imported and not used. Go forces you to use what you import. This keeps the code clean.

Another pitfall is blocking the shutdown. If your worker does a long operation and doesn't check the context in the middle, the shutdown waits for the operation to finish. If the operation takes ten minutes, the shutdown takes ten minutes. Break long operations into smaller steps. Check the context between steps.

The worst goroutine bug is the one that never logs.

When to use what

Go provides several tools for lifecycle management. Pick the right one for the job.

Use context.WithCancel when you need to stop a group of goroutines immediately. The cancel function gives you manual control over the shutdown trigger.

Use context.WithTimeout when you need to enforce a deadline on an operation. The context cancels automatically after the duration expires. This is essential for shutdown processes to prevent hanging.

Use context.WithDeadline when you have a specific wall-clock time to stop by. The context cancels when the deadline arrives.

Use os/signal with a channel when you need to react to OS signals like Ctrl+C or SIGTERM. This is the standard way to catch shutdown requests from the user or the container runtime.

Use http.Server.Shutdown when you are running an HTTP server and need to drain active connections. It handles the complexity of stopping listeners and waiting for requests.

Use a simple return in main when you have no background goroutines and just need to exit. If the program is sequential, main returning is enough.

Pick the context variant that matches your timeout requirement.

Where to go next