19.7 Context in HTTP Handlers and gRPC
Right, let’s talk about where you’ll most likely meet a context.Context: in the belly of an HTTP handler or a gRPC method. This isn’t an academic exercise; it’s the primary control panel your server code has for dealing with the messy reality of the web—clients that vanish, networks that flake, and requests that just take too darn long.
The moment an incoming HTTP request knocks on your server’s door, the framework (like net/http or something fancier) creates a context for it and passes it to your handler. This context is your lifeline. It’s pre-wired with two crucial features: a cancellation signal that fires the instant the client disconnects (saving you from talking to a void), and a deadline, which is the server’s polite but firm suggestion for how long you should spend on this whole affair.
The Handler’s Lifeline: request.Context()
In the standard net/http package, you don’t get the context passed as a separate argument. You have to pluck it from the http.Request object itself. This feels a bit clunky, I know. It’s a relic of a time before context was a standard thing. But it’s straightforward.
func YourHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // This is the key line.
// Now use ctx for any downstream calls.
result, err := database.QueryContext(ctx, "SELECT...")
if err != nil {
// Handle the error, which might be a context cancellation!
return
}
// ... process result and send response ...
}
Why is this so important? Imagine a user hits refresh or closes their browser tab halfway through your complex database query. Without checking the context, your server would blissfully continue processing, wasting precious resources on a result that will never be delivered. Using QueryContext (or any other context-aware method) means the database driver gets a heads-up the moment the client connection drops. It can then cancel its own operation, freeing up that database connection for a request that someone actually cares about.
gRPC: Context is Baked In
gRPC, being a modern protocol, learned from this. Context is a first-class citizen and is passed directly as the first argument to every RPC method. It’s beautifully consistent.
// A gRPC server method definition
func (s *myServer) GetUserProfile(ctx context.Context, req *pb.GetUserRequest) (*pb.UserProfile, error) {
// The ctx is right there, ready to use.
user, err := s.userStore.Get(ctx, req.UserId)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
return &pb.UserProfile{
Name: user.Name,
// ... other fields ...
}, nil
}
The principle is identical: use this ctx for any I/O-bound operation (database calls, caching, calls to other services) so the entire call graph can react appropriately if the client gives up.
The #1 Pitfall: Ignoring ctx in Goroutines
Here’s the mistake I see everyone make once. You’re in a handler, and you think, “This task is long-running, I’ll fire off a goroutine to handle it and return an HTTP 202 Accepted immediately.” So you write something dangerously like this:
func BadHandler(w http.ResponseWriter, r *http.Request) {
go func() {
// DO NOT DO THIS. This goroutine is now an untethered ghost.
time.Sleep(10 * time.Second) // Simulate work
fmt.Println("Work done!") // But the client is long gone!
}()
w.WriteHeader(http.StatusAccepted)
}
This is a fantastic way to leak resources and do work for no reason. The client’s context, r.Context(), is completely detached from your new goroutine. If the client cancels, your goroutine will keep running, oblivious.
The correct pattern is to propagate the context into the goroutine. However, you have to be smart about it. You can’t just use the original context directly, because when the handler returns, that context will be canceled, immediately killing your background goroutine. This is where context derivation shines.
func GoodHandler(w http.ResponseWriter, r *http.Request) {
// Create a new context detached from the original's cancellation.
// We keep the values, but we sever the cancellation link.
// This is a rare case where context.WithoutCancel is useful (or pre-1.21, you'd do a custom hack).
backgroundCtx := context.WithoutCancel(r.Context())
go func(ctx context.Context) {
// Now this goroutine uses a context that won't be cancelled
// by the client disconnecting. We've made a conscious choice.
time.Sleep(10 * time.Second)
fmt.Println("Work done! And it was intentional.")
}(backgroundCtx)
w.WriteHeader(http.StatusAccepted)
}
The key here is intent. The first example was a bug. This example is a conscious architectural decision: “I am aware the client may leave, but I need this work to continue anyway.” You might use this for logging, finalizing a transaction, or sending a webhook. Just be aware you’re now responsible for cleaning up that goroutine.
Best Practice: Annotate Your Contexts
When your handler calls a service, which calls a repository, which calls a cache, it can be hard to trace where a cancellation came from. Use context.WithValue to add simple, observable metadata for debugging. Don’t abuse it for passing essential function parameters, but do use it for tracing IDs, API endpoint names, or other request-scoped metadata.
func AnnotatedHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Add a key for the handler name to help with tracing
type handlerKey struct{} // Use an unexported type to avoid collisions
ctx = context.WithValue(ctx, handlerKey{}, "YourHandler")
// Now pass this annotated ctx down the chain
myService.ProcessStuff(ctx, ...)
}
The bottom line is this: in server code, the context is your best friend for writing robust, resource-conscious applications. It’s the thread that ties your entire operation together, allowing it to fail gracefully the moment it becomes pointless. Respect the context, propagate it faithfully, and your servers will be far more resilient.