Back to issues
JAVASCRIPT

Lexical Scope and Closures: How JavaScript Decides Who Sees What

Understand once and for all how JavaScript resolves variables, why closures work, and how to avoid the classic scope bugs.

By Thiago Saraiva7 MIN

Mental model: Functions in JavaScript carry their birth certificate, not their current address. Wherever a function travels, gets passed, returned, or stored, it remembers the scope where it was born. Call site is irrelevant. Definition site is destiny.

Ever written a setTimeout inside a for loop and watched every callback print the same value? That classic bug exists because of one thing: lexical scope. Once you internalize it, half of JavaScript stops feeling like magic.

What Is Lexical Scope?

Lexical Scope (or Static Scope) is how JavaScript decides which variables a function can access. The rule is simple: what matters is where the function was defined, not where it's called.

Think of nested Russian dolls. Each inner doll sees everything around it, but the outer dolls have no idea what's tucked inside.

Resolution chain: local -> parent -> global -> error if not found.

Visualizing the Scope Chain

When child() looks up a variable, the engine walks outward like this:

┌─────────────────────────────────────┐
│ GLOBAL SCOPE                        │
│   global = "I'm global"             │
│   ┌─────────────────────────────┐   │
│   │ parent() scope              │   │
│   │   fromParent = "..."        │   │
│   │   ┌─────────────────────┐   │   │
│   │   │ child() scope       │   │   │
│   │   │   fromChild = "..." │   │   │
│   │   │        ↑            │   │   │
│   │   │   lookup starts     │   │   │
│   │   └─────────┬───────────┘   │   │
│   │             │ not found?    │   │
│   └─────────────┼───────────────┘   │
│                 │ keep climbing     │
└─────────────────┴───────────────────┘

Outward only. Never inward. Never sideways.

Closures: The Practical Consequence

A closure happens when a function "remembers" the variables from the scope where it was created, even after that scope has finished executing:

counter() has already finished executing, but count is still alive because the returned methods hold a reference to it via lexical scope. That's encapsulation without classes.

Picture the function as a submarine. When it dives, it carries its own oxygen tank, the scope it captured at birth. The surface can vanish, the crew keeps breathing.

Factory Functions: Closures in the Real World

Each returned function captures its own factor. Simple, elegant, and powerful.

Closure Patterns You'll Actually Use

Factories are the hello-world. Here's where closures start earning their keep.

1. Memoization (cache in the closure):

The cache lives inside the closure. No globals, no class, no ceremony.

2. Rate limiter (state without classes):

3. Event handler with private state:

isOn is invisible outside. Nothing can poke at it. True privacy, zero boilerplate.

The Classic Bug: Loop + Closures

This one catches everyone at least once:

var has no block scope, so a single i is shared by every callback. let creates a fresh binding per iteration, and each closure captures its own.

When Closures Bite You

Closures are great until they quietly eat your RAM. Real scenario:

Even though the handler never touches hugeData, some engines keep the entire enclosing scope alive because the closure could reference it. Remove the button from the DOM without removing the listener and you've got a leak: invisible in code review, screaming in DevTools' memory profiler.

Fix: keep heavy data out of the enclosing scope, or null it out when you're done.

Test pollution is the same problem in a different costume. Jest modules hold closures across tests, so a mutable variable captured at module scope will cheerfully carry state from one test to the next and ruin your afternoon.

Pitfalls You Need to Know

  1. JavaScript uses lexical scope, not dynamic scope. A function reads variables from where it was defined, not from where it's called.
  2. var has no block scope. Use let and const. Always.
  3. Closures capture references, not values. If the captured variable changes later, the closure sees the new value.
  4. Memory leaks. Closures keep references alive. A closure that holds a reference to a removed DOM node blocks the garbage collector.
  5. Variable shadowing. Redeclaring a name in an inner scope hides the outer one and breeds confusion.

FAQ

Closure vs callback, what's the difference? A callback is a function passed as an argument. A closure is a function that captures its surrounding scope. Most callbacks are closures, but not all closures are callbacks. Orthogonal concepts, frequently confused.

Do closures have a performance cost? Tiny. Modern engines optimize them aggressively. You'll feel memory pressure long before you feel CPU cost. Don't micro-optimize; do watch for leaks.

How do I "break" a closure when I need to? Set the captured reference to null, or refactor so the heavy object never enters the enclosing scope. You can't manually unlink a closure, but you can make its references unreachable.

Class private fields (#field) vs closures for privacy, which wins? Private fields are cleaner syntax and play nicer with this. Closures win when you don't want a class at all, or when you prefer per-instance private state in a functional style. Both are valid; pick the one that matches the surrounding code.

Closures inside setInterval, anything weird? Yes. The callback keeps its captured scope alive for as long as the interval runs. Forget to clearInterval and the closure, plus everything it references, lives forever. Always pair setInterval with a teardown path.

Key Takeaways

Lexical scope is the fundamental mechanism behind variable resolution in JavaScript. Closures are its natural consequence: functions that remember where they came from. This single idea is the foundation of the module pattern, factory functions, data privacy, and a long list of patterns you already use without naming.

Get lexical scope right, and most of JavaScript's "magic" turns into plain mechanics.