Why build an outbound call with http.NewRequestWithContext instead of http.NewRequest?
answer
- one of the two supplies a default
- the default is an empty root
- cancellation has to reach the call somehow
- process-local versus on the wire
- the header is the only thing serialised
basics
~10 shttp.NewRequest gives the request context.Background(), so the outbound call ignores the caller's deadline and cancellation and carries none of the per-request state your code keeps in a context.Context. NewRequestWithContext attaches the caller's context instead.
solid answer
~40 s`http.NewRequest` is defined as `NewRequestWithContext` with `context.Background()`, so a request built that way is detached from its caller: when the caller's context is cancelled or its deadline passes, the outbound call keeps running, and any per-request state — a request id, a correlation value, a logger — held in that `context.Context` is unreachable from the request. `NewRequestWithContext(ctx, …)` attaches the caller's context, so `req.Context()` hands it back to anything inspecting the request, and cancelling the parent aborts the in-flight call including the body read. For header propagation it matters twice: the identifier you want on the wire usually lives in the context, and the code that turns it into a header has to be able to find it there. The context itself never crosses the wire — only the header does.
code
go · 8 linesfunc fetch(ctx context.Context, url, traceID string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Trace-Id", traceID)
return http.DefaultClient.Do(req)
}go deeper
Know the shape of both constructors and that the context-taking one is the default choice in any code that already has a context to hand. Recall that a context never travels over the network.
Explain that the shorter constructor substitutes an empty root context, and describe both consequences: no cancellation or deadline reaches the call, and per-request state kept in the context is invisible to whatever builds the request.
Demonstrate the two-step propagation discipline across a service boundary — read from context and write a header on the way out, read the header and repopulate context on the way in — and say where you would enforce it so a new call site cannot skip it.
Weigh how much of this you make a convention versus a mechanism: a rule reviewers enforce is cheap and leaks, a shared outbound-call helper costs an internal library other teams must adopt. Be able to argue which your organisation should pay for.
### The two constructors ```go func NewRequest(method, url string, body io.Reader) (*http.Request, error) func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*http.Request, error) ``` The first is the second with `context.Background()` supplied for you. That is the whole difference, and it is a bigger difference than it looks. ### What the request's context controls Every `*http.Request` carries a `context.Context`, readable with `req.Context()`. On an outbound request the transport watches it for the lifetime of the exchange: - If the context is cancelled or its deadline expires before the response headers arrive, `client.Do` returns an error wrapping `context.Canceled` or `context.DeadlineExceeded`. - If it fires while you are still reading `resp.Body`, the read fails with the same error and the connection is closed rather than being reused. So the context is not merely a dial timeout — it covers the whole call, body included, which is why the context must stay alive as long as you intend to read the response. ### Why context.Background() is the wrong default inside a request path A server handler receives a request whose context is already cancelled when the client disconnects or when the handler returns. If your handler makes a downstream call built with `http.NewRequest`, that downstream call is orphaned from all of it: the caller has hung up, the handler has returned, and your outbound request is still occupying a connection and waiting for a service that may be the reason the caller gave up. Threading `r.Context()` into `NewRequestWithContext` makes the whole chain collapse together when the top of it is abandoned. ### The tracing half of the answer The second reason is the one that concerns headers. Per-request identity in Go rides in `context.Context`: whatever value you (or a middleware) attached when the request arrived is read back out of the context later, formatted, and written onto the outbound request with `req.Header.Set`. If the outbound request is built from `context.Background()`, any code that keys off `req.Context()` to decide what to write finds nothing there and writes nothing, and the next hop sees a request with no identifier and begins a fresh chain. The symptom shows up nowhere near the cause: the sender logs fine, the receiver logs fine, and only the joined view is broken. ### The context does not travel This is the boundary worth stating out loud, because it is the thing people get backwards. A `context.Context` is process-local. `net/http` serialises the request line, the headers and the body; there is no channel by which a context value reaches another process. Propagation is therefore always a two-step dance: 1. On the way out — read the value from `ctx`, write it into `req.Header` with `Set`. 2. On the way in — read it from the inbound `r.Header` with `Get`, put it into the handler's own context. The context is the in-process carrier; the header is the wire carrier. Every hop does both halves, and a hop that does only one is where a chain breaks. ### Retrofitting a context onto an existing request Two methods exist for a request you already hold: - `req.WithContext(ctx)` returns a **shallow** copy with the new context. Cheap — but the copy shares the original's header map, so mutating headers on one mutates both. - `req.Clone(ctx)` returns a **deep** copy, duplicating the URL and the header map. This is what you want when the copy will be modified. It panics if `ctx` is nil. Both return a new `*http.Request`; neither mutates the original. ### What good looks like A function that performs an outbound call takes `ctx context.Context` as its first parameter, passes it to `NewRequestWithContext`, sets the propagation headers from values it reads out of that same `ctx`, and lets the caller decide the deadline. Nothing about the call is reachable from a package-level variable, and nothing about it survives the caller giving up.
- Does anything you stored with context.WithValue reach the receiving service?No. A `context.Context` is process-local, and `net/http` serialises only the request line, the headers and the body. Anything that must cross the boundary has to be copied into a header before the request is sent, and read back out of the inbound header by the receiver into its own context. Forgetting either half is exactly how a correlation chain acquires a hole.
- You already hold an *http.Request and need it under a different context. What do you call?`req.WithContext(ctx)` returns a shallow copy with the new context — cheap, but it shares the original's header map, so header mutations on the copy hit the original too. `req.Clone(ctx)` returns a deep copy, duplicating the URL and the header, which is what you want whenever the copy will be modified. `Clone` panics if the context is nil.
- What happens to an outbound call when the context you passed is cancelled while you are reading the body?The read fails with an error wrapping `context.Canceled` (or `context.DeadlineExceeded`), and the connection is closed instead of being returned for reuse. Cancellation covers the whole exchange, not just the header phase, so a context you cancel with `defer cancel()` before you finish consuming the body will break the body read.
saying these in an interview costs you the question
- Says http.NewRequest picks up the caller's context automatically
- Thinks context values are serialised onto the wire
- Treats the request context as only a connect timeout
- Cancels the context before finishing the response body
- Assumes a downstream call stops when the handler returns