If you've built a web server in Go, you probably started it with a simple http.ListenAndServe()
. It works great until you need to stop it. What do you do? You hit Ctrl+C
, and the process dies instantly.
When we stop our API application (usually by pressing Ctrl+C ) it is terminated immediately with no opportunity for in-flight HTTP requests to complete. This isn’t ideal for two reasons:
- It means that clients won’t receive responses to their in-flight requests — all they will experience is a hard closure of the HTTP connection.
- Any work being carried out by our handlers may be left in an incomplete state.
I am going to demonstrate to you how to mitigate these problems by adding
graceful shutdown
functionality inside your application in a very simple way, so that in-flight HTTP requests have the opportunity to finish being processed before the application is terminated.
What is a Graceful Shutdown?
A graceful shutdown is a process where a server:
- Stops accepting new incoming connections the moment the shutdown signal is received.
- Continues to handle any requests already in progress.
- Waits for all active requests to finish before shutting down completely.
- Has a timeout, so it doesn't wait forever for a stuck request.
The Starting Point: Normal Server
Here’s a standard, basic Go web server. It has the problem we described—hitting Ctrl+C kills it instantly leaving no room for the background operations running in other goroutines to actually finish there tasks.
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, World!")
})
fmt.Println("Starting server on :4000")
err := http.ListenAndServe(":4000", mux)
if err != nil {
fmt.Println("Error starting server:", err)
}
}
Step 1: Listening for OS Signals
When our application is running, we can terminate it at any time by sending it a specific
signal. A common way to do this, which you’ve probably been using, is by pressing Ctrl+C
on your keyboard to send an interrupt signal — also known as a SIGINT
.
It’s important to explain upfront that some signals are catchable and others are not. Catchable signals can be intercepted by our application and either ignored, or used to trigger a certain action (such as a graceful shutdown).Other signals, like SIGKILL
, are not catchable and cannot be intercepted.
We'll use a buffered channel
to receive these signals.
Why a buffered channel? The signal.Notify()
function that sends signals doesn't wait for a receiver to be ready. If we used a regular (unbuffered
) channel, a signal could be sent at the exact moment our channel isn't ready to receive, and the signal would be missed. A buffer of size 1 ensures that a signal can be "dropped off" in the channel and picked up a moment later, guaranteeing we never miss it.
The next thing that we want to do is update the server so that it ‘catches’ any SIGINT
and SIGTERM
signals. As I mentioned above, SIGKILL
signals are not catchable
(and will always cause the application to terminate immediately), and we’ll leave SIGQUIT
with its default behavior (as it’s handy if you want to execute a non-graceful shutdown via a keyboard shortcut).
To catch the signals, we’ll need to spin up a background goroutine which runs for the lifetime of the application we are writing. In this background goroutine, we can use the signal.Notify()
function to listen for specific signals and relay them to a channel for further processing.
I’ll demonstrate how to set up the listener goroutine in the background:
go func() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
s := <-quit
fmt.Println("Caught signal:", s.String())
}()
The code above isn’t doing much —But the important thing is that it demonstrates
the pattern of how to catch specific signals and handle them inside your code. With that in place I'll now proceed to the actual implementation of graceful shutdown that's our main topic in step two below.
Step 2: Orchestrating the Shutdown
After catching a specific signal using our useful goroutine above, we now need a way to inform our server to begin the process of graceful shutdown process. Luckily enough our http.Server
struct has a very powerful method known as Shutdown()
that we can call to do just that.The official documentation describes this as follows:
Shutdown gracefully shuts down the server without interrupting any active connections.
Shutdown works by first closing all open listeners, then closing all idle connections, and
then waiting indefinitely for connections to return to idle and then shut down.
Before writing the code let me explain step by step how this works:
- The Shutdown() method on HTTP server takes context.Context as an argument, this allows one to set a timeout. The timeout simply acts as a safety net as it tells the shutdown process to not wait longer than time set i.e 37 seconds.
- Calling
server.Shutdown()
causesserver.ListenAndServe()
to immediately stop blocking and return an http.ErrServerClosed error. This is a "good" error—it allows us to know the graceful shutdown has begun. -
server.Shutdown()
itself then blocks and waits for all active requests to finish (or for its context to time out). - Once it's done, server.Shutdown() returns its own result: nil if successful, or an error if it timed out.
Now lets head to code and show how all this fits in together to implement graceful shutdwon.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
if err := serve(); err != nil {
fmt.Println("Server error:", err)
os.Exit(1)
}
fmt.Println("Server stopped successfully.")
}
func serve() error {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, World!")
})
server := &http.Server{
Addr: ":4000",
Handler: mux,
}
shutdownError := make(chan error)
go func() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
s := <-quit
fmt.Println("Caught signal:", s.String())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
shutdownError <- server.Shutdown(ctx)
}()
fmt.Println("Starting server on", server.Addr)
err := server.ListenAndServe()
if !errors.Is(err, http.ErrServerClosed) {
return err
}
err = <-shutdownError
if err != nil {
return err
}
return nil
}
Seeing it in Action
For now, when you try to run this and press Ctr+C, it still looks like it shuts down almost instantly and that is very true because at the moment we have no slow request coming into our server. So to demonstrate our graceful shutdown implementation can handle slow request and thus the issues we highlighted above, we'll create a slow endpoint and see how it's handled, lets go into the code:
func slowHandler(w http.ResponseWriter, r *http.Request){
fmt.Println("Starting slow request...")
time.Sleep(10 * time.Second)
fmt.Println("...finished slow request.")
}
In order for this to work you'll need to create an endpoint for this handler, just below the line where we create the serve mux, like this:
mux.HandleFunc("/slow", slowHandler)
Run the Test
You'll need two terminals so as to see everything happening real time.
Terminal 1:
for running your Go server.
go run .
# Output: Starting server on :4000
Terminal 2:
we make a request to the slow endpoint.
curl http://localhost:4000/slow
Terminal 1 (Immediately):
As soon as curl is running, switch back to your server's terminal and press Ctrl+C.
logs in terminal 1:
Starting server on :4000
Starting slow request... <-- curl started the request
# You press Ctrl+C now
Caught signal: interrupt <-- The shutdown process begins!
# The server is now waiting patiently... (for about 9 seconds)
...finished slow request. <-- The handler was allowed to finish
Server stopped successfully. <-- Clean exit!
In terminal 2, curl completes successfully after 10 seconds. We just implemented a graceful handling of an in-flight request!
That's it! Any time that we want to gracefully shutdown our application, we can do so by sending a SIGINT ( Ctrl+C )
or SIGTERM signal
. So long as no in-flight requests take more than 30 seconds to complete, our handlers will have time to complete their work and our clients will receive a proper HTTP response.And if we ever want to exit immediately, without a graceful shutdown, we can still do so by sending aSIGQUIT ( Ctrl+\ )
or SIGKILL signal
instead.
THANK YOU AND HAPPY CODING!
Top comments (0)