http.Get has no deadline. Neither does http.DefaultClient. If the other side accepts the connection and never writes a byte, your goroutine waits as long as the process lives. context.Context is how Go code puts a limit on that wait, and the same mechanism, one Done() channel, also stops work nobody needs any more, shuts a server down cleanly and carries request data.
Everything below ran on Go 1.27.1 on macOS. The code is on GitHub: huseyinbabal/go-context.
What each program shows
| Program | Context API | Output |
|---|---|---|
hang | none | waits until Ctrl+C |
timeout | WithTimeout(2s) | context deadline exceeded; server: client gone after 1.999s |
mirrors | WithCancel | result from mirror 2; mirrors 1 and 3 stopped after 303 ms |
loop | WithTimeout(1s) + select on Done() | resized 5 of 20 images, timed out: true |
cause | WithCancelCause | ctx.Err(): context canceled, Cause(ctx): mirror 2 answered first |
shutdown | signal.NotifyContext + Server.Shutdown | the 3 s report still gets sent after Ctrl+C |
reqid | WithValue | [EQRSEAQV] searching for "go context" |
vetcheck | discarded cancel | go vet reports a context leak |
The server that takes its time
// /search?mirror=N&ms=D answers after D milliseconds, unless the client gives up first.
func main() {
http.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) {
mirror := r.URL.Query().Get("mirror")
ms, _ := strconv.Atoi(r.URL.Query().Get("ms"))
start := time.Now()
select {
case <-time.After(time.Duration(ms) * time.Millisecond):
log.Printf("mirror %s: answered after %v", mirror, time.Since(start).Round(time.Millisecond))
fmt.Fprintf(w, "result from mirror %s", mirror)
case <-r.Context().Done():
log.Printf("mirror %s: client gone after %v, stopped", mirror, time.Since(start).Round(time.Millisecond))
}
})
log.Fatal(http.ListenAndServe("localhost:9091", nil))
}server/main.go
The select is the server half of the story: every request handled by net/http carries a context, and the server cancels it when the client’s connection goes away. A handler that waits on r.Context().Done() notices and stops; one that only waits on its own work keeps going for nobody.
1. A deadline on one request
hang asks for ms=600000, ten minutes, with a plain http.Get. Nothing comes back. After Ctrl+C the server logs client gone after 4.716s, stopped: the only thing that ended the wait was killing the client.
The fix is three lines:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", "http://localhost:9091/search?mirror=1&ms=600000", nil)
resp, err := http.DefaultClient.Do(req)timeout/main.go
error: Get "http://localhost:9091/search?mirror=1&ms=600000": context deadline exceededoutput
mirror 1: client gone after 1.999s, stoppedserver
When the deadline passes, the transport closes the connection, Do returns, and the server’s r.Context() fires on the other end. Both sides stop within the same two seconds.
http.Client{Timeout: 2 * time.Second} would also cap this request. The difference is scope: the client timeout covers one HTTP call, while a context can cover a whole operation (several calls, a database query, a loop) and can be cancelled early by your own code, not only by a clock.
2. Start three, keep the fastest
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
results := make(chan string, 3)
for mirror, ms := range map[int]int{1: 1500, 2: 300, 3: 2500} {
go func() {
if r, err := search(ctx, mirror, ms); err == nil {
results <- r
}
}()
}
fmt.Println(<-results)
cancel() // the other two requests stop heremirrors/main.go
result from mirror 2output
mirror 2: answered after 301ms
mirror 1: client gone after 303ms, stopped
mirror 3: client gone after 303ms, stoppedserver
Without cancel(), mirrors 1 and 3 would have kept working for 1.5 s and 2.5 s on answers nobody reads. The channel is buffered with room for three so the two losing goroutines can never block on a send that nobody receives.
Contexts made from ctx inherit this: cancelling a parent cancels every context derived from it, so one cancel() near the top of a request stops every goroutine and call underneath.
3. Your own loops have to look
A context only stops code that checks it. For work that never touches the network, check Done() yourself:
func resize(ctx context.Context, images int) (int, error) {
for i := range images {
select {
case <-ctx.Done():
return i, ctx.Err()
default:
}
time.Sleep(200 * time.Millisecond)
}
return images, nil
}loop/main.go
With a 1 s timeout and 200 ms per image:
resized 5 of 20 images
timed out: true
canceled: falseoutput
The last two lines are errors.Is(err, context.DeadlineExceeded) and errors.Is(err, context.Canceled). They tell a timeout apart from someone calling cancel(), which usually matters for what you log or retry.
4. Why was it cancelled?
ctx.Err() only ever says context canceled or context deadline exceeded. Since Go 1.20, WithCancelCause lets the code that cancels say why:
ctx, cancel := context.WithCancelCause(context.Background())
go func() {
time.Sleep(300 * time.Millisecond)
cancel(errors.New("mirror 2 answered first"))
}()cause/main.go
request error: Get "http://localhost:9091/search?mirror=1&ms=1500": mirror 2 answered first
ctx.Err(): context canceled
Cause(ctx): mirror 2 answered firstoutput
In this run the HTTP client’s error carried the cause too. Go 1.21 added the same for deadlines with WithTimeoutCause and WithDeadlineCause.
5. Ctrl+C without dropping requests
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
go srv.ListenAndServe()
<-ctx.Done() // Ctrl+C closes this
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)shutdown/main.go
A /report request takes three seconds; Ctrl+C one second into it:
11:29:41 report: started
^C11:29:42 shutting down, waiting for open requests
11:29:44 report: sent
11:29:44 byeserver
Shutdown stops accepting new connections and waits for open requests until shutdownCtx runs out. The second context matters: if a handler hangs, the 10 s timeout keeps the process from waiting forever on exit. Without NotifyContext, Ctrl+C would kill the process and the client would get a reset connection instead of its report.
6. A request ID through every call
type ctxKey struct{} // an unexported key type: no other package can collide with it
func withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := rand.Text()[:8]
ctx := context.WithValue(r.Context(), ctxKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func requestID(ctx context.Context) string {
id, _ := ctx.Value(ctxKey{}).(string)
return id
}reqid/main.go
[EQRSEAQV] searching for "go context"
[Y77YZRGR] searching for "keycloak"server
The package docs draw the line: values are for “request-scoped data that transits processes and APIs”. A request ID, a trace span or the authenticated user fit. A database handle, a config struct or an argument a function needs to do its job do not: ctx.Value returns any, so the compiler cannot check it, and a dependency hidden in a context does not show up in the function signature.
Two habits, and what vet catches
ctx context.Contextis the first parameter, as infunc search(ctx context.Context, mirror, ms int). The docs also say not to store a context in a struct.- Every
WithTimeout,WithDeadlineandWithCancelis followed bydefer cancel(). Throwing the function away leaves the context and its timer in memory until the deadline.
ctx, _ := context.WithTimeout(context.Background(), 2*time.Second)vetcheck/main.go
vetcheck/main.go:10:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leakoutput
Beyond the programs above
| API | Since | Use it when |
|---|---|---|
db.QueryContext, exec.CommandContext | long-standing | the same ctx should cancel a SQL query or kill a child process |
context.AfterFunc(ctx, f) | Go 1.21 | run cleanup when a context is done, without a goroutine waiting on Done() |
context.WithoutCancel(ctx) | Go 1.21 | keep the values (request ID) but outlive the request, e.g. an audit write after the response |
context.WithTimeoutCause / WithDeadlineCause | Go 1.21 | a deadline that says which limit was hit |
t.Context() in tests | Go 1.24 | a context that is cancelled when the test ends |
Run it yourself
git clone https://github.com/huseyinbabal/go-context && cd go-context
go run ./server &
go run ./timeout
go run ./mirrors
go run ./loop
go run ./cause