Chapter 17 gave our handlers real JSON encoding. This chapter puts context.Context in the spotlight: the mechanism Go uses to carry cancellation signals, deadlines, and request-scoped values from a handler down to wherever the work actually happens.
Every http.Request already carries a context via r.Context(). Go creates it per request and cancels it automatically when the client disconnects. When you add a deadline, it cancels when the clock runs out. Either way, anything watching ctx.Done() stops.

Three new routes demonstrate the three jobs a context does:
GET /slow — a 5-second handler that stops early when the client disconnects, using only r.Context() with no timeout setGET /slow-store — calls findTask with a 100ms deadline; the simulated 500ms query always exceeds it and returns 504GET /trace — reads a request ID from a header, stores it on the context, and reads it back downstreamCheck out the finish branch to follow along:
bashgit checkout 18-context-finish
The context.Context interface lives in Go's standard library. It exposes four methods, but two do most of the heavy lifting: ctx.Done() returns a channel that closes when the context is cancelled or expires, and ctx.Err() reports why it closed.
The net/http package creates a fresh context for each request and attaches it to r.Context(). Your job is to receive it and pass it down.
slowHandler makes the automatic cancellation visible:
go// main.gofunc slowHandler(w http.ResponseWriter, r *http.Request) {ctx := r.Context()select {case <-time.After(5 * time.Second):writeJSON(w, http.StatusOK, map[string]string{"status": "done"})case <-ctx.Done():log.Printf("slow: request cancelled: %v", ctx.Err())}}
The select blocks until one of two channels delivers: the 5-second timer, or ctx.Done(). This is the same channel-receive pattern from Chapter 13. When a client disconnects before 5 seconds, Go closes ctx.Done() and the second branch runs.
No timeout was set anywhere in the handler. The client simply left, Go cancelled r.Context(), and the handler stopped.
Not every stop signal comes from a disconnecting client. Sometimes you want to enforce a budget yourself: "this store call should never take more than 2 seconds, no matter what."
Calling context.WithTimeout(parent, d) returns two values: a derived context that expires after duration d, and a cancel function. Always call defer cancel() right after this line. If you skip it, the internal timer keeps running until the deadline fires. This leaves a small memory allocation behind for every request, which quickly accumulates on a busy server.
The simulated store delay used in this chapter is defined at the top of main.go:
go// main.goconst storeQueryDelay = 500 * time.Millisecond
getTask now sets a 2-second deadline and passes the context into findTask:
go// main.gofunc getTask(w http.ResponseWriter, r *http.Request) {id, err := strconv.Atoi(r.PathValue("id"))if err != nil {http.Error(w, "invalid task id", http.StatusBadRequest)return}ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)defer cancel()task, err := findTask(ctx, id)if err != nil {log.Printf("getTask: %v", err)http.Error(w, err.Error(), http.StatusGatewayTimeout)return}writeJSON(w, http.StatusOK, task)}
Since 2 seconds is longer than 500ms, findTask completes successfully and the handler returns the task. Now look at slowStoreHandler. It sets a 100ms deadline on the exact same call:
go// main.gofunc slowStoreHandler(w http.ResponseWriter, r *http.Request) {ctx, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)defer cancel()task, err := findTask(ctx, 1)if err != nil {log.Printf("slow-store: %v", err)http.Error(w, err.Error(), http.StatusGatewayTimeout)return}writeJSON(w, http.StatusOK, task)}
The 100ms deadline expires before the 500ms simulation finishes. Because of this, findTask returns ctx.Err(), which holds the error context.DeadlineExceeded. The handler logs this error and sends a 504 Gateway Timeout response.
This is the same function doing the same work, but it has two different outcomes because the caller chose different deadlines. Try both routes while the server is running:
bashcurl --max-time 1 http://localhost:8080/slowcurl -i http://localhost:8080/slow-store

In the /slow case, ctx.Err() is context.Canceled because the client left. In /slow-store, it is context.DeadlineExceeded because the timer fired.
There is also a manual sibling to the timeout function called context.WithCancel(parent). It returns a context and a cancel function, but it does not use a clock. Instead, you call cancel() yourself whenever you decide the work should stop.
Go has a strict convention for contexts. context.Context is usually the first argument to any function that does work. Never store it in a struct, and never read it from a global variable. Always pass it explicitly down the call chain.
findTask demonstrates the signature:
go// main.gofunc findTask(ctx context.Context, id int) (Task, error) {select {case <-time.After(storeQueryDelay):return Task{ID: id, Title: "Sample task"}, nilcase <-ctx.Done():return Task{}, ctx.Err()}}
This is a stand-in for the real database call you will write in Chapter 27. There, pool.QueryRow(ctx, sql, args...) takes a context as its first argument. The repository methods in Chapter 29 use the exact same (ctx context.Context, ...) signature. The payoff for doing this is concrete. A cancelled or expired context stops the pgx driver's in-flight query on the database side, rather than just stopping the handler waiting for the result.
A context can also carry data tied to a specific request. This might be a request ID, an authenticated user, or a trace span. You store a value using context.WithValue(parent, key, val) and retrieve it using ctx.Value(key).
Two rules matter here.
First, do not use context.Value to pass ordinary function arguments. If a function needs a database pool or a logger, it should receive it explicitly. Hiding parameters inside the context makes your code harder to follow. Because ctx.Value(someKey) can hold absolutely anything, the Go compiler cannot type-check it or help you catch mistakes.
Second, use a private typed key to prevent collisions between packages:
go// main.gotype ctxKey stringconst requestIDKey ctxKey = "requestID"
ctxKey is an unexported type defined in this specific package. No other package can produce an identical key, even if they also use the string "requestID". This ensures keys from different packages never collide. A bare string key lacks this protection.
requestIDFrom is a small typed accessor:
go// main.gofunc requestIDFrom(ctx context.Context) string {if id, ok := ctx.Value(requestIDKey).(string); ok {return id}return "unknown"}
The type assertion .(string) returns two values: the value itself, and a boolean ok. If the key is absent or holds the wrong type, ok becomes false and the function returns a safe default string.
traceHandler puts all of this together. It reads the header, stores it on the context, and reads it back through the accessor:
go// main.gofunc traceHandler(w http.ResponseWriter, r *http.Request) {reqID := r.Header.Get("X-Request-ID")if reqID == "" {reqID = "req-unknown"}ctx := context.WithValue(r.Context(), requestIDKey, reqID)log.Printf("trace: handling request %s", requestIDFrom(ctx))writeJSON(w, http.StatusOK, map[string]string{"requestID": requestIDFrom(ctx)})}
In Chapter 32, we will write middleware that sets the request ID on the incoming r.Context() before any handler runs. This means every function below it can read the same ID through requestIDFrom without needing it passed as an extra argument.
Passing context.Background() instead of r.Context()
context.Background() is an empty root context with no cancellation and no deadline. If you pass it into a store function, a disconnecting client can no longer stop its own work. The store will just keep running after the response is gone. Always use r.Context() for web requests.
context.TODO() is the other common placeholder. It signals that you know you need a context, but you have not wired it up yet. This is fine during development, but you should not leave it in production handlers.
Skipping defer cancel()
Every context.WithTimeout or context.WithCancel call returns a cancel function. Failing to call it leaks the internal timer until the deadline fires. If you put defer cancel() on the line immediately after the context is created, you will not forget it.
Reaching for context.Value to pass ordinary parameters
Once you know the context can carry values, it is tempting to thread a logger, a config struct, or a database pool through it. Doing this hides dependencies from your function signatures and from the compiler. Keep context.Value strictly for request-scoped metadata like a request ID, an authenticated user, or a trace context. Pass everything else as normal arguments.
Section 3 is now complete: you have built an HTTP server with routing, JSON encoding, and context-aware handlers. Chapter 19 opens Section 4, where we design the full Task API and set up an idiomatic Go project structure to hold it.