skip to content

Why can't an inner handler pass a tenant id back up to an outer middleware through r.Context()?

level: seniorimportance: should knowfreq 41%

answer

  1. values travel one direction only
  2. the parent never learns about the child
  3. reassigning r changes one frame's variable
  4. send something mutable down first
  5. the holder is per request, never shared

basics

~20 s

Context values flow downward only. Attaching one builds a child the parent never learns about, and reassigning r rebinds a local variable pointing at a fresh copy, so the outer middleware still holds the original request and reads nothing.

solid answer

~50 s

Two immutabilities stack here. A `context.Context` is immutable: attaching a value returns a *child* pointing at the parent, and the parent an outer frame holds is unchanged. And `r.WithContext(ctx)` returns a copy, so when the inner handler writes `r = r.WithContext(ctx)` it rebinds its own local variable; the outer middleware's `r` still refers to the request it created. So a logging middleware that reads the tenant after `next.ServeHTTP` returns logs an empty string no matter what the handler attached. The fix is to send a mutable container *down* before you need the value back: the outer middleware allocates a small per-request struct, puts a pointer to it in the context, and the inner handler fills the field. The container must be allocated per request — a package-level or shared-struct field would be written by every concurrent request at once.

code

go · 11 lines
go
type tenantHolder struct{ id string }
type holderKey struct{}

func logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		h := &tenantHolder{} // allocated per request, never package level
		r = r.WithContext(context.WithValue(r.Context(), holderKey{}, h))
		next.ServeHTTP(w, r)
		log.Printf("tenant=%q", h.id)
	})
}

go deeper

for a junior

Remember the direction rule: a value attached deeper in the chain is visible to code below it, never to the middleware that called it.

for a middle

Explain both halves — deriving a context builds a child the parent cannot see, and reassigning the request rebinds only that frame's variable — and show the pointer-holder workaround.

for a senior

Diagnose the empty log line without guessing, and call out the two dangerous shortcuts: hoisting the holder to shared state, and filling it from a goroutine without synchronisation.

for a principal

Decide where a value should be resolved in the first place so it flows downward, and set the boundary on what request-scoped state is allowed to be mutable and who may write it.

## The symptom An outer middleware wants to log the tenant each request was resolved to, and the tenant is only known deeper in the chain, where the routing decision has been made: ```go func logging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) id, _ := TenantFrom(r.Context()) // always "" log.Printf("tenant=%q", id) }) } ``` The inner handler demonstrably attached the value — a log line there prints it — and the outer middleware demonstrably runs afterwards. The read still yields nothing. Nothing is broken; the design says this cannot work. ## Why it cannot work **Contexts derive downward.** Attaching a value to a context does not insert anything into that context. It allocates a new context whose parent is the one you passed, holding one key and one value. Lookups walk from child toward root, so a child can see everything its ancestors hold and an ancestor can see nothing its descendants added. The outer middleware holds the parent; the value lives in a child it has no reference to. **Requests are copied, and variables are local.** `r.WithContext(ctx)` returns a new `*http.Request`. When the inner handler writes `r = r.WithContext(ctx)`, it changes what its own parameter variable points at. The outer middleware's `r` is a different variable in a different frame, still pointing at the request it passed to `next.ServeHTTP`. Even if it were the same pointer, the context field would still be the old one, because `WithContext` does not write through the pointer. This is not an accident of the API. It is what makes a middleware chain safe to reason about: whatever a frame passes down, it still holds afterwards, and no code below it can alter the request or context it will use for its own after-work. ## The pattern that does work If an outer frame needs a value produced below it, it must send something *mutable* down before the call, because the downward direction is the only one that works. A pointer to a per-request struct does that: the pointer is copied downward like any value, and both frames end up referring to the same allocation. ```go type tenantHolder struct{ id string } type holderKey struct{} func logging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := &tenantHolder{} // one per request r = r.WithContext(context.WithValue(r.Context(), holderKey{}, h)) next.ServeHTTP(w, r) log.Printf("tenant=%q", h.id) }) } ``` The handler below fills it: ```go if h, ok := r.Context().Value(holderKey{}).(*tenantHolder); ok { h.id = tenant } ``` The outer middleware still holds `h`, so after `next.ServeHTTP` returns it reads what was written. Note that the *context* is still immutable — the value it carries is a pointer, and the pointee is what changed. ## The two mistakes that make this dangerous **Hoisting the holder out of the request.** The tempting simplification is to keep the tenant on the middleware struct itself, or in a package-level variable, since "the middleware only handles one request at a time". It does not. `net/http` runs each request in its own goroutine against the *same* handler value, so every concurrent request writes the same field. The result is not a crash but a mix-up: a response computed for one tenant logged, or scoped, under another. It is also a data race, which the race detector will report on a `-race` build if two requests overlap during the run — and will report nothing at all if they happen not to. The holder must be allocated inside the handler func, per request. **Writing the holder from a goroutine.** If the handler starts a goroutine that fills the holder, the outer middleware's read after `next.ServeHTTP` returns is no longer ordered after that write. Either the write must happen before the handler returns, or the field needs a mutex or an atomic. A plain string field written from another goroutine and read here is a race regardless of whether you observe it. ## Alternatives worth naming Often the better answer is to remove the need. If the outer middleware is the one that wants the tenant, let the outer middleware resolve it and attach it on the way down — then every frame below sees it, and nothing has to travel upward. Reserve the holder pattern for values only the inner layers can know, such as the identifier of the record a handler actually served, and keep the struct small and explicitly documented as request-scoped so nobody is tempted to cache it anywhere longer-lived.

  • What breaks if the holder is a field on the middleware struct instead of allocated per request?
    Every concurrent request shares it. `net/http` serves each request in its own goroutine against the same handler value, so the writes interleave and one request's tenant is read by another — a cross-tenant mix-up plus a data race the detector will flag on a `-race` build when the requests overlap.
  • Is the holder pattern safe if the handler fills it from a goroutine it started?
    Not as written. The outer middleware's read after `next.ServeHTTP` returns is not ordered after a write from another goroutine, so it is a race. Either complete the write before the handler returns, or protect the field with a mutex or an atomic.
  • When would you avoid the holder entirely?
    Whenever the outer middleware could resolve the value itself. Attaching it on the way down means every frame below sees it and nothing has to travel back up. Keep the holder for facts only the inner layers can know, such as which record was actually served.
  • Does the immutability argument still hold if the middleware and handler share the same *http.Request pointer?
    Yes. `WithContext` does not write through the pointer — it allocates a new request and returns it — so even an identical pointer in both frames would still carry the original context. The value only becomes visible upward through something mutable you passed down.

It is like handing someone a photocopy with a note attached: they can staple more pages to their copy, but your copy on the desk never grows unless you gave them a folder that was yours to begin with.

saying these in an interview costs you the question

  • Says a handler can add a value to the parent context
  • Thinks reassigning r updates the caller's request
  • Keeps the resolved tenant on the shared middleware struct
  • Uses a package-level variable for per-request state
  • Writes the holder from a goroutine with no synchronisation
  • Believes each request goroutine gets its own package state