Common Usecases — Middle¶
The junior file showed you how r.Context(), http.NewRequestWithContext, and db.QueryContext plug together. The middle level is about the machinery around those calls: graceful shutdown, middleware that decorates the context, production-grade key patterns, server timeouts, and the new t.Context() API.
By the end of this page you should be able to:
- Wire up
srv.Shutdown(ctx)so in-flight requests drain before the binary exits. - Write middleware that adds a request ID, a logger, and an authenticated user to the context.
- Distinguish between request timeouts (
http.Server.WriteTimeout) and request deadlines (context-driven). - Decide when to add a value to a context and when to pass it as a parameter.
- Use
t.Context()from Go 1.24+ in tests.
Graceful Shutdown¶
A long-running HTTP server has a problem: when SIGTERM arrives (a deploy, a Kubernetes rolling restart), in-flight requests should finish, not be killed mid-response. http.Server.Shutdown handles this:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", health)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 90 * time.Second,
}
// Catch SIGINT / SIGTERM.
rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
serverErr := make(chan error, 1)
go func() { serverErr <- srv.ListenAndServe() }()
select {
case err := <-serverErr:
if !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server: %v", err)
}
case <-rootCtx.Done():
log.Println("shutdown signal received")
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
}
What srv.Shutdown(ctx) does, step by step:
- Stops accepting new connections.
- Waits for active requests to finish.
- If
ctxexpires before all are done, force-closes the remaining connections and returnsctx.Err().
Notice that the shutdown uses a separate context from the root signal context. We don't want SIGINT-during-SIGINT to kill the drain phase early. The 30*time.Second budget is the maximum we are willing to wait for in-flight requests. Pick a value matching your longest expected handler.
Coordinating with worker goroutines¶
If your service has long-lived workers (consumers, schedulers), they should listen to rootCtx.Done():
When SIGTERM fires, rootCtx is canceled and every worker stops. The HTTP server's drain happens in parallel. A complete shutdown looks like:
SIGTERM
│
├── rootCtx canceled ──► workers stop
│
└── srv.Shutdown(shutdownCtx)
│
├── new connections refused
├── active requests drain
└── return when all drained or shutdownCtx expired
errgroup.WithContext is the standard way to coordinate the workers and surface the first error:
g, ctx := errgroup.WithContext(rootCtx)
g.Go(func() error { return runConsumer(ctx, queue) })
g.Go(func() error { return runScheduler(ctx) })
if err := g.Wait(); err != nil {
log.Printf("worker: %v", err)
}
Server Timeouts vs Context Deadlines¶
http.Server has four timeouts. None of them is a context deadline:
| Field | Meaning |
|---|---|
ReadHeaderTimeout | Max time to read request headers |
ReadTimeout | Max time to read entire request including body |
WriteTimeout | Max time from end of header read to end of response write |
IdleTimeout | Max keep-alive idle time |
These are socket-level safeguards. They protect the server from slowloris attacks and runaway clients. They are not visible inside your handler — they cannot be checked, extended, or refined per route.
For per-handler deadlines, use http.TimeoutHandler or derive your own context with WithTimeout:
http.TimeoutHandler cancels the request's context when the timeout fires and writes a 503 to the client. Your handler still has to check ctx.Done() for the cancellation to actually free resources. Otherwise the response is dropped but your handler keeps running.
A pattern for variable deadlines per route:
func withRouteTimeout(d time.Duration, h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), d)
defer cancel()
h.ServeHTTP(w, r.WithContext(ctx))
})
}
mux.Handle("/api/heavy", withRouteTimeout(2*time.Second, heavyHandler))
mux.Handle("/api/cheap", withRouteTimeout(200*time.Millisecond, cheapHandler))
r.WithContext(ctx) returns a shallow-copied request whose context is ctx. Always do this when you derive a context inside middleware — never mutate r directly.
Middleware That Decorates Context¶
Middleware is the canonical place to put values into the context. Each middleware function:
- Reads or generates a value (request ID, user, logger).
- Calls
context.WithValueto add it. - Wraps
rwithr.WithContext(...)and calls the next handler.
A complete chain of three middlewares:
package main
import (
"context"
"log/slog"
"net/http"
"github.com/google/uuid"
)
// === Request ID ==========================================================
type reqIDKey struct{}
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, reqIDKey{}, id)
}
func RequestIDFrom(ctx context.Context) string {
id, _ := ctx.Value(reqIDKey{}).(string)
return id
}
func RequestIDMiddleware(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 = uuid.NewString()
}
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(WithRequestID(r.Context(), id)))
})
}
// === Logger ==============================================================
type loggerKey struct{}
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, loggerKey{}, l)
}
func LoggerFrom(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok {
return l
}
return slog.Default()
}
func LoggerMiddleware(base *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
l := base.With("request_id", RequestIDFrom(r.Context()), "path", r.URL.Path)
next.ServeHTTP(w, r.WithContext(WithLogger(r.Context(), l)))
})
}
}
// === Authenticated user ==================================================
type User struct {
ID string
Email string
}
type userKey struct{}
func WithUser(ctx context.Context, u User) context.Context {
return context.WithValue(ctx, userKey{}, u)
}
func UserFrom(ctx context.Context) (User, bool) {
u, ok := ctx.Value(userKey{}).(User)
return u, ok
}
func AuthMiddleware(verify func(token string) (User, error)) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
user, err := verify(token)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), user)))
})
}
}
// === Wiring ==============================================================
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /me", func(w http.ResponseWriter, r *http.Request) {
u, _ := UserFrom(r.Context())
log := LoggerFrom(r.Context())
log.Info("loading profile", "user_id", u.ID)
// ... use u.ID in DB query, etc.
})
var handler http.Handler = mux
handler = AuthMiddleware(verifyToken)(handler)
handler = LoggerMiddleware(slog.Default())(handler)
handler = RequestIDMiddleware(handler) // outermost: id flows everywhere
http.ListenAndServe(":8080", handler)
}
func verifyToken(token string) (User, error) {
return User{ID: "u-123", Email: "test@example.com"}, nil
}
Order matters: the outermost middleware in the chain runs first on the way in. Here RequestIDMiddleware is outermost so the ID is available to LoggerMiddleware. If you swap the order, the logger's request_id field is empty.
The Type-Safe Key Pattern¶
Notice every key is an unexported empty struct: type reqIDKey struct{}. This pattern has three properties:
- Zero size:
unsafe.Sizeof(reqIDKey{}) == 0. No allocation. - Type-unique: two packages can both have
type key struct{}and they will not collide because Go uses pointer identity for keys, derived from the type's package path. - Unexported: nothing outside the package can construct one, which means nothing outside the package can read or write the value. The package owns the key.
The alternative, string keys, is broken:
Anything in any package that does ctx.Value("user_id") reads your value. Tests, third-party middleware, future you. The compiler cannot help. Use the struct key.
For repeated reuse, some teams expose a singleton constant of the key type:
This works and is type-safe across keys but slightly less hermetic — every contextKey-typed value collides with every other in the same package. The empty-struct-per-key approach is the cleanest.
When To Use WithValue vs Pass As Parameter¶
A practical decision tree:
Is the value needed by EVERY function on the call path?
├── YES → context value (tracing, logger, user)
└── NO → parameter
Is the value request-scoped (changes per request)?
├── YES → context value
└── NO → DI / config / package-level
Is the value the actual subject of the function?
├── YES → parameter (it's the input!)
└── NO → consider context
Would the function silently misbehave if the value is missing?
├── YES → parameter (don't hide critical inputs)
└── NO → context value with sensible default
Examples:
| Value | Where it goes | Why |
|---|---|---|
userID for "get my profile" | parameter | It IS the function's input |
| Authenticated user (for authz checks across many handlers) | context | Cross-cutting, request-scoped |
| Trace ID | context | Cross-cutting, observability |
*sql.DB | DI / parameter | Not request-scoped, must exist |
| Feature flag value | DI / package-level | Not request-scoped |
Per-request slog.Logger (with attrs) | context | Cross-cutting, request-scoped |
| Tenant ID in multi-tenant SaaS | context | Request-scoped, used by every layer |
| Idempotency key | context | Request-scoped, used by middleware + handler |
Senior reviewers will push back on ctx.Value for anything that could be a parameter without making 30 signatures longer. When in doubt: parameter.
Tests with t.Context() (Go 1.24+)¶
Pre-Go 1.24 the idiom was:
func TestThing(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// ...
}
Go 1.24 added t.Context() which gives you a context that:
- Is canceled automatically when the test ends.
- Has no deadline (set one with
WithTimeoutif needed). - Inherits cleanup from
t.Cleanup.
func TestThing(t *testing.T) {
ctx := t.Context()
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
o, err := loadOrder(ctx, 42)
if err != nil { t.Fatal(err) }
_ = o
}
If your test spawns a goroutine that should die when the test ends, capture t.Context() instead of context.Background():
go func() {
for {
select {
case <-ctx.Done():
return
case <-time.After(100 * time.Millisecond):
// poll
}
}
}()
Cancel propagation works automatically — when t finishes, ctx.Done() closes.
b.Context() exists on *testing.B for benchmarks. tb.Context() on testing.TB.
Table-Driven Tests with Per-Case Timeout¶
func TestLookup(t *testing.T) {
cases := []struct {
name string
id int64
timeout time.Duration
wantErr error
}{
{"happy path", 1, 2 * time.Second, nil},
{"slow query", 999, 50 * time.Millisecond, context.DeadlineExceeded},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), tc.timeout)
defer cancel()
_, err := loadOrder(ctx, tc.id)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("got %v, want %v", err, tc.wantErr)
}
})
}
}
Each subcase has its own deadline, deriving from t.Context() so all goroutines spawned in that subcase die when the subcase ends.
Worker Pools With Context¶
A common middle-level task: build a pool of N workers consuming from a channel. The pool stops when ctx is canceled.
func RunPool[T, R any](
ctx context.Context,
n int,
jobs <-chan T,
process func(context.Context, T) (R, error),
) <-chan Result[R] {
out := make(chan Result[R])
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
r, err := process(ctx, j)
select {
case <-ctx.Done():
return
case out <- Result[R]{Value: r, Err: err}:
}
}
}
}()
}
go func() { wg.Wait(); close(out) }()
return out
}
type Result[R any] struct {
Value R
Err error
}
Key invariants:
- Every blocking operation selects on
ctx.Done(). outis closed by a goroutine waiting onwg, after all workers stop.- The caller can
rangeoveroutand the loop ends naturally on cancellation.
Use this whenever you want N parallel HTTP fetches, N parallel DB inserts, or N parallel anything.
A Compact Pipeline Example¶
A classic three-stage pipeline: produce IDs, fetch records, write results. Cancellation flows top to bottom:
func produceIDs(ctx context.Context, ids []int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, id := range ids {
select {
case <-ctx.Done():
return
case out <- id:
}
}
}()
return out
}
func fetchRecords(ctx context.Context, in <-chan int) <-chan Record {
out := make(chan Record)
go func() {
defer close(out)
for id := range in {
r, err := db.Lookup(ctx, id)
if err != nil {
continue
}
select {
case <-ctx.Done():
return
case out <- r:
}
}
}()
return out
}
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
ids := produceIDs(ctx, []int{1, 2, 3, 4, 5})
records := fetchRecords(ctx, ids)
for r := range records {
fmt.Println(r)
}
}
If you press Ctrl-C, ctx is canceled, produceIDs exits, the channel closes, fetchRecords finishes the in-flight item then exits. No goroutine leaks.
A Note on context.WithValue Performance¶
Every WithValue allocates a new struct (*valueCtx) and adds to the chain. Lookups are linear in chain depth. For 5–10 values this is invisible (single-digit nanoseconds). For 50, it shows up in profiles.
| Chain depth | Value() cost (typical) |
|---|---|
| 1 | ~5 ns |
| 5 | ~12 ns |
| 20 | ~40 ns |
| 100 | ~200 ns |
If you find yourself adding 30 values to a context, you have probably misused it. Bundle related values into a single struct and store one key:
type RequestInfo struct {
ID, TenantID, UserID string
Logger *slog.Logger
}
type reqInfoKey struct{}
func WithRequestInfo(ctx context.Context, ri *RequestInfo) context.Context {
return context.WithValue(ctx, reqInfoKey{}, ri)
}
func RequestInfoFrom(ctx context.Context) *RequestInfo {
ri, _ := ctx.Value(reqInfoKey{}).(*RequestInfo)
return ri
}
One key, one allocation, one O(depth) lookup, then field accesses are O(1).
Self-Assessment¶
You are ready for the senior file when you can:
- Implement
srv.Shutdownwith proper SIGTERM handling and per-worker coordination. - Write a chain of three middlewares (request ID, logger, auth) using type-safe keys.
- Explain why we never mutate
rdirectly and always dor.WithContext(...). - Defend, in review, why a particular value belongs in
ctx.Valueor in a parameter. - Refactor a test from
context.Background()boilerplate tot.Context().
What's Next¶
Senior takes us across services: deadline budgeting (when ctx has 500ms left, what does each downstream call get?), gRPC's role in metadata propagation, and observability (OpenTelemetry's role in injecting/extracting span context).