JavaRush /Courses /Go SELF /Stopping an HTTP server without losing requests

Stopping an HTTP server without losing requests

Go SELF
Level 63, Lesson 0
Available

1. Why you can’t “just stop the process”

If you’ve only been writing learning programs so far, then “stopping the server” looked roughly like this: you pressed Ctrl+C, the terminal went “pfff”, and everything ended. For a small demo, that’s fine. But as soon as a server starts handling real requests, an unpleasant truth suddenly shows up: “the process exited” does not mean “clients received normal responses”.

Imagine that right now your service is handling a request to create a task: it has already read the request body, already started writing to storage… and then the process gets killed “hard”. The client may get a broken connection, and the data may end up in some weird intermediate state. And then there are long-polling, large responses, slow clients, and all the fun that makes backend engineers start drinking herbal tea and calling it “observability”.

That’s exactly why we introduce the concept of graceful shutdown: the server stops accepting new connections and tries to correctly finish requests that have already started. In this lecture we’ll assemble a server lifecycle skeleton: caught an OS signal → canceled the context → moved on to shutdown. The key is to wait for the event correctly: via <-ctx.Done() and without loops like “sleep 100 ms and check again”.

Which signals matter: SIGINT and SIGTERM

OS signals sound like something from the world of “kernels, drivers, and grim people in hoodies”, but in practice they’re just the standard way for the operating system to tell your process: “time to exit”. The idea is very simple: the process gets a chance to leave cleanly instead of disappearing in the middle of important work like a minor-scene character.

The most important signals for us are SIGINT and SIGTERM. SIGINT usually arrives when you press Ctrl+C in the terminal (i.e., “the user asked to stop”). SIGTERM is a more “system” signal: it’s often sent by process managers, containers, orchestrators, CI infrastructure, or simply an admin who decided to stop a service in a civilized way.

It helps to keep a small meaning table in mind:

Signal How it typically arrives Human meaning
SIGINT
Ctrl+C “Stop, please”
SIGTERM
system/orchestrator “Finish your work gracefully”

Important: a signal is not the command “kill yourself immediately”, but a polite request to terminate. Our job is to accept it and initiate a predictable shutdown.

2. Signals → context: NotifyContext and waiting on Done

signal.NotifyContext: a “signal context” without callbacks

Once you’ve started to like context.Context in Go, going back to low-level “subscribe to signals, read from a channel, don’t forget to unsubscribe” is not something you want. And the good news is: you don’t have to.

The os/signal package gives us signal.NotifyContext(parent, ...), which turns OS signals into a context. That is, we get a ctx that will be canceled when the process receives one of the selected signals. It’s very “Go-ish”: from there on, we work everywhere via ctx, as if it were a normal cancellation by timeout or by pressing a “Cancel” button.

Mini example: create a context that will be canceled on Ctrl+C and on SIGTERM:

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

func makeSignalContext() (context.Context, func()) {
	sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	return sigCtx, stop
}

It’s important to notice the second return value: stop. This function unsubscribes us from signals and frees subscription resources. It’s almost always placed right next to it with a defer, so you don’t forget:

sigCtx, stop := makeSignalContext()
defer stop()

If you forget stop(), the program may not explode immediately. But it’s exactly the kind of bug that then lives in code for years because “well, it worked”. So it’s better to build the habit: created a resource — immediately add a defer to release it.

ctx.Done(): how to correctly wait for cancellation without polling

Here we lock in the key building block of this lecture: how to wait for shutdown correctly.

context.Context has a Done() method that returns a channel. This channel is used as a “cancellation signal”: when the context is canceled, the channel “fires” (technically, it gets closed), and everyone waiting on it wakes up.

Why is the channel read-only? Because whoever is waiting for cancellation usually should not be able to cancel the “parent”. This is reflected in the interface itself: Context has no Cancel method, and Done() returns a receive-only channel.

In practice it comes down to a single line that looks like magic until you get used to it:

<-sigCtx.Done() // blocks until the context is canceled

This is not polling, not “check every 100 ms”, not “spin in a loop”. It’s honest event waiting. With this approach, the Go runtime does not make your CPU sadly sit at 100% just because you’re waiting for Ctrl+C.

To really “see” this waiting, you can make a mini demo (purely for understanding, not for production):

import (
	"context"
	"fmt"
)

func waitForCancel(ctx context.Context) {
	fmt.Println("waiting...") // waiting...
	<-ctx.Done()
	fmt.Println("canceled!")  // canceled!
}

The idea is: while ctx is not canceled, the program is stopped on the line <-ctx.Done() and doesn’t bother anyone.

4. Skeleton: server, goroutine, and waiting for a signal

Why you can’t avoid a goroutine

Now let’s assemble everything into a single lifecycle. The main technical problem is simple: ListenAndServe() blocks execution. If you write “start the server, then wait for a signal” in one line, you will never reach the signal wait — because the server has already taken your main thread forever (well, or until it gets stopped).

So we use a classic trick: we start the server in a way that allows waiting for a signal in parallel. For this we only need one goroutine. For now, think of a goroutine as a “lightweight execution thread”: we run a piece of work next to the main flow so the main flow can handle coordination. We won’t dive deep into concurrency here — we only need this practical trick.

Let’s draw a simple scheme of who does what:

flowchart TD
    A[main goroutine] --> B[sozdaem sigCtx cherez NotifyContext]
    B --> C[zapuskaem HTTP-server]
    C --> D["zhdem <-sigCtx.Done()"]
    D --> E[initsiiruem ostanovku servera]

main skeleton: context, server, and starting in a goroutine

Let’s assume we already have a mux with handlers for our learning task API (we built it in previous topics). In this lecture we focus on the signal and waiting.

main skeleton (part 1): create the context and the server.

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

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

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

	_ = sigCtx
	_ = srv
}

main skeleton (part 2): start the server in a goroutine so it doesn’t block main.

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

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

Notice http.ErrServerClosed. When the server is stopped normally, ListenAndServe() often returns exactly this error. There’s no need to log it as a “crash”, otherwise your logs will look like a hypochondriac’s diary: “everything is bad!” — even though everything is fine. So we filter it via errors.Is.

And finally — waiting for the signal without polling, using exactly the line we fixed earlier:

import "log"

log.Printf("server started on %s", srv.Addr)
<-sigCtx.Done()
log.Printf("signal received, shutting down...")

At this point main “wakes up” when a signal arrives. And this is exactly where you will initiate graceful shutdown.

Important: in this lecture we do not go deep into the shutdown call itself (there are nuances there, and the main one is that a timeout is mandatory). Here our goal is to correctly “live” until the shutdown moment: catch the signal and not waste CPU on waiting loops.

To tie everything into a more coherent fragment (and still not make one huge listing), you can mentally glue the pieces together like this:

sequenceDiagram
    participant M as main goroutine
    participant S as server goroutine
    participant OS as OS signals

    M->>M: sigCtx := NotifyContext(...)
    M->>S: go ListenAndServe()
    M->>M: <-sigCtx.Done()
    OS-->>M: SIGINT/SIGTERM
    M->>M: nachinaem ostanovku

Mini route /health, to see the behavior “live”

So this doesn’t stay abstract, let’s add one simple “health” endpoint to ServeMux that responds instantly. This is convenient: you can hit it from a browser or with curl and see that the server is alive, then press Ctrl+C and watch how it cleanly goes through the shutdown path.

Handler example (let’s keep it as short as possible):

import (
	"net/http"
)

func handleHealth(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte("ok\n")) // ok
}

Route registration in main:

mux := http.NewServeMux()
mux.HandleFunc("GET /health", handleHealth)

Now your lifecycle looks like this: the server is running, /health responds with ok\n, you press Ctrl+C, sigCtx is canceled, <-sigCtx.Done() unblocks, and then shutdown begins.

Yes, we still haven’t shown “how exactly to stop it” — we’re deliberately keeping the focus on the signal and waiting. What matters is that we already have an entry point into shutdown, and it’s not tied to “check a flag in a loop”, but tied to the context.

5. Common mistakes

Mistake #1: waiting for a signal via a polling loop with time.Sleep.
When people first hear “you should wait for an event”, their hand writes for { ... time.Sleep(100*time.Millisecond) }. It works, but it’s poor style: extra delay, extra load, more code, more places for bugs. In Go, waiting for context cancellation is straightforward: <-ctx.Done(). The channel is already a ready-made “button”; no need to invent your own.

Mistake #2: forgetting to call stop() after signal.NotifyContext.
NotifyContext subscribes the process to signals. If you don’t call stop(), you keep the subscription hanging around longer than necessary. In small demos it’s often “not visible”, but it’s a dangerous habit. Train yourself: got stop — immediately write defer stop() next to it.

Mistake #3: logging http.ErrServerClosed as a real error.
On a normal shutdown, ListenAndServe() will often finish with http.ErrServerClosed. If you print that as “ERROR”, your logs will look like the server is constantly crashing. Filter this error via errors.Is, and log only unexpected errors.

Mistake #4: trying to call shutdown “after ListenAndServe” without running anything in parallel.
If ListenAndServe() sits in main without a goroutine, the code below it will never run while the server is alive. That’s why waiting for the signal and initiating shutdown must be arranged so that main can do it: either run the server in a goroutine, or run shutdown logic in a goroutine. Otherwise you’ll get a perfectly “eternal” server that can’t be stopped gracefully — almost like some legacy projects.

Mistake #5: not understanding why Done() is read-only.
Sometimes you want to ask “can I close this channel myself?”. No, you can’t, and that’s correct: Done() is a signal from the context owner to dependent operations. The signal receiver must not be able to cancel someone else’s work. That’s the point of context as a cancellation tree.

1
Task
Go SELF, level 63, lesson 0
Locked
Panic Button
Panic Button
1
Task
Go SELF, level 63, lesson 0
Locked
Uptime
Uptime
1
Task
Go SELF, level 63, lesson 0
Locked
Server heartbeat
Server heartbeat
1
Task
Go SELF, level 63, lesson 0
Locked
Signal in the background
Signal in the background
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION