~/definitionOfDone.dev/blog/module-scope-is-not-request-scope.md

7 min read

Module Scope Is Not Request Scope

Here is a twenty-line function with a bug that will never appear in a unit test, never reproduce on your machine, and only manifests in production — in requests that have nothing…

TL;DR

  • Lambda reuses execution environments. Module-level state survives between invocations — for hundreds of requests, across different callers, sometimes across different handlers sharing a layer.
  • So a module-level variable that any request can write is a cross-request bug. It won't reproduce locally, won't appear in a unit test, and will present as an intermittent production failure that scales with traffic and heals when environments recycle.
  • The rule: module scope is for things belonging to the execution environment. Function scope is for things belonging to the request. Anything varied by a request belongs in function scope.
  • Fix it by removing the state, not by guarding it. A guard prevents one known failure; removing the state removes the category.
  • A boolean parameter meaning "don't do the default thing" usually means the default didn't belong in that function. Make it a parameter and the flag disappears.
  • Review heuristic that catches nearly all of these: find every module-scope let in shared code and ask what writes it.

Here is a twenty-line function with a bug that will never appear in a unit test, never reproduce on your machine, and only manifests in production — in requests that have nothing to do with the request that caused it.

jssnippet
let headers = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Credentials': true
};

export const createResponse = (statusCode, body, excludeHeaders) => {
  if (excludeHeaders) {
    headers = {};                       // reassigns module state
  }
  return { statusCode, headers, body: JSON.stringify(body) };
};

Sit with it for a second. Everything you need is on the screen.

The mechanism

headers is declared with let at module scope. The excludeHeaders parameter doesn't skip headers for this call — it reassigns the module-level variable.

In a long-running server that would be bad enough. In Lambda it's worse, because of how execution environments work.

When Lambda starts an environment it loads your code, resolves imports, and executes module-level statements once. Then it invokes your handler. Then it keeps the environment alive and invokes your handler again for the next request. And again. Module state persists for the life of that environment, which can be many minutes and many hundreds of invocations.

So:

  1. Cold start. headers holds the CORS defaults.
  2. Fifty requests call createResponse(200, body). All get CORS headers. Fine.
  3. Request fifty-one calls createResponse(200, body, true) — a handler suppressing headers for a redirect, or a webhook acknowledgement.
  4. headers is now permanently {}.
  5. Requests fifty-two onward get no CORS headers. For the life of that environment.

Step five is where it stops being a local bug. If this lives in a shared layer mounted by many functions, a webhook acknowledgement that legitimately suppresses headers has just broken CORS for every subsequent browser-facing response served by that execution environment.

And the symptom is the worst kind: intermittent. Some browser requests work, some don't, depending on which environment they land on and what that environment happened to serve earlier. The failure rate scales with traffic. It heals itself when environments recycle, which means it disappears while you're investigating. Every attempt to reproduce it locally succeeds, because locally you invoke the function once.

mermaidsnippet
sequenceDiagram
    participant A as Request A<br/>(webhook ack)
    participant M as Module state
    participant B as Request B<br/>(browser-facing)
    Note over M: cold start<br/>headers = { CORS defaults }
    A->>M: createResponse(200, body, true)
    M->>M: headers = {}
    Note over M: mutated for the life<br/>of this environment
    B->>M: createResponse(200, body)
    M-->>B: headers = {}
    Note over B: browser blocks the response

I've spent real time on bugs with this signature. They cost far more than their size suggests, because the reproduction story is "run it under load and watch a percentage fail" and the causal chain crosses a module boundary, a request boundary, and an infrastructure concept most people know in the abstract but don't carry into code review.

Don't guard it. Remove it.

The instinct on spotting this is to make the mutation local:

jssnippet
const base = { /* defaults */ };
export const createResponse = (statusCode, body, excludeHeaders) => {
  const headers = excludeHeaders ? {} : { ...base };
  // ...
};

That works. It's still not what I'd ship, because it keeps a boolean meaning "do the other thing", and the module still holds an opinion about CORS that every caller inherits by default.

Here's the version I'd actually write:

tssnippet
export const createResponse = (
  statusCode: number,
  body: unknown,
  headers: Record<string, string> = {}
): APIGatewayProxyResult => ({
  statusCode,
  headers,
  body: JSON.stringify(body ?? null),
  isBase64Encoded: false
});

Count what changed:

  • No module state. Nothing to leak, so no leak is possible.
  • No boolean flag. Headers are a parameter. Want none? That's the default. Want CORS? Pass CORS.
  • No opinion. The module builds a response shape. That's its whole job.
  • Pure. Same inputs, same output, always. Testable with no harness.

The bug isn't fixed. The bug is unreachable, because the state it depended on no longer exists.

That distinction is the point. A guard prevents a known failure. Removing the state removes the entire category — including the failures nobody has thought of yet. Add a second flag to the guarded version and you can reintroduce the same class of bug. There's nowhere to put it in the second version.

The boolean was the design error

Worth pulling on, because it's the more transferable lesson.

createResponse(200, body, true) is unreadable at the call site. What's true? You have to open the module to find out.

And the flag exists because the module made a decision — "responses get these CORS headers" — that it wasn't entitled to make. So it needed an escape hatch for callers who disagreed.

Once headers are a parameter, the flag has nothing to do. The escape hatch disappears because the thing it was escaping from is gone.

A boolean parameter named "don't do the default thing" almost always means the default didn't belong in that function. Push the decision to the caller and the parameter evaporates.

There's a genuine cost, and I'll name it: the old version gave every handler sensible CORS for free. The new one means a handler that forgets gets none.

The right resolution isn't to put the defaults back. It's a separate, explicitly named function — corsHeaders(origin, allowlist) — that the handler calls and passes in. Composition instead of a default, and the call site now says what it's doing:

jssnippet
return createResponse(200, payload, corsHeaders(event.headers.origin, ALLOWED_ORIGINS));

Slightly more typing. Considerably more obvious.

Module scope is not the enemy

To be clear, because this can be over-applied: module-level state in Lambda is not automatically wrong. It's the standard mechanism for things you want to survive between invocations, and it's the single most effective cold-start optimisation available:

jssnippet
const s3 = new S3Client({});              // reuse the client — correct
const pool = new Pool(dbConfig);          // reuse connections — correct
let cachedSigningKeys;                    // avoid refetching JWKS — correct, if done properly

Reusing an SDK client or a connection pool across invocations is right. Caching a slow-changing value is right.

The distinction isn't "state or no state". It's whose state is it:

Module scope is for things that belong to the execution environment. Function scope is for things that belong to the request. Anything derived from, or varied by, a request belongs in function scope.

headers failed that test. It was varied by a per-call argument and lived in environment scope. That mismatch is the bug, stated precisely — and it's the thing to look for.

Two more shapes worth recognising

jssnippet
let secrets;
export const handler = async (event) => {
  secrets = await getSecrets(process.env.SECRET_ID);   // unconditional reassignment
  // ...
};

This looks like caching and isn't — it refetches every invocation. All of the shared-mutable-state hazard, none of the benefit. It's the worst of both, and it's common.

jssnippet
let client;
export const doThing = async (config) => {
  if (!client) client = createClient(config);   // first caller's config wins
  // ...
};

Lazy initialisation where the initialising value comes from the caller. The first request in each environment silently determines behaviour for all the rest. Correct only while every caller passes identical configuration — which is true right up until someone adds one that doesn't.

The review heuristic

Find every module-scope let or var in shared code and ask what writes it.

Module-scope const holding an immutable value: fine. Module-scope let assigned inside an exported function: that's where these bugs live, essentially every time. It's a grep, and it takes ten minutes across a whole codebase.

The version you'll actually meet won't be CORS headers. It'll be a cached tenant identifier, a locale set on first use, a feature flag resolved once, a logger configured by whichever handler got there first. Same mechanism, same invisibility, same intermittent production symptom.

Two questions catch nearly all of them:

  1. What in this module survives between invocations?
  2. Is any of it derived from a request?

If the answer to the second is yes, the bug is already in production. You just haven't correlated the support tickets yet.

newer Your Integration Layer Holds Until Something Breaks older A Shared HTTP Wrapper That Decides Nothing