1. Request context and its cancellation
If you wrote a server “by intuition,” a handler looks like an honest worker: it receives a request, does the job, and returns a response. But the real world is harsh: the client can close the tab, a mobile network can drop, a load balancer can cut the connection, and you are still computing, hitting storage, and formatting JSON.
As a result, you waste CPU and memory on a request that no longer exists, and then you wonder why the server “seems to serve few users,” but still breathes heavily.
From Go’s perspective, request cancellation is not magic and not a “side effect.” It is a signal delivered through the request’s context.Context. In Go, the server side agreed long ago: “if the operation is no longer needed, communicate that via the context.” This is one of the key elements of reliability: without contexts and deadlines, a server easily starts accumulating a “backlog” of work and runs into resource limits.
What the lifecycle looks like
For visualization, it is handy to keep the following lifecycle diagram in mind:
flowchart TD
A[Klient otpravil zapros] --> B[Handler poluchil r *http.Request]
B --> C["Beryom ctx := r.Context()"]
C --> D[Peredayom ctx v app/storage]
D --> E{ctx otmenyon?}
E -- net --> F[Delayem rabotu i otvechayem]
E -- da --> G[Preryvayemsya i vykhodim]
What r.Context() is and when it gets canceled
r.Context() is the context of a specific HTTP request. You can think of it as “the request lifecycle represented as a value.” Inside net/http, the context is attached to http.Request (historically done this way to avoid breaking the API when context appeared in Go).
A context is useful because it carries two kinds of signals.
First, the cancellation signal: ctx.Done() returns a channel that is closed upon cancellation. Second, the reason for cancellation: ctx.Err() returns an error explaining what happened (usually context.Canceled or context.DeadlineExceeded).
In practice, r.Context() is canceled in situations like: the client disconnected, the server is starting shutdown, the request timeout expired (at the server level or at the level of an “internal” deadline you set yourself). You do not need to guess what exactly happened—you just need to react correctly: stop work quickly and avoid doing extra work.
Mini demo: just read ctx.Err().
package main
import (
"fmt"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Println("ctx err:", r.Context().Err()) // ctx err: <nil>
_, _ = w.Write([]byte("ok\n"))
}
2. Basic rules for handlers
Do not replace r.Context() with context.Background()
When you are just starting out, it seems logical: “I need a context? then I’ll take context.Background().” This is almost always a mistake in HTTP code.
context.Background() is the root of the context tree; it never gets canceled. That is, if you replace r.Context() with Background(), you throw away the main signal: “the client is gone / the request is no longer needed.”
The correct start inside a handler is almost always: take ctx := r.Context() and then use it as a “pass” to all layers: the app layer, storage, external calls. Yes, even if today storage is just “an in-memory slice” and there are no locks/network calls. We are building a habit that will later save a lot of time.
Minimal pattern skeleton:
package main
import (
"net/http"
)
func listTasksHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := ctx.Err(); err != nil {
return
}
_ = ctx // we'll pass it to the app layer next
_, _ = w.Write([]byte("todo\n"))
}
Pay attention to if err := ctx.Err(); err != nil { return }. At the beginning of a request this is almost always nil, but such a “guard check” is useful when the handler is complex and can be invoked under interesting conditions (for example, the client drops the connection almost immediately).
Early return on ctx.Err()
In previous lectures we actively used guard clauses: “if there is an error—return.” With contexts it is the same: “if the context is canceled—return.” The only important thing to remember is that cancellation is not a “server error,” but a signal that continuing is pointless.
It is convenient to introduce a small helper so the code reads like plain text:
package main
import "context"
func isCanceled(ctx context.Context) bool {
return ctx.Err() != nil
}
And use it in handlers:
package main
import (
"net/http"
)
func createTaskHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if isCanceled(ctx) {
return
}
// read JSON, validate, save...
_, _ = w.Write([]byte("created\n"))
}
This approach is good because you do not pretend “I will definitely respond to the client” if the client is already gone. Beginners sometimes try to “return a JSON error anyway”—but if the connection is already closed, you are just spending time on nicely wrapping a response that nobody will see.
Pass ctx through layers
The biggest practical value of r.Context() appears when you carry it through layers. It looks boring: one more parameter gets added to signatures. But in return you get control: any link in the chain can stop in time.
Suppose we have a training application “task tracker” and a service:
package app
import "context"
type TaskService struct {
// storage and other dependencies
}
func (s *TaskService) List(ctx context.Context) ([]Task, error) {
_ = ctx
return nil, nil
}
Note: ctx is a method parameter, not a struct field. This is important: a context must not “live” longer than the operation it is meant for. Storing a context inside a struct is usually a bad idea: lifecycles get mixed, and the calling code loses control over the deadline and cancellation.
At the handler level it looks like this:
package httpapi
import (
"context"
"net/http"
)
type TaskLister interface {
List(ctx context.Context) ([]any, error) // simplified for the example
}
type Handlers struct {
Service TaskLister
}
func (h *Handlers) list(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_ = ctx
_, _ = w.Write([]byte("ok\n"))
}
In real code, of course, you will have proper types ([]Task instead of []any), but the idea is the same: the context goes “down” as the first argument of the operation.
3. Long work and cancellation checks
Sometimes you do work that takes time: filtering a large list, import, report generation, “slow” disk writes, network calls (even if here we simulate it with Sleep). And even if you do not use select and channels, you can still be “polite” about request cancellation: do the work in chunks and between chunks check ctx.Err().
Example: simulate a heavy operation in 5 steps.
package app
import (
"context"
"time"
)
func slowCompute(ctx context.Context) error {
for i := 0; i < 5; i++ {
if err := ctx.Err(); err != nil {
return err
}
time.Sleep(200 * time.Millisecond)
}
return nil
}
There is no magic here. But the effect is huge: if the client left on the first step, you do not “keep computing for another second just because you started.” You stop almost immediately and free resources.
And in the handler:
package httpapi
import (
"net/http"
)
func slowHandler(w http.ResponseWriter, r *http.Request) {
if err := slowCompute(r.Context()); err != nil {
return
}
_, _ = w.Write([]byte("done\n"))
}
4. Internal timeouts via context.WithTimeout()
Sometimes request cancellation is an “external event” (the client left). But sometimes you want to limit the time of a specific operation inside the request. For example: “reading tasks from storage must not take longer than 300 ms; otherwise it is better to return an error than to keep the connection open.”
It is important to do it exactly like this: use r.Context() as the parent. Then you preserve the relationship between lifecycles: “if the request died, child operations die too.”
package httpapi
import (
"context"
"net/http"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
opCtx, cancel := context.WithTimeout(r.Context(), 300*time.Millisecond)
defer cancel()
_ = opCtx
_, _ = w.Write([]byte("ok\n"))
}
This pattern is valuable for two reasons at once. First, defer cancel() frees timer resources even if the operation finishes earlier. Second, you can distinguish a regular error from a timeout: errors.Is(err, context.DeadlineExceeded).
5. Cancellation and the HTTP response
What to do with context.Canceled and context.DeadlineExceeded
It is tempting to have a “table: which error → which HTTP status.” And yes, it is useful, but it is important not to turn it into bureaucracy. In the context of request cancellation, there is a practical minimum.
In the context.Canceled situation, most often the client is already gone, and you physically cannot respond correctly. Therefore the most reasonable behavior is: stop work and exit silently (maybe log at debug level, but not as a “server error”).
In the context.DeadlineExceeded situation, you need to understand whose deadline it is. If it is the request deadline (for example, server WriteTimeout or a client timeout), the picture is similar: the client may no longer be waiting. If it is your internal operation timeout (via WithTimeout), then you may still have time to send a neat error. People often use 504 (Gateway Timeout) or 503 (Service Unavailable), but in a training project the principle matters more: this is not “500 internal”, because the system behaved normally and simply did not meet the limit.
A small cheat sheet as a guideline:
| ctx.Err() | What it usually means | What to do in the handler |
|---|---|---|
|
the request is alive, keep working | continue |
|
the client is gone / the request was canceled | stop work and exit |
|
the request or operation deadline expired | stop work; with an internal timeout you can return a neat error |
A nuance with ResponseWriter
Beginners often have a very human desire: “but I must return JSON!” The problem is that if the context is canceled, writing to w may end with an error (for example, broken pipe). Technically you can ignore the write error, but it is better to not do heavy work for a response that will not be delivered.
Therefore, a practical technique looks like this: before generating a large response (sorting, JSON marshaling, tables), check ctx.Err() one more time. This is especially useful if the response is large or takes a long time to build.
package httpapi
import (
"net/http"
)
func safeWrite(w http.ResponseWriter, r *http.Request, b []byte) {
if err := r.Context().Err(); err != nil {
return
}
_, _ = w.Write(b)
}
In “real” code you probably have writeJSON, and you can add such a check there. Not as a “silver bullet,” but as a last sanity filter: “if the request is dead, do not draw a postcard.”
6. Example
To tie everything into one piece, imagine we have a “task export” handler that runs a bit longer than usual. We set an internal timeout for the operation and do “chunked” work to respect cancellation.
package httpapi
import (
"context"
"net/http"
"time"
)
func exportHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
defer cancel()
if err := slowCompute(ctx); err != nil {
return
}
_, _ = w.Write([]byte("exported\n"))
}
Yes, there is no JSON envelope and no pretty errors yet—and that is fine for today’s lecture focus. The main goal is to build the habit that any handler works under the contract “I do work while the context is alive; if it dies, I leave without drama.”
7. Typical mistakes when handling request cancellation
Mistake #1: replacing r.Context() with context.Background().
This is often done “so it just works,” especially when somewhere below you need to pass ctx, but “for some reason it is missing.” The cost of this replacement is high: you throw away the most important capability—stopping when the request is no longer needed. As a result, the server may continue heavy work after the client leaves and gradually turn into a factory of useless computations.
Mistake #2: checking cancellation once at the beginning and then forgetting.
if ctx.Err() != nil { return } in the first line of a handler is useful, but it will not save you if a long processing phase follows. The context can be canceled at any moment. Therefore “long” logic needs chunking: a loop, stages, steps—and checking ctx.Err() between them. Then cancellation works like an emergency stop, not like a decorative lamp.
Mistake #3: trying to “always return a response” on context.Canceled.
When the client closed the connection, you are no longer obliged (and often not able) to “say goodbye nicely.” Trying at that moment to marshal JSON, write headers, and log it as a “server error” usually only makes things worse: you spend resources and create noise in the logs. The correct approach is to simply stop work and exit.
Mistake #4: storing context.Context in a service or storage struct.
This looks like a convenient “parameter optimization”: store it once in a struct and then you do not pass it further. But this mixes lifecycles and removes the caller’s control over deadlines. A context must be a parameter of a specific operation, not a hidden field “somewhere inside an object.” That is why in Go people usually write Do(ctx, ...), not “store it and forget it.”
Mistake #5: setting a timeout via WithTimeout(), but forgetting cancel().
Even if the operation finishes successfully, the timer and related infrastructure must be released. If you do not do defer cancel(), you can accumulate resources under high load, and it will look like “some weird leak” that you really do not want to chase on a Friday evening.
GO TO FULL VERSION