Go HTTP Server graceful shutdown

Published 2026-09-25


I want to share how I implement graceful shutdown in my Go HTTP Servers. The goal of this article is to explain the graceful shutdown components and to help you implement it yourself. You only need the standard library, but you have to explicitly set it up yourself.

But first a quick step back showing what a basic, non-graceful, setup looks like. This is how most tutorials teach you to setup a HTTP Server in Go. The main() function initializes a http.Server with a request router. Our example below has one endpoint: /sleep/N, where N is the number of seconds the server will sleep before it returns OK. This code is handled by the sleepHandler function:

func main() {    router := http.NewServeMux()    router.HandleFunc("GET /sleep/{durationSeconds}", sleepHandler)     srv := http.Server{        Addr:         ":5001",        Handler:      router,        IdleTimeout:  time.Minute,        WriteTimeout: time.Minute, // Accommodate long sleeps        ReadTimeout:  10 * time.Second,    }     fmt.Println("server started")    if err := srv.ListenAndServe(); err != nil {        log.Printf("server shutdown: %v", err)        os.Exit(1)    }    // srv.Close() <-- not necessary, closing the program has the same effect}

func sleepHandler(w http.ResponseWriter, r *http.Request) {    duration, err := time.ParseDuration(r.PathValue("durationSeconds") + "s")    if err != nil {        http.Error(w, "sleep duration must be an int", http.StatusBadRequest)        return    }     select {    case <-r.Context().Done():        return // Client aborted the request    case <-time.After(duration):        w.Write([]byte("OK\n")) // Sleep completed    }}

Once you call srv.ListenAndServe() it will block the main() function, pausing its execution until ListenAndServe() returns. To stop this program, we the user has to press a keyboard shortcut like ctrl+c to get the program to exit. The shortcut trigger the OS to sends an interrupt signal (SIGINT) to the program, making it stop whatever it was doing and exit.

The main drawback of this basic example is the program abruptly closes active requests when it shuts down. If that request was doing something important, that work may potentially be lost. As we can see in the diagram below, any active request is dropped when the http server receives the shutdown signal:

Active requests are mercilessly dropped

With graceful shutdown we can achieve the setup below where active connections are allowed to complete. Each request sleeps for four seconds before completing. Here the program is only given a two second graceful-shutdown window so some requests are still dropped. If the window was four seconds then all active requests would have time to complete. It's up to you to decide how long your window should be.

Active requests are allowed to complete, mostly

Three http.Server methods

To make our server handle shutdowns gracefully we want to use three methods on the http.Server object. The first method is ListenAndServe() that is responsible for listening for new requests and routing them to the correct HTTP handler. As long as this method is running, new requests are accepted by the server. When it returns, any new requests are rejected. This is an important step of a graceful shutdown as the program can never shutdown if there are constantly new requests to handle.

The second method is Shutdown(). It has two jobs. The first is to tell ListenAndServe() to return, stopping our server from accepting new requests. Its second job is to wait for all requests to complete. When all requests have completed, the Shutdown() function returns. This signals to the caller that the server is idle and that the program can exit. It takes a context.Context as argument, allowing the caller to force Shutdown() to return even though not all requests were completed.

A context in Go is specifically made to handle lifetimes inside a Go program. Cancelling a context means that it's lifetime has ended and that we should stop whatever we're doing and return. Contexts are often passed as argument into functions, giving the caller a stop-signal it can use to stop the called function and regain control.
For example, every request from a client comes with its own context in the http.Server. If the client aborts the request, usually by closing the browser or browser tab, the context for that request is cancelled inside the http.Server. This allows our sleepHandler() to abort and return early. There is no reason the HTTP handler should keep using resources and do extra work if the client does not want the result. Passing a context into Shutdown() is useful when stubborn requests refuses to complete. The program can't always wait forever before it shuts down.

Finally, the Close() method forcefully closes any active requests. Again, stubborn requests. We don't have to call this function, but we may be forced to. The example above didn't call it. This is because as soon as ListenAndServe returned, the program had no more code to run and simply exited. When the program exits, all active requests are killed.

signal.NotifyContext

To make the shutdown truly graceful, we need the ctrl+c OS interrupt signal to not actually abort the program. We want to intercept the signal and then gracefully shut down the program, on our own terms. We accomplish this with signal.NotifyContext(), intercepting OS signals and cancelling the attached context when it does.

Graceful Shutdown Example

Let's look at a http.Server handling shutdown gracefully:

func main() {    router := http.NewServeMux()    router.HandleFunc("GET /sleep/{durationSeconds}", sleepHandler)    srv := http.Server{        Addr:         ":5001",        Handler:      router,        IdleTimeout:  time.Minute,        WriteTimeout: time.Minute,        ReadTimeout:  10 * time.Second,    }     // OS interrupt signal triggers appCtx to cancel, ending its lifetime    appCtx, allowForcedShutdown := signal.NotifyContext(        context.Background(),        syscall.SIGINT,    )     // Proper logging because we are sophisticated software engineers, right?    log := slog.New(slog.NewTextHandler(        os.Stdout,        &slog.HandlerOptions{Level: slog.LevelDebug},    ))     // Run in separate goroutine to not block main() while it's running    var wg sync.WaitGroup    wg.Go(func() {        log.Debug("server accepting new connections")        if err := srv.ListenAndServe(); err != nil {            if appCtx.Err() != nil {                log.Debug("app shutdown initiated, rejecting new connections")                return            }            log.Error("server rejecting new connections", "reason", err)        }    })     log.Debug("waiting for shutdown signal...")    <-appCtx.Done() // Block until shutdown signal is received    log.Debug("signal received, initiating graceful shutdown")    allowForcedShutdown()     // stop ListenAndServe then give requests X seconds to complete    gshutCtx, gshutTimer := context.WithTimeout(        context.Background(),        30*time.Second,    )    defer gshutTimer()     if err := srv.Shutdown(gshutCtx); err != nil {        srv.Close() // Context cancelled, force-close requests        log.Warn("graceful shutdown failed, requests were dropped", "reason", err)    } else {        log.Debug("graceful shutdown success, all requests completed")    }     log.Debug("waiting for tracked goroutines to complete...")    wg.Wait() // Wait for ListenAndServe to return, should be immediate    log.Debug("server stopped")}

I added lots of logging to help explain the code; I may have overdone it.

The first big change is adding appCtx. It's derived from signal.NotifyContext that listens for a SIGINT signal from the OS. When the signal is received, only appCtx is cancelled. This is an important difference as it allows the program to continue running. It's now up to the program to gracefully close itself.
We can undo this change in behavior by calling allowForcedShutdown(). This tells the Go context to stop intercepting signals. We only need to intercept the signal once. Subsequent ctrl+c presses should be allowed to force-kill the program.

In the main function, we initiate the graceful shutdown below the <-appCtx.Done() line. This function call is blocking until appCtx is cancelled. The context has served its purpose. All remaining code is specifically for performing the graceful shutdown.

We start by creating a new gshutCtx context that has a 30 second timer. We then pass it in when calling srv.Shutdown(gshutCtx). This means that the function will return in at most 30 seconds. Hopefully it returns before the timeout, meaning that all requests were able to complete within that time period. If they didn't all finish the function returns an error. We can check the error and call srv.Close() ourselves if necessary.

The final step is to wait for all tracked goroutines to complete. This step is also optional. We're only tracking ListenAndServe right now, and we know it returned as soon as Shutdown was called. At this point there are no active requests, all goroutines have returned and the program can finally exit.

http.Server BaseContext

One thing I haven't mentioned in this article is that you can pass a BaseContext into the http.Server when initializing it. Each connection-context is created as a child of the base context. When the base context is cancelled (perhaps via OS signal), each child context is also cancelled. This allows the server to gracefully end each connection. This is useful for long-lived sessions, like Websockets or SSE. The server can now close these requests gracefully without using srv.Close() that brutally ends them. This setup makes less sense for short-lived requests as we typically want them to complete before shutting down.

func main() {    router := http.NewServeMux()    router.HandleFunc("GET /sleep/{durationSeconds}", sleepHandler)     appCtx, _ := signal.NotifyContext(context.Background(), syscall.SIGINT)    srv := http.Server{        Addr:         ":5001",        BaseContext:  appCtx, // Passing in our own BaseContext        Handler:      router,        IdleTimeout:  time.Minute,        WriteTimeout: time.Minute,         ReadTimeout:  10 * time.Second,    }    [...]}

The End

If you want to see the different versions and how I test them, you can find the source code here: https://github.com/emieli/go-http-graceful-shutdown
That's all I have for you today. Thanks for reading!

Copyright 2021-2026, Emil Boklund.
All Rights Reserved.