Go Context Timeouts and Request Cancellation
A Go HTTP handler can outlive the request that started it unless the work inside the handler pays attention to cancellation. That matters when a client disconnects, an upstream request takes too long, or a database query is no longer useful.
Go solves this with context.Context. Every incoming *http.Request already has a context, and that context is canceled when the client connection closes, the request is canceled by HTTP/2, or the handler returns. You can also derive a shorter deadline for work that should not consume the entire request lifetime.
This article shows a practical pattern for request timeouts, explains why it works, and highlights the mistakes that commonly make cancellation ineffective.
The core pattern
The most important rule is simple: derive your timeout from the request context, not from context.Background().
ctx, cancel := context.WithTimeout(r.Context(), 250*time.Millisecond)
defer cancel()This creates a child context that ends when either condition happens first:
- the original request is canceled; or
- the 250 millisecond deadline expires.
Calling cancel() with defer releases resources associated with the timer as soon as the handler finishes. Even when the timeout never fires, canceling explicitly is the correct cleanup pattern.
A complete HTTP example
The following program exposes /work. A query parameter controls how long simulated work takes. The handler allows at most 250 milliseconds for that work.
The example uses only the Go standard library.
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"time"
)
const requestBudget = 250 * time.Millisecond
func workHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), requestBudget)
defer cancel()
delay := 100 * time.Millisecond
if raw := r.URL.Query().Get("delay"); raw != "" {
parsed, err := time.ParseDuration(raw)
if err != nil || parsed < 0 {
http.Error(w, "invalid delay", http.StatusBadRequest)
return
}
delay = parsed
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-timer.C:
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, "{\"status\":\"ok\",\"delay\":%q}\n", delay.String())
case <-ctx.Done():
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
}
// If the client disconnected, the parent request context is canceled.
// There is usually no useful response left to write in that case.
return
}
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/work", workHandler)
server := &http.Server{
Addr: ":18080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("listening on %s", server.Addr)
log.Fatal(server.ListenAndServe())
}Run it with:
go run main.goA request that finishes within the budget succeeds:
curl -i 'http://127.0.0.1:18080/work?delay=100ms'Relevant response:
HTTP/1.1 200 OK
Content-Type: application/json
{"status":"ok","delay":"100ms"}A slower request reaches the application deadline first:
curl -i 'http://127.0.0.1:18080/work?delay=500ms'Relevant response:
HTTP/1.1 504 Gateway Timeout
request timed outWhy select makes cancellation effective
Creating a context does not automatically stop arbitrary work. The code doing the work must observe the context.
In the example, the handler waits on two channels:
select {
case <-timer.C:
// work completed
case <-ctx.Done():
// cancellation or timeout happened first
}ctx.Done() is closed when the context ends. That gives concurrent code a standard signal it can react to immediately.
This pattern is especially useful for your own goroutines, loops, queues, and channel operations. For libraries such as database/sql and net/http, prefer APIs that accept a context directly because those libraries already know how to stop their own work.
Propagate the context to database calls
A timeout at the handler level is only useful if downstream operations receive the same context.
With database/sql, use methods such as QueryContext, QueryRowContext, and ExecContext:
func loadUser(ctx context.Context, db *sql.DB, id int64) (string, error) {
var name string
err := db.QueryRowContext(
ctx,
`SELECT name FROM users WHERE id = ?`,
id,
).Scan(&name)
return name, err
}Then pass the request-derived context:
name, err := loadUser(ctx, db, userID)Why this matters: if the request deadline expires, the database operation can be canceled instead of continuing to consume a connection and server resources for a result nobody needs anymore.
Cancellation support ultimately depends on the database driver and server, so verify the behavior of the driver you use in production.
Propagate the context to outgoing HTTP requests
The same principle applies when your service calls another service.
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
"https://api.example.com/data",
nil,
)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)When ctx is canceled, the HTTP client can abort the request instead of waiting indefinitely.
For production systems, also configure an http.Client timeout or transport-level timeouts. Context deadlines express the budget for a specific operation, while client and transport timeouts provide additional network safety boundaries.
Distinguish a deadline from client cancellation
Both events make ctx.Done() ready, but they mean different things.
Use ctx.Err() to tell them apart:
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
// Your deadline expired.
case errors.Is(ctx.Err(), context.Canceled):
// The parent context was canceled, often because the client went away.
}A server-generated deadline can reasonably become an HTTP 504 Gateway Timeout when the handler is acting as a gateway to slower work.
Client cancellation is different. If the client has already disconnected, writing another response is usually pointless and may fail. Logging the cancellation at an appropriate level and returning is often the better behavior.
Server timeouts and context timeouts solve different problems
The http.Server fields in the complete example are not replacements for context.WithTimeout.
Server timeouts protect the HTTP connection
Settings such as these constrain network-level behavior:
server := &http.Server{
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}They help defend the server from connections that read or write too slowly and from idle connections that remain open too long.
Context timeouts protect application work
A request budget controls how long a particular unit of application work may continue. It can be passed to database queries, RPCs, HTTP calls, and your own goroutines.
In practice, reliable services often need both layers.
Avoid creating a new background context in handlers
This is a common mistake:
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()Inside a request handler, that breaks the cancellation chain. If the client disconnects, the new background-derived context does not know about it, so downstream work can continue unnecessarily.
Prefer:
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
defer cancel()Now the child deadline and the original request cancellation are both respected.
Do not store contexts in long-lived structs
A context represents the lifetime of an operation. Pass it explicitly through function calls, usually as the first parameter:
func fetchReport(ctx context.Context, id string) error {
// ...
return nil
}Avoid storing a request context in a service struct for later reuse. That makes lifetimes unclear and can accidentally reuse a canceled context for unrelated work.
A useful rule is: data and dependencies belong in structs; operation lifetime belongs in function parameters.
Make your own loops cancellation-aware
Long-running loops should periodically check the context.
func processItems(ctx context.Context, items []string) error {
for _, item := range items {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
process(item)
}
return nil
}The default branch keeps the check non-blocking when the request is still active.
For expensive per-item operations, passing ctx deeper is usually better than checking only once per loop iteration.
Common pitfalls
Forgetting to call cancel
Always call the cancellation function returned by context.WithCancel, context.WithTimeout, or context.WithDeadline when you are done with the derived context.
The usual pattern is:
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()Creating a timeout but ignoring it downstream
This does not help:
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
defer cancel()
result := slowFunction()If slowFunction has no way to observe ctx, it will continue running after the deadline.
Pass the context into the operation or design the operation so it can select on ctx.Done().
Using one fixed timeout for every operation
Different work deserves different budgets. A lightweight cache lookup and a large report export should not necessarily share the same deadline.
Choose timeouts from service-level expectations and measured latency, then keep enough budget for downstream operations and response writing.
Logging every cancellation as an error
Client cancellations are normal on busy systems. Users navigate away, mobile networks change, reverse proxies enforce their own timeouts, and callers abandon speculative requests.
Treat cancellation as an operational signal, but avoid turning every canceled request into a high-severity error unless it indicates a real failure in your application.
A practical timeout strategy
For a backend request that depends on several services, think in terms of a total budget.
For example, if your endpoint should finish in 800 milliseconds, you might reserve part of that time for application work and response handling, then give an upstream dependency a shorter child deadline.
requestCtx, cancelRequest := context.WithTimeout(r.Context(), 800*time.Millisecond)
defer cancelRequest()
upstreamCtx, cancelUpstream := context.WithTimeout(requestCtx, 500*time.Millisecond)
defer cancelUpstream()The upstream call cannot exceed 500 milliseconds, and it also ends immediately if the parent request is canceled or reaches its 800 millisecond limit.
This nested-budget approach prevents one dependency from consuming the entire request lifetime.
Conclusion
context.Context is Go’s standard mechanism for carrying cancellation and deadlines across API boundaries.
For HTTP handlers, the reliable pattern is to start with r.Context(), derive shorter deadlines only when needed, propagate that context to every cancellable downstream operation, and make custom concurrent work listen to ctx.Done().
The result is not just faster timeout handling. It also reduces wasted database work, unnecessary network calls, leaked goroutines, and resource pressure when requests are no longer useful.