AlgoMaster Logo

Request Context

High Priority11 min readUpdated June 6, 2026

Every *http.Request carries a context.Context that the server cancels when the client disconnects or the response is finished. This lesson covers how to pull that context out with r.Context(), propagate it into databases and downstream HTTP calls, attach per-request values like a request ID, and avoid the trap of starting goroutines that outlive the request.

Where the Request Context Comes From

The net/http server creates a fresh context.Context for every incoming request and attaches it to the *http.Request. You read it back with r.Context(). The server cancels that context in two situations: the client closes the connection before you finish writing the response, or your handler returns and the response is fully sent. Either way, anything still running under that context sees a context.Canceled error.

The mechanics of context.Context itself, including Done(), Err(), and the cancellation semantics, work the same way they do anywhere else. The HTTP server is what creates and cancels it for you.

Inside the handler, ctx.Err() is nil because the request is still in flight. As soon as the handler returns, the server cancels that context and any later read of ctx.Err() would return context.Canceled.

Passing the Context Into Downstream Calls

The whole point of having a context on the request is to thread it into anything the handler calls so cancellation flows downstream. If the client gives up and disconnects mid-request, your database query and your outbound HTTP call should give up too, instead of burning CPU on a result nobody will read.

Three places this matters in practice: database queries, outbound HTTP requests, and any goroutine the handler spawns.

For database calls, use the Context variants. db.QueryContext, db.ExecContext, db.QueryRowContext. They all take a context.Context as the first argument and abort the query when the context is canceled.

The driver checks the context periodically and returns early with an error when it's canceled. Without QueryContext, the query would run to completion even if the client has long since disconnected.

For outbound HTTP calls, build the request with http.NewRequestWithContext (or attach a context with req.WithContext(ctx) if you already have a request).

If the client closes the connection while you're waiting on recs.internal, the Do call returns with context.Canceled and your handler bails out instead of holding open an upstream connection for nothing.

Observing Cancellation: A Slow Handler Pattern

For long-running handlers that loop or wait on something, you can poll ctx.Done() and bail out when the client gives up. The pattern is the standard select over a Done() channel, just with r.Context() as the source.

This example uses httptest.NewServer so you can run it end-to-end. The handler simulates a long-running operation. The client sends a request, waits a moment, then cancels.

The server sees context canceled even though the client canceled with a WithTimeout. The HTTP server doesn't propagate the client's specific reason; it notices the connection died and cancels the server-side context with context.Canceled. The handler stops doing work as soon as <-ctx.Done() fires.

There's no standard 499 Client Closed Request status in net/http, but you can return immediately when ctx.Err() != nil. Writing to w after that is harmless (the framework swallows the write), but pointless.

Adding Per-Request Values with WithValue

Sometimes middleware needs to stamp a value on the request that handlers further down read back. A request ID is the canonical example: middleware generates one, every log line in the request includes it, and the response carries it back to the client. The typed-key rules for context.WithValue apply here just as they do for any other context use.

To attach a value to the request context, derive a new context and call r.WithContext(newCtx), which returns a new *http.Request with the new context. You pass that to the next handler.

When the request hits /products/42, the middleware generates the ID, stamps it on the context, and passes a new request down. The handler calls logRequest with r.Context() and the helper reads the ID back. The handler itself doesn't have to know the ID exists; only the logger and the middleware do.

The unexported ctxKey type matters. If you used "requestID" as a string key, any other package that also uses "requestID" would collide silently. The unexported type makes the key globally unique by construction. The same rule applies harder in HTTP code because middleware from third-party packages routinely lives in the same request.

Deadlines for Slow Upstream Calls

If your handler calls a service that might be slow, you usually don't want the upstream call to inherit only the client's open-ended patience. Derive a timeout from r.Context() and pass that derived context downstream. The result is "whichever happens first, client disconnect or my 2-second budget, cancels the upstream call."

The handler doesn't have to write any cancellation logic itself. http.DefaultClient.Do returns the context deadline exceeded error after exactly 2 seconds. Critically, the derived context is still a child of r.Context(), so if the client disconnects after 500ms, the upstream call dies then, not at 2 seconds.

Picking a timeout is its own design problem. Set it lower than the parent's deadline (if any), high enough to cover normal latency, and remember that this is per-call, not per-request. If your handler makes three upstream calls, three 2-second budgets can stack up.

Goroutines That Outlive the Request

Here's the trap. You start a goroutine inside a handler and pass r.Context() to it. The handler returns, the server cancels the request context, and your goroutine sees context.Canceled and exits immediately. If the goroutine was supposed to do real work after the response was sent, it never gets to do it.

The handler returns within a millisecond, the server cancels the request context, and the goroutine prints background: canceled, context canceled instead of sending the email. The bug is that you reused the request context for a job whose lifetime isn't tied to the request.

The fix is to start the background job with a fresh context, usually context.Background(), optionally with its own timeout. If you still want the request ID or other request-scoped values, copy them across explicitly.

Now the goroutine has its own 30-second budget, isn't tied to the request, and still carries the request ID for logging. The defer cancel() keeps the timer from leaking when the work finishes early.

The flow of context propagation through a single request looks like this:

The solid arrows are correct context flow. The dotted arrow is the wrong one: pass r.Context() into a goroutine that's meant to outlive the request and the goroutine dies as soon as the response is sent. The fix is the red node: derive a fresh context.

Putting It Together: Request ID Middleware and a DB-Backed Handler

A short E-Commerce flow that combines everything: middleware stamps a request ID, the handler reads it for logging, and a (mocked) DB call receives the request context for cancellation. The mock DB lets you run the example without a real database.

The middleware never touches the handler, the handler never touches the request ID directly, and the DB call uses a context that's a child of the request context plus a 1-second timeout. If the client disconnects at 50ms, both r.Context() and dbCtx are canceled, and the mock DB returns immediately. If the DB is genuinely slow, the 1-second timeout fires. If everything is healthy, the request completes in 200ms.

The request ID middleware is a good template. Real production code adds a few more pieces (sometimes it accepts an X-Request-ID header from upstream so trace IDs survive across services, sometimes it generates a UUID instead of a timestamp-based ID), but the shape is the same: derive a new context, stamp a value, hand a new request to the next handler.

Quiz

Request Context Quiz

10 quizzes