Using Context in Go HTTP Servers and Clients

Use r.Context() in HTTP handlers to stop work when clients disconnect, add timeouts to outgoing requests with NewRequestWithContext, and pass values through middleware.

HTTP is where most Go programs meet contexts. On the server side, every request already comes with a context that is cancelled when the client goes away. On the client side, attaching a context to an outgoing request is how you put a time limit on it.

The Request Context in a Handler

r.Context() returns a context that the server cancels when the client disconnects, when the request finishes, or when the server shuts down. Pass it to everything the handler calls:

package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"
)

func slowReport(ctx context.Context) (string, error) {
	select {
	case <-time.After(100 * time.Millisecond):
		return "report ready", nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

func reportHandler(w http.ResponseWriter, r *http.Request) {
	res, err := slowReport(r.Context())
	if errors.Is(err, context.Canceled) {
		fmt.Println("client left, stopped work")
		return
	}
	if err != nil {
		http.Error(w, "timeout", http.StatusGatewayTimeout)
		return
	}
	fmt.Fprintln(w, res)
}

func main() {
	// normal request
	rec := httptest.NewRecorder()
	reportHandler(rec, httptest.NewRequest("GET", "/report", nil))
	fmt.Print(rec.Code, " ", rec.Body.String())

	// simulate a client that disconnects after 20ms
	ctx, cancel := context.WithCancel(context.Background())
	time.AfterFunc(20*time.Millisecond, cancel)
	req := httptest.NewRequest("GET", "/report", nil).WithContext(ctx)
	reportHandler(httptest.NewRecorder(), req)
}

Output:

200 report ready
client left, stopped work

In a real server you do not cancel anything yourself. When the browser tab closes, net/http cancels r.Context(), and slowReport stops instead of finishing work nobody will see.

Do not use Background inside handlers:

Calling context.Background() in a handler cuts the link to the request. Database queries and API calls then keep running after the client is gone. Always start from r.Context().

A Timeout for the Whole Handler

Wrap the request context to give the handler a time budget:

func handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel()
	data, err := loadData(ctx)
	// ...
}

The work stops at whichever comes first: two seconds, or the client disconnecting. http.TimeoutHandler from the standard library does something similar for a whole handler and also writes a 503 response when time runs out.

Outgoing Requests With a Context

http.Get(url) has no way to receive a context. Build the request with http.NewRequestWithContext instead:

package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"
)

func main() {
	slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		select {
		case <-time.After(300 * time.Millisecond):
			fmt.Fprintln(w, "finally")
		case <-r.Context().Done():
		}
	}))
	defer slow.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, slow.URL, nil)
	if err != nil {
		panic(err)
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		fmt.Println("timed out:", errors.Is(err, context.DeadlineExceeded))
		return
	}
	defer resp.Body.Close()
	fmt.Println("status:", resp.Status)
}

This prints timed out: true. The error message itself looks like Get "http://127.0.0.1:41235": context deadline exceeded, and errors.Is sees the deadline error inside it.

Client.Timeout vs context:

http.Client{Timeout: 10 * time.Second} sets a limit for every request made by that client, including reading the body. A context gives each call its own limit and also connects it to cancellation from upstream. Many programs use both: a generous client timeout as a safety net and a context per call.

Passing Values Through Middleware

Middleware, a function that wraps a handler, is the usual place to add request IDs or the logged-in user to the context:

package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
)

type reqIDKey struct{}

func withRequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		if id == "" {
			id = "generated-1"
		}
		ctx := context.WithValue(r.Context(), reqIDKey{}, id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func hello(w http.ResponseWriter, r *http.Request) {
	id, _ := r.Context().Value(reqIDKey{}).(string)
	fmt.Fprintf(w, "request %s handled\n", id)
}

func main() {
	h := withRequestID(http.HandlerFunc(hello))

	req := httptest.NewRequest("GET", "/", nil)
	req.Header.Set("X-Request-ID", "abc-123")
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	fmt.Print(rec.Body.String())
}

This prints request abc-123 handled. r.WithContext(ctx) returns a copy of the request carrying the new context, which is passed to the next handler.

Background work after the response:

If a handler starts a goroutine that should keep running after the response, such as sending an email, do not give it r.Context(). That context is cancelled as soon as the handler returns. Use context.WithoutCancel(r.Context()) (Go 1.21) to keep the values but drop the cancellation, and add its own timeout.

Context-aware HTTP code:

Handlers start from r.Context(), every outgoing request uses NewRequestWithContext, and middleware adds values with r.WithContext. That way, one client disconnect stops all the work it caused.

Summary

Use r.Context() in handlers so work stops when clients disconnect, and wrap it with WithTimeout for a handler budget. Build outgoing requests with http.NewRequestWithContext and check errors.Is(err, context.DeadlineExceeded). Middleware adds values with r.WithContext, and work that must outlive the request uses context.WithoutCancel plus its own timeout.