JavaScript Closures and Scope 2026 Tutorial

JavaScript Scope & Closures: Lexical Scope, Closure Patterns, and Memory

Scope and closures are two of the most important concepts in JavaScript — and two of the most misunderstood. Scope determines where variables are accessible in your code. Closures happen when a function remembers the variables from the place it was created, even after that outer function has finished executing. Understanding both is essential for writing bug-free JavaScript and acing technical interviews. This guide breaks down every type of scope, then builds your understanding of closures from simple examples to advanced patterns.

You’ll need a solid grasp of JavaScript functions and variables (let, const, var) to follow along.

What Is Scope

Scope is the set of rules that determines where and how a variable can be looked up. When you reference a variable, JavaScript searches for it in the current scope first, then works outward through parent scopes until it finds the variable or reaches the global scope. If the variable isn’t found anywhere, you get a ReferenceError.

JavaScript has four types of scope: global, function, block, and module. Each creates a boundary that controls variable visibility.

Global Scope

Variables declared outside any function or block live in the global scope. They’re accessible everywhere in your code:

const API_URL = 'https://api.example.com'; // global

function fetchData() {
  console.log(API_URL); // accessible here
}

if (true) {
  console.log(API_URL); // accessible here too
}

In browsers, global var declarations become properties of the window object. let and const don’t:

var oldWay = 'attached';
let newWay = 'not attached';

console.log(window.oldWay); // "attached"
console.log(window.newWay); // undefined

Minimize global variables — they pollute the namespace, create naming collisions, and make code harder to reason about. Use modules instead.

Function Scope

Variables declared with var inside a function are scoped to that entire function, regardless of blocks:

function example() {
  var x = 10;
  if (true) {
    var y = 20; // still function-scoped!
    console.log(x); // 10
  }
  console.log(y); // 20 — accessible outside the if block
}
// console.log(x); // ReferenceError — not accessible outside function

This is why var is considered dangerous — it ignores block boundaries, leading to unexpected variable leaks.

Block Scope

Variables declared with let and const are scoped to the nearest enclosing block ({}):

if (true) {
  let blockScoped = 'only here';
  const alsoBlockScoped = 'and here';
}
// console.log(blockScoped); // ReferenceError

for (let i = 0; i < 3; i++) {
  // `i` only exists inside this loop
}
// console.log(i); // ReferenceError

Block scoping makes code predictable — variables exist only where you need them. This is one of the main reasons to always use let/const over var.

Temporal Dead Zone (TDZ)

console.log(a); // undefined (var is hoisted with undefined)
console.log(b); // ReferenceError (in TDZ)

var a = 1;
let b = 2;

let and const variables exist in the TDZ from the start of the block until their declaration is reached. Accessing them in the TDZ throws a ReferenceError. This catches bugs that var's hoisting would silently allow.

Lexical (Static) Scope

JavaScript uses lexical scoping — a function's scope is determined by where it's written in the source code, not where it's called from:

const name = 'global';

function outer() {
  const name = 'outer';
  
  function inner() {
    console.log(name); // "outer" — looks up to where inner was DEFINED
  }
  
  return inner;
}

const fn = outer();
fn(); // "outer" — not "global"

This is the foundation of closures. The inner function remembers the scope where it was created, not where it's executed.

The Scope Chain

When JavaScript looks up a variable, it follows the scope chain — from the current scope outward through each parent scope until it finds the variable or reaches global scope:

const a = 'global a';

function first() {
  const b = 'first b';
  
  function second() {
    const c = 'second c';
    
    function third() {
      console.log(a); // found in global scope
      console.log(b); // found in first()
      console.log(c); // found in second()
      // Scope chain: third -> second -> first -> global
    }
    third();
  }
  second();
}
first();

The scope chain is like a one-way ladder — you can look up (to outer scopes) but never down (into inner scopes from outside).

What Are Closures

A closure is created when a function retains access to variables from its outer (enclosing) scope, even after the outer function has returned. The inner function "closes over" the variables it references.

function createCounter() {
  let count = 0; // this variable is "closed over"
  
  return {
    increment() { return ++count; },
    decrement() { return --count; },
    getCount()  { return count; },
  };
}

const counter = createCounter();
counter.increment(); // 1
counter.increment(); // 2
counter.decrement(); // 1
counter.getCount();  // 1

// `count` is private — no way to access it directly

The count variable would normally be garbage collected when createCounter returns. But because the returned methods reference count, JavaScript keeps it alive in memory. This is a closure.

Practical Closure Examples

Function Factories

function multiplier(factor) {
  return (number) => number * factor;
}

const double = multiplier(2);
const triple = multiplier(3);
const toPercent = multiplier(100);

double(5);    // 10
triple(5);    // 15
toPercent(0.85); // 85

Private State

function createBankAccount(initialBalance) {
  let balance = initialBalance;
  const transactions = [];
  
  return {
    deposit(amount) {
      balance += amount;
      transactions.push({ type: 'deposit', amount, date: new Date() });
      return balance;
    },
    withdraw(amount) {
      if (amount > balance) throw new Error('Insufficient funds');
      balance -= amount;
      transactions.push({ type: 'withdraw', amount, date: new Date() });
      return balance;
    },
    getBalance() { return balance; },
    getHistory() { return [...transactions]; }, // return copy
  };
}

const account = createBankAccount(1000);
account.deposit(500);   // 1500
account.withdraw(200);  // 1300
// account.balance — undefined (truly private)

Event Handlers with State

function createClickTracker(elementId) {
  let clicks = 0;
  
  const element = document.getElementById(elementId);
  element.addEventListener('click', () => {
    clicks++;
    element.textContent = `Clicked ${clicks} times`;
  });
}

Memoization (Caching)

function memoize(fn) {
  const cache = new Map();
  
  return function(...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

const expensiveCalc = memoize((n) => {
  console.log('Computing...');
  return n * n;
});

expensiveCalc(5); // "Computing..." -> 25
expensiveCalc(5); // 25 (cached — no "Computing...")

Partial Application

function partial(fn, ...presetArgs) {
  return (...laterArgs) => fn(...presetArgs, ...laterArgs);
}

const add = (a, b, c) => a + b + c;
const add5 = partial(add, 5);
const add5and10 = partial(add, 5, 10);

add5(3, 7);    // 15
add5and10(20); // 35

The Classic Loop Problem

This is the most famous closure gotcha and a staple interview question:

// Bug with var
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// Prints: 3, 3, 3 (not 0, 1, 2!)

// Why? `var` is function-scoped, so all callbacks share
// the same `i`, which is 3 by the time they run.

Three solutions:

// Solution 1: Use let (creates new binding per iteration)
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// Prints: 0, 1, 2

// Solution 2: IIFE (pre-ES6 approach)
for (var i = 0; i < 3; i++) {
  ((j) => {
    setTimeout(() => console.log(j), 100);
  })(i);
}

// Solution 3: Bind the value
for (var i = 0; i < 3; i++) {
  setTimeout(console.log.bind(null, i), 100);
}

The Module Pattern

Before ES modules, closures powered the Module Pattern — a way to create encapsulated, reusable code:

const UserModule = (function() {
  // Private
  const users = [];
  let nextId = 1;
  
  function validate(user) {
    if (!user.name) throw new Error('Name required');
    if (!user.email) throw new Error('Email required');
  }
  
  // Public API
  return {
    add(user) {
      validate(user);
      users.push({ ...user, id: nextId++ });
    },
    find(id) {
      return users.find(u => u.id === id) || null;
    },
    count() {
      return users.length;
    },
  };
})();

The IIFE creates a closure that holds users, nextId, and validate privately. Only the returned object's methods are accessible from outside.

Closures and Memory

Closures keep referenced variables alive in memory. This is usually fine, but can cause memory leaks if you're not careful:

// Potential memory leak
function setupHandler() {
  const largeData = new Array(1000000).fill('x');
  
  return () => {
    // Only uses largeData.length, but the entire array is retained
    console.log(largeData.length);
  };
}

// Better — only close over what you need
function setupHandler() {
  const largeData = new Array(1000000).fill('x');
  const length = largeData.length; // extract what we need
  
  return () => {
    console.log(length); // largeData can be garbage collected
  };
}

Be especially careful with closures in event listeners — if you add listeners but never remove them, the closures (and everything they reference) stay in memory forever.

Closures in Interviews

Closures appear in almost every JavaScript interview. Common questions include the loop + setTimeout problem (covered above), implementing private variables, writing memoize or debounce functions, and explaining the output of nested function examples. The key insight interviewers look for: a closure gives a function persistent, private access to variables from its creation context.

// Classic interview question
function createFunctions() {
  const result = [];
  for (let i = 0; i < 5; i++) {
    result.push(() => i);
  }
  return result;
}
const fns = createFunctions();
console.log(fns[0]()); // 0
console.log(fns[3]()); // 3
// Works correctly because `let` creates a new binding per iteration

Best Practices

Always use let/const — block scoping prevents most scope-related bugs. Keep closures lean — only close over the data you actually need. Be mindful of memory — remove event listeners when components unmount. Use the module pattern or ES modules for encapsulation. Name your closures (even inline) for better stack traces. Understand that closures capture references to variables, not their values at the time of creation.

With scope and closures mastered, you're ready to tackle callbacks — functions passed to other functions — which is where closures really shine in practice. Continue to Callbacks.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *