JavaRush /Courses /Go SELF /Server.Shutdown(ctx) and the shutdown timeout

Server.Shutdown(ctx) and the shutdown timeout

Go SELF
Level 63, Lesson 1
Available

1. Why you need Shutdown and why a timeout is mandatory

When you first bring up an HTTP server, it can seem like “stopping” it is just exiting main(). Unfortunately, a server is a social creature: it has active connections, in-flight requests, buffers, and clients that are not required to behave nicely. In this section, we will unpack what Shutdown actually does, and why without a timeout you risk ending up with a zombie process: it “seems like it should exit”, but for some reason it does not.

(*http.Server).Shutdown(ctx) is a graceful shutdown. The idea is: the server stops accepting new connections and gives already-started requests a chance to finish “normally”. But the key word here is chance. If a request hangs, a client holds the connection open, a handler takes too long, or you are waiting on an external resource somewhere, the wait can drag on.

This is where the important idea appears: the context sets an upper bound on how long we wait. A context in Go can report “time is up” via a deadline/timeout and cancellation. And context.WithTimeout is the standard way to create a derived context with a deadline.

If you call Shutdown(context.Background()), you are saying: “wait as long as needed”. In production, this is like saying “I will be back in five minutes” and disappearing for three days. So the rule is simple and strict:

Always call Shutdown with a context created via context.WithTimeout (timeout is mandatory).

A small table for quick orientation:

Method Meaning What happens to active requests Gotcha
srv.Shutdown(ctx)
graceful shutdown tries to wait for completion without a timeout it may wait forever
srv.Close()
hard shutdown connections are cut off users may see disconnects/errors

2. The canonical shutdown template with WithTimeout

Base pattern: WithTimeout + defer cancel() + Shutdown

Many beginners try to “cut the corner”: write srv.Shutdown(context.Background()) or create a timeout but forget cancel(). That is normal: the brain saves energy and assumes “it will handle itself”. But a context has resources (timers, links), and the right template is not bureaucracy, it is code hygiene.

The canonical stop pattern looks like this: create a context with a timeout, immediately add defer cancel(), then call Shutdown. WithTimeout creates a derived context that will be canceled automatically when time runs out.

Mini example (in a vacuum, without signals and without the surrounding server):

package main

import (
	"context"
	"log"
	"net/http"
	"time"
)

func stopServer(srv *http.Server) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		log.Printf("shutdown error: %v", err)
	}
}

What matters here is not only that the timeout exists, but also that cancel() is called guaranteed. Yes, the context will be canceled by the timeout anyway, but cancel() is “clean up your desk”: free the associated resources earlier if we finish faster.

How to log a Shutdown error

Beginners often expect Shutdown to be either “successful” or “broken”. In reality there is a third mode: “we tried, but time ran out”. And that is not always a catastrophe — but it is always a signal that your application does not perfectly respect cancellation, or that your timeout is actually too small.

If the timeout expired, you will typically see an error related to context.DeadlineExceeded. And ctx.Err() for such a context will also be context.DeadlineExceeded. This is the normal mechanism: the deadline arrived — the context was canceled — operations should finish.

An example of more “speaking” diagnostics without unnecessary drama:

package main

import (
	"context"
	"errors"
	"log"
	"net/http"
	"time"
)

func shutdownWithLog(srv *http.Server) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		if errors.Is(err, context.DeadlineExceeded) {
			log.Printf("shutdown: timeout exceeded")
			return
		}
		log.Printf("shutdown error: %v", err)
	}
}

It is important not to confuse two layers of errors:

  • http.ErrServerClosed is the expected error from ListenAndServe when the server is shutting down normally (filter it where you start the server).
  • context.DeadlineExceeded is the signal that the shutdown procedure did not fit into the deadline (log it in the shutdown branch).

3. How to choose a shutdown timeout

It is tempting to give a universal number: “set 5 seconds and you will be happy”. But in programming, magic numbers work poorly (except 42, but that is a different course). Instead, you need a way to reason: how much time does the server need to complete requests correctly?

The logic is: the shutdown timeout should be large enough for your “normal” requests to finish, and small enough so the process does not hang forever. If you build an API where a typical request takes 20–50 milliseconds, then 30 seconds for shutdown sounds generous. If you have operations that really take 2–3 seconds (for example, heavy aggregation), then a 1-second shutdown will regularly “cut off” the process too early.

Since in Go context propagates deadlines and cancellation down the call stack, a good architecture usually leads to handlers and dependencies either finishing in a reasonable time, or being canceled correctly via the context.

In a learning task server (a hypothetical todo/tasks) you can start with 5 seconds. The important thing is not the number, but that it exists and is explicitly written in the code.

A good style is to extract the timeout into a constant so it does not get lost among other logic:

package main

import "time"

const shutdownTimeout = 5 * time.Second

4. Graceful shutdown in main(): signal, timeout, Shutdown

Overall scheme

In main(), the server is usually started and blocks the thread via ListenAndServe(). At the same time, the termination signal comes from “outside”, so we need a place where we wait for the signal and initiate shutdown. We do this via signal.NotifyContext and waiting for <-sigCtx.Done(), and then we add the correct Shutdown with a timeout.

Diagrammatically, it looks like this:

flowchart TD
    A[main: start HTTP server] --> B[ListenAndServe blocks]
    A --> C["goroutine: wait for <-sigCtx.Done()"]
    C --> D["create shutdownCtx = WithTimeout(...)"]
    D --> E["call srv.Shutdown(shutdownCtx)"]
    E --> F[ListenAndServe returns]

Starting the server and waiting for a signal

Startup code (assume mux is already built):

package main

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

func main() {
	mux := http.NewServeMux()
	srv := &http.Server{Addr: ":8080", Handler: mux}

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

	go shutdownOnSignal(sigCtx, srv)

	err := srv.ListenAndServe()
	if err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Printf("server error: %v", err)
	}
}

The most interesting part is hidden in shutdownOnSignal. Let us implement it so that the timeout is mandatory:

package main

import (
	"context"
	"log"
	"net/http"
	"time"
)

const shutdownTimeout = 5 * time.Second

func shutdownOnSignal(sigCtx context.Context, srv *http.Server) {
	<-sigCtx.Done() // wait for the "stop event"

	ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		log.Printf("shutdown error: %v", err)
	}
}

Notice the detail: we do WithTimeout(context.Background(), ...), not WithTimeout(sigCtx, ...). Why? Because sigCtx is already canceled at the moment we reach shutdown (we just returned from <-sigCtx.Done()), and if we make it the parent, we get a context that is “canceled immediately”. Then shutdown effectively will not wait at all.

That is why the shutdown branch often uses a separate “neutral” parent — context.Background().

Why you cannot call Shutdown simply “after ListenAndServe”

A typical beginner trap looks like this:

err := srv.ListenAndServe()
srv.Shutdown(...)

It seems logical: “when the server finishes working, then do shutdown”. But ListenAndServe is a blocking call: while the server is running, this line will not return. So you will simply never reach Shutdown.

Therefore, shutdown is started “next to” the server, most often via a goroutine: the main goroutine serves the server, and a parallel one waits for the stop signal and calls shutdown. This is not “magic”, just two parallel tasks.

Mini refactor of main() for readability

As the code grows, main() turns into “spaghetti”: routes, DI, logging, and shutdown are all mixed together. We are not building deep layered architecture yet, but a small refactor is already useful: extract server construction into a function and keep only the lifecycle in main().

Example:

package main

import (
	"net/http"
)

func newServer() *http.Server {
	mux := http.NewServeMux()
	// mux.HandleFunc(...) // your handlers were written earlier
	return &http.Server{Addr: ":8080", Handler: mux}
}

Then main() becomes “cleaner”:

package main

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

func main() {
	srv := newServer()

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

	go shutdownOnSignal(sigCtx, srv)
	runServer(srv)
}

And runServer is the place where we filter http.ErrServerClosed:

package main

import (
	"errors"
	"log"
	"net/http"
)

func runServer(srv *http.Server) {
	err := srv.ListenAndServe()
	if err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Printf("server error: %v", err)
	}
}

This split into functions is good because now you almost physically cannot “accidentally” call Shutdown without a timeout: you have one function where shutdown lives, and it already contains WithTimeout.

5. Common mistakes with Server.Shutdown(ctx) and timeouts

Mistake #1: Shutdown(context.Background()) “because it is easier”.
This is the most dangerous version of “easier”. It turns process termination into a lottery: if a request hangs, Shutdown can wait for a very long time. The fix is simple and discipline-based: create the shutdown context only via context.WithTimeout(...), and make the timeout value explicit as a constant.

Mistake #2: they do WithTimeout, but forget cancel().
Even if the timeout triggers by itself, cancel() is the correct way to release resources associated with the context. In Go it is customary to call cancel() always, usually via defer cancel() immediately after creating the context. This small habit unexpectedly saves a lot of nerves in large systems.

Mistake #3: using an already-canceled context as the parent for the shutdown context.
A common scenario: you waited for <-sigCtx.Done(), and then you do context.WithTimeout(sigCtx, 5*time.Second). But sigCtx is already canceled, which means the child context will be canceled immediately too. As a result, shutdown is “instant” and there will be no waiting. The correct approach is to create the context in the shutdown branch from context.Background().

Mistake #4: logging http.ErrServerClosed as a “server error”.
This is the expected completion of ListenAndServe during a normal shutdown. If you log it as an error, you will get noise in the logs and false alerts. Filter it via errors.Is(err, http.ErrServerClosed) where you start the server.

Mistake #5: ignoring the Shutdown error.
If Shutdown does not fit into the timeout, you at least want to know about it from the logs. Even in a learning project it is useful to print a message; otherwise you will be sure that “it shut down nicely”, while in reality some requests were cut off by the deadline.

1
Task
Go SELF, level 63, lesson 1
Locked
Enter shutdown
Enter shutdown
1
Task
Go SELF, level 63, lesson 1
Locked
Shutdown timeout
Shutdown timeout
1
Task
Go SELF, level 63, lesson 1
Locked
Double shutdown
Double shutdown
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION