Apps Artificial Intelligence CSS DevOps Go JavaScript Laravel Linux MongoDB MySQL PHP Python Rust Svelte Vue

Graceful HTTP Server Shutdown in Go

6 min read .
Graceful HTTP Server Shutdown in Go

Stopping a web server with Ctrl+C looks harmless during development, but production deployments need a more careful shutdown process. If a process exits immediately, active HTTP requests can be interrupted, clients may receive connection errors, and in-flight work can be left unfinished.

Go’s standard library already provides the pieces needed for a clean shutdown. The main tools are os/signal, context, and http.Server.Shutdown.

This guide shows a practical pattern for shutting down an HTTP server when the process receives SIGINT or SIGTERM.

Why graceful shutdown matters

A typical deployment platform stops an application in two stages:

  1. It sends a termination signal such as SIGTERM.
  2. It waits for a limited amount of time before forcefully killing the process.

A graceful shutdown lets the application use that window to stop accepting new connections and finish requests that are already running.

For an HTTP API, that usually means:

  • listen for operating-system shutdown signals;
  • tell the HTTP server to stop accepting new work;
  • give active requests a deadline to finish;
  • force the server closed if that deadline expires.

Complete example

The following program uses only the Go standard library. Go 1.27 is the current stable major release at the time of writing, and the APIs used here are also available in earlier recent Go versions.

package main

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

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "ok")
    })

    server := &http.Server{
        Addr:              ":8080",
        Handler:           mux,
        ReadHeaderTimeout: 5 * time.Second,
    }

    stopCtx, stop := signal.NotifyContext(
        context.Background(),
        os.Interrupt,
        syscall.SIGTERM,
    )
    defer stop()

    errCh := make(chan error, 1)
    go func() {
        log.Printf("server listening on %s", server.Addr)
        errCh <- server.ListenAndServe()
    }()

    select {
    case <-stopCtx.Done():
        log.Println("shutdown signal received")
    case err := <-errCh:
        if err != nil && !errors.Is(err, http.ErrServerClosed) {
            log.Fatalf("server error: %v", err)
        }
        return
    }

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

    if err := server.Shutdown(shutdownCtx); err != nil {
        log.Printf("graceful shutdown failed: %v", err)
        if closeErr := server.Close(); closeErr != nil {
            log.Printf("forced close failed: %v", closeErr)
        }
    }

    err := <-errCh
    if err != nil && !errors.Is(err, http.ErrServerClosed) {
        log.Fatalf("server stopped unexpectedly: %v", err)
    }

    log.Println("server stopped cleanly")
}

Save the file as main.go, then run it:

go run main.go

In another terminal, verify the endpoint:

curl http://localhost:8080/health

The response should be:

ok

Now send Ctrl+C to the server process. On Linux or in a container environment, a deployment system will commonly send SIGTERM instead.

Step 1: use an explicit http.Server

It is tempting to start a small service with this one-liner:

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

That is convenient, but an explicit http.Server is more useful in production because it gives you access to methods such as Shutdown and lets you configure timeouts.

server := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,
}

ReadHeaderTimeout limits how long the server waits for a client to send request headers. It is a sensible baseline protection for an internet-facing HTTP server.

Step 2: convert OS signals into a context

signal.NotifyContext creates a context that is canceled when one of the selected signals arrives:

stopCtx, stop := signal.NotifyContext(
    context.Background(),
    os.Interrupt,
    syscall.SIGTERM,
)
defer stop()

os.Interrupt covers the interrupt signal commonly generated by Ctrl+C. syscall.SIGTERM is the standard termination signal used by many process managers, containers, and orchestration platforms.

Using a context keeps the shutdown trigger compatible with the rest of Go’s cancellation model.

Step 3: run the server without blocking shutdown handling

ListenAndServe blocks while the server is running, so it needs to run in a goroutine if the main goroutine is also responsible for handling shutdown signals.

errCh := make(chan error, 1)

go func() {
    errCh <- server.ListenAndServe()
}()

The channel is buffered so the server goroutine can report its result even if the main goroutine is briefly busy handling the shutdown path.

The program then waits for either a signal or an unexpected server exit:

select {
case <-stopCtx.Done():
    log.Println("shutdown signal received")
case err := <-errCh:
    if err != nil && !errors.Is(err, http.ErrServerClosed) {
        log.Fatalf("server error: %v", err)
    }
    return
}

Handling both cases is important. If the server fails to bind to its port, for example, the program should report that error instead of waiting forever for a shutdown signal.

Step 4: give active requests a deadline

A graceful shutdown should still have an upper bound. A request handler might be stuck on a slow dependency, a database call, or another external service.

Create a separate timeout context for the shutdown operation:

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

if err := server.Shutdown(shutdownCtx); err != nil {
    log.Printf("graceful shutdown failed: %v", err)
}

server.Shutdown closes listeners, stops accepting new connections, and waits for active connections to become idle. If the context deadline expires first, Shutdown returns an error.

The best timeout depends on the service. Ten seconds is reasonable for a small example, but a production value should fit both your normal request duration and your deployment platform’s termination window.

Step 5: force close only as a fallback

If graceful shutdown exceeds its deadline, the application can fall back to server.Close():

if err := server.Shutdown(shutdownCtx); err != nil {
    log.Printf("graceful shutdown failed: %v", err)
    if closeErr := server.Close(); closeErr != nil {
        log.Printf("forced close failed: %v", closeErr)
    }
}

Unlike Shutdown, Close does not wait for active connections to finish. That is why it should be a fallback rather than the normal shutdown mechanism.

Why http.ErrServerClosed is not a failure

After Shutdown closes the server’s listeners, ListenAndServe returns http.ErrServerClosed.

That error describes the expected shutdown path, so it should not be logged as an application failure:

if err != nil && !errors.Is(err, http.ErrServerClosed) {
    log.Fatalf("server stopped unexpectedly: %v", err)
}

A common mistake is to wrap ListenAndServe directly in log.Fatal. During a normal graceful shutdown, that can make an expected server close look like a crash.

Testing graceful shutdown locally

A basic manual test is enough to verify the signal path:

go run main.go

Then call the health endpoint:

curl http://localhost:8080/health

Finally, press Ctrl+C in the server terminal.

For a Linux process, you can also send SIGTERM explicitly:

kill -TERM <pid>

The process should stop accepting new connections and exit after its active requests have completed or the shutdown timeout has expired.

Common pitfalls

Using the request context for server shutdown

Do not use a random request’s context as the parent for the shutdown timeout. A request context may already be canceled when its client disconnects.

Use a fresh context instead:

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

Forgetting SIGTERM

Handling only Ctrl+C can appear correct during local development but fail to trigger cleanup in a container or process manager. Listen for both the interrupt signal and SIGTERM when targeting Unix-like production systems.

Allowing shutdown to wait forever

Passing a context without a deadline to Shutdown can make a deployment hang indefinitely if a handler never finishes. Always choose a finite timeout that matches your operational environment.

Closing dependencies too early

If the application uses a database connection pool, message producer, or other shared dependency, do not close it before active HTTP handlers finish. A practical order is:

  1. stop accepting new HTTP traffic;
  2. wait for active handlers through server.Shutdown;
  3. close application dependencies;
  4. exit the process.

Production checklist

Before deploying a Go HTTP service, verify that:

  • the server uses an explicit http.Server;
  • SIGINT and SIGTERM are handled;
  • shutdown has a finite timeout;
  • http.ErrServerClosed is treated as an expected result;
  • long-running handlers respect request contexts where possible;
  • databases, queues, and other resources are closed after HTTP traffic drains;
  • the shutdown timeout is shorter than the platform’s forced-termination window.

Conclusion

Graceful shutdown is a small amount of code that prevents avoidable failures during deployments and restarts. Go’s standard library makes the pattern straightforward: receive termination signals through signal.NotifyContext, stop the server with http.Server.Shutdown, bound the wait with a timeout, and use Close only when graceful shutdown cannot finish in time.

Once this pattern is part of your service template, rolling deployments and routine restarts become much safer for clients and in-flight requests.

Related Posts

chevron-up