Home
Backend from First Principles / Module 8 — Request Context

Request Context

AsyncLocalStorage, contextvars, context.Context — sharing state down the call tree.


The argument that infects every function

You write a clean handler. It calls a service, which calls a repository, which runs a query. Then a requirement lands: every log line must include the request ID so support can trace one user's journey through the logs.

The request ID is known at the top — middleware generated it when the request arrived. The place that needs it is the logging call four layers deep. So you pass it down:

JavaScript
function getOrder(orderId, requestId) {
  return orderService.fetch(orderId, requestId);
}
function fetch(orderId, requestId) {
  logger.info('fetching order', { requestId });
  return orderRepo.findById(orderId, requestId);
}
function findById(orderId, requestId) {
  logger.info('db query', { requestId });
  return db.query('SELECT ...', [orderId]);
}

Every function in the chain now has a requestId parameter it does not use — it only forwards it. Then you add the authenticated user for audit logs. And the tenant ID for a multi-tenant query. And a trace ID for distributed tracing. Four parameters, threaded through every signature in the codebase, touched by functions that have no interest in them.

This is sometimes called "tramp data" — arguments that are just passing through. It couples every layer to data it does not care about, and adding a fifth field means editing every signature again. Request context is the structured fix.


What request context is

Request context is request-scoped storage: a small bag of values created when a request arrives, readable by any code running on behalf of that request, and discarded when the request finishes.

The two defining properties:

Request-scoped — each in-flight request gets its own isolated context. Request A's context and request B's context never see each other, even though both are being handled concurrently by the same process. This isolation is the hard part, and it is what the language runtime provides.

Ambient — code reads the context without it being passed in. The logger calls context.get('requestId') directly. The function signatures stay clean: fetch(orderId), findById(orderId) — no requestId parameter anywhere.

Text
   request A ──► [context A: reqId=a1, user=alice ]──► handler ─► service ─► repo
   request B ──► [context B: reqId=b2, user=bob   ]──► handler ─► service ─► repo
                  two requests, two isolated contexts, same code

The contrast with Module 8's predecessor — middleware — is worth stating. Middleware populates the context at the start of the request. Context is how that populated data reaches the deep layers without tramp arguments.


How each runtime implements it

The mechanism differs by language because the concurrency model differs. The concept is identical.

Node.js — AsyncLocalStorage. Node is single-threaded with an async event loop, so "thread-local" storage makes no sense. AsyncLocalStorage instead follows the async call chain — anything that runs because of a given request, including across await points, sees that request's store.

JavaScript
const { AsyncLocalStorage } = require('async_hooks');
const context = new AsyncLocalStorage();

// Middleware: open a context for this request
function contextMiddleware(req, res, next) {
  const store = new Map([
    ['requestId', req.headers['x-request-id'] || crypto.randomUUID()],
    ['user', null],
  ]);
  context.run(store, () => next());   // everything inward shares `store`
}

// Anywhere deep in the call tree — no parameter needed:
function getRequestId() {
  return context.getStore()?.get('requestId');
}

Go — context.Context, passed explicitly. Go made the opposite choice on purpose: context is a real first argument, by convention the first parameter of every request-path function. It is verbose, but the data flow is visible in every signature and impossible to lose.

Go
func GetOrder(ctx context.Context, orderID string) (*Order, error) {
    log.Info(ctx, "fetching order")          // ctx carries requestId
    return orderRepo.FindByID(ctx, orderID)  // pass it on
}

Python — contextvars. Standard library since 3.7, and asyncio-aware: each task gets its own copy, so concurrent coroutines do not clobber each other.

Python
from contextvars import ContextVar
request_id: ContextVar[str] = ContextVar('request_id')

# In middleware:  request_id.set(generate_id())
# Anywhere deep:  request_id.get()

Node and Python give you ambient context (clean signatures, runtime-managed isolation). Go gives you explicit context (visible, but threaded by hand). Both are legitimate — they trade invisibility against verbosity.


What belongs in context — and what does not

Context is useful precisely because it is small and disciplined. The things that genuinely belong there share a trait: they are about the request itself, set once, and read widely.

Notice these are all small scalars or one small object, all known at request start, all read-only afterwards.

What does not belong in context:

The database connection or ORM handle. It is tempting — "every layer needs the DB" — but it makes the dependency invisible. A function that needs the database should receive it as an explicit dependency so its needs are honest. Hiding infrastructure in context turns a clear dependency into spooky action at a distance.

Mutable working state. Context is not a scratchpad for passing half-computed values between functions. If findById needs a result from fetch, that is a return value, not a context write. Treat the context as immutable after the middleware fills it.

Anything large. The context lives for the whole request. A big object parked in it is memory held longer than it needs to be, multiplied by every concurrent request.

The litmus test: is this a fact about the request, set once at the edge, that many unrelated layers need to read? Yes → context. Anything else → a parameter, a return value, or an explicit dependency.


Common mistakes

Reading context before it is set. context.getStore() returns undefined if the calling code is not running inside context.run(...). Background jobs, startup code, and a timer callback that escaped the request all fall outside. Code that reads context must tolerate a miss — context.getStore()?.get('requestId') ?? 'no-request-id' — not assume the bag is always there.

Losing context across an async boundary. AsyncLocalStorage follows the async chain, but a few things break the chain: handing work to an external callback-based library, or pushing a job onto a queue. The queued job runs later, outside the original request, with no context. The fix is to copy the values you need out of context and pass them explicitly into the job payload. Context does not survive a trip through a message broker.

Using context as a function-argument replacement everywhere. If calculateShipping needs the cart total, that is a parameter. Passing it via context just to avoid typing an argument makes the function's real inputs invisible — you can no longer tell what it depends on by reading its signature. Context is for cross-cutting request facts, not for dodging parameters on ordinary functions.

Mutating context deep in the call tree. A repository that writes back into the context creates a hidden channel between layers. Now the order functions run in matters in a way nothing documents. Populate context once, in middleware, then treat it as frozen.

Leaking context into responses. Internal trace IDs and the full user object are for logs, not for the JSON you send the client. Be deliberate about what crosses from context into a response body.

Used with discipline, request context removes the tramp-argument problem cleanly: signatures stay honest, the request ID reaches every log line, and adding a new request-scoped field is a one-line change instead of a codebase-wide edit. Used carelessly, it becomes a global variable with extra steps. The discipline — small, request-only, write-once — is the whole technique. Next: Module 9, where the handler / service / repository layers that context flows through get defined properly.


Source & Credit

The Backend from First Principles series is based on what I learnt from Sriniously's YouTube playlist — a thoughtful, framework-agnostic walk through backend engineering. If this material helped you, please go check the original out: youtube.com/@Sriniously. The notes here are my own restatement for revisiting later.

⁂ Back to all modules