Dynamic Scope: The Scoping Model JavaScript Chose Not to Use (and Why It Matters)
Understand the difference between lexical scope and dynamic scope, why JavaScript uses the former, and how AsyncLocalStorage simulates dynamic scope in Node.js.
Mental model: Lexical scope is the address of the house where you grew up. No matter where life takes you, that's where your memories live. Dynamic scope is whoever drove you to the party tonight. Same you, but the context changes every trip. JavaScript picked the first one. And thank goodness it did.
If you write JavaScript, you use lexical scope all day without thinking about it. But have you ever stopped to imagine what scope would feel like if it were determined by who called the function rather than where it was written? That's dynamic scope, and understanding why JavaScript rejected it is the fastest way to actually get closures.
Lexical vs Dynamic: The Fundamental Difference
- Lexical scope (JavaScript): prints
"global", becauseinnerwas defined in the global scope, and that's where it looks forvalue. - Dynamic scope: would print
"outer", becauseinnerwas called byouter, and the lookup would walk the call chain.
In lexical scope, scope follows the structure of the code. In dynamic scope, it follows the execution flow.
Side by side
| Aspect | Lexical (JS) | Dynamic |
|---|---|---|
| Resolved by | Where code is written | Who called the function |
| Resolved at | Parse/compile time | Runtime |
| Debugging | Read the file | Trace the call stack |
| Enables closures | Yes | No, not cleanly |
| Example languages | JS, Python, C, Java | Emacs Lisp, early Lisp, Bash (sort of) |
Why Lexical Scope Is Better
With lexical scope, you can read the code and know exactly where each variable comes from. With dynamic scope, you'd have to trace the entire call chain at runtime. Debugging becomes a nightmare.
Lexical scope also enables closures, functions that "remember" the environment where they were created. Without it, closures wouldn't work the way we expect.
A little history (and a war story)
McCarthy's original Lisp (1958) ended up dynamically scoped almost by accident. The first interpreter, written by Steve Russell, looked up free variables on the runtime environment list, and that behavior became the de facto semantics. The community spent decades cleaning up the mess. Scheme (1975, Sussman and Steele) made lexical scope the default and basically every modern language followed. Emacs Lisp held out the longest, dynamic scope was the only option until Emacs 24 in 2012 introduced opt-in lexical binding via a file-local variable. Generations of Elisp hackers got bitten by functions silently picking up bindings from whoever called them.
JavaScript has its own cautionary tale. Every junior dev has, at least once, confused this with dynamic scope. You write a callback, this isn't what you expected, and you lose an hour. this resolves dynamically (it depends on how the function is called), but actual variable lookup is rock solid lexical. Internalize that distinction and half your "what is going on" bugs vanish.
When Dynamic Scope Makes Sense
JavaScript doesn't use dynamic scope, but context based on the call chain is genuinely the right tool for some jobs:
- Request-scoped data: any function called within a request needs access to that request's context (user, trace ID, tenant).
- Logging context: propagating trace IDs without threading them through every signature.
- Feature flags: configuration that varies per request or user.
Picture this. You have to thread a trace ID through 15 function calls just so the logger at the bottom can print it. Every function in between now carries a parameter it doesn't care about, doesn't read, and only exists to hand off. That's the itch dynamic scope scratches.
Simulating Dynamic Scope in Node.js
AsyncLocalStorage is the official answer in Node, stable since 16.4 and the recommended primitive in 2026 for request context, OpenTelemetry trace propagation, and structured logging:
This is dynamic in spirit: saveToDatabase doesn't know where the context came from, only that someone up the call chain called asyncLocalStorage.run(). Worker threads each get their own store, and the per-async-resource cost dropped significantly after the V8 continuation-preserved embedder data work landed, so the old "AsyncLocalStorage is expensive" reflex from the Node 14 era no longer applies for typical web workloads.
When NOT to Use AsyncLocalStorage
AsyncLocalStorage is a power tool. Treat it like one. Prefer an explicit parameter when:
- The data is part of the function's job. If
chargeCustomer(amount, customerId)needs the customer ID, pass it. Hiding it in ambient context makes the function lie about its inputs. - You're writing a library. Libraries shouldn't silently depend on a context someone, somewhere, remembered to set. Accept a config object. Let callers decide.
- Unit testing matters. Pure functions with explicit inputs are trivial to test. Functions that reach into ambient storage need setup, mocks, and teardown.
- The call chain is short. If you're passing through two functions, just pass it. The abstraction tax isn't worth a two-hop trip.
Rule of thumb: if the data is observational (trace IDs, request correlation, user locale for logging), AsyncLocalStorage is great. If the data is operational (the thing the function actually works on), make it a parameter.
The Context Stack Pattern
To understand how dynamic scope works under the hood:
Each function call does push(), sets its locals, and on return does pop(). Lookup walks the stack from top to bottom, from the current frame down to the first one that defined the variable.
It's a stack of trays in a cafeteria. Drop a new tray on top when you enter a function, take it off when you leave, and when you need a value you check the top tray first, then the one below, and so on down.
Pitfalls
Confusing it with lexical scope: JavaScript does not use dynamic scope. this has behavior that looks dynamic (it depends on how the function is called), but variable scoping is always lexical.
Race conditions: in asynchronous code, shared context can leak between requests if it isn't properly isolated. AsyncLocalStorage handles this for you. A plain module-level object does not.
Hard-to-trace dependencies: ambient context is implicit. You can't see in the function signature where the data comes from.
Prefer explicit parameters: dynamic scope, or simulations of it, should be used sparingly. Reserve AsyncLocalStorage for trace IDs, logging context, and request correlation.
FAQ
Is this dynamic scope?
No, but it rhymes. this is resolved based on how the function is called (method call, new, bind, arrow inheritance), but variable lookup itself is always lexical. Two different mechanisms, same "wait, what?" energy.
Do closures work because of lexical scope? Yes. A closure is a function plus the lexical environment where it was defined. That environment exists precisely because scope is tied to where the function was written, not who called it. Swap in dynamic scope and closures as we know them collapse.
Is AsyncLocalStorage slow?
It has a cost, async context tracking isn't free, but for typical web workloads (trace IDs, request context) it's negligible next to your database round trips. The performance gap versus manual parameter passing has narrowed substantially since the Node 18 to 22 cycle. Don't micro-optimize it away. Do benchmark if you're putting it in a hot loop.
Why did early Lisp use dynamic scope if it's so bad? It was the path of least resistance for the interpreter. Nobody sat down and said "dynamic scope is great." It fell out of the implementation, people shipped code, and by the time the industry agreed lexical was cleaner there was a mountain of cleanup to do. Lesson: defaults matter.
Can I just use a global variable instead of AsyncLocalStorage?
In a single-threaded synchronous script, sure. In a Node server handling concurrent requests, absolutely not. Two requests arrive, both write to your global, and now user A is seeing user B's trace ID in the logs, or worse. AsyncLocalStorage exists specifically to give you per-async-context isolation.
Key Takeaways
- JavaScript uses lexical scope: scope determined by where the function was written
- Dynamic scope determines scope by who called the function at runtime
- Lexical scope is more predictable and enables closures
AsyncLocalStoragesimulates dynamic scope in Node.js for request context and logging- Use it sparingly: explicit parameters are clearer and more testable
- Understanding dynamic scope is the shortest path to understanding why lexical scope was the right call for JavaScript