MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

How JavaScript Closures Work Under the Hood

Deep dive into JavaScript closures, lexical scoping, and practical patterns for data privacy and factory functions.

JavaScriptClosuresFundamentals

By MinhVo

Introduction

Closures are one of JavaScript's most powerful and often misunderstood features. They're not a special syntax or a library method—they emerge naturally from how JavaScript handles scope and function execution. Every JavaScript developer uses closures daily, often without realizing it. Event handlers, callbacks, module patterns, and React hooks all rely on closures to function correctly.

Understanding closures at a deep level transforms how you write JavaScript. You'll move from occasionally confused by unexpected variable values to confidently leveraging closures for data privacy, factory functions, memoization, and sophisticated state management patterns. This deep dive explores closures from the engine's perspective, examining how lexical scoping, the scope chain, and garbage collection interact to create this powerful abstraction.

We'll trace through JavaScript's execution model step by step, examining how the engine creates execution contexts, maintains scope chains, and determines which variables are accessible where. By understanding these mechanics, closures become predictable rather than mysterious.

JavaScript Fundamentals

Understanding Lexical Scoping: The Foundation

Before understanding closures, you must understand lexical scoping—the rule that determines how variable names are resolved in nested functions. JavaScript uses lexical (static) scoping, meaning the structure of the source code at write time determines scope, not the call stack at runtime.

How the Engine Creates Execution Contexts

When JavaScript encounters a function call, it creates an execution context containing three components: the variable environment, the lexical environment, and the this binding. The lexical environment is where closures live.

// Global execution context created first
const globalVar = 'I am global';
 
function outerFunction() {
  // outerFunction's execution context created
  const outerVar = 'I am outer';
 
  function innerFunction() {
    // innerFunction's execution context created
    const innerVar = 'I am inner';
 
    // When innerFunction accesses outerVar, the engine
    // looks up the scope chain: inner -> outer -> global
    console.log(outerVar);  // 'I am outer'
    console.log(globalVar); // 'I am global'
  }
 
  innerFunction();
}
 
outerFunction();

The Scope Chain

Every function in JavaScript has an internal property called [[Environment]] (in the ECMAScript specification) that references the lexical environment where the function was defined, not where it's called. This is the fundamental mechanism that makes closures work.

const x = 10;
 
function foo() {
  const y = 20;
 
  function bar() {
    const z = 30;
    console.log(x + y + z); // 60
  }
 
  return bar;
}
 
const myBar = foo();
myBar(); // 60 - bar still has access to y even after foo() returned

When foo() executes, it creates a new lexical environment containing y and the reference to bar. When foo() returns bar, the function object carries its [[Environment]] reference—the lexical environment from foo. Even after foo() completes, its lexical environment persists because bar holds a reference to it.

Closures: The Mechanism Explained

A closure is the combination of a function and the lexical environment in which it was declared. The function "closes over" the variables from its surrounding scope, maintaining access to them even after the outer function has returned.

Creating a Closure

function createCounter(initialValue = 0) {
  // These variables exist in createCounter's lexical environment
  let count = initialValue;
  const history = [];
 
  // This function closes over count and history
  return {
    increment() {
      count++;
      history.push({ action: 'increment', value: count, time: Date.now() });
      return count;
    },
    decrement() {
      count--;
      history.push({ action: 'decrement', value: count, time: Date.now() });
      return count;
    },
    getCount() {
      return count;
    },
    getHistory() {
      return [...history]; // Return copy to prevent mutation
    },
  };
}
 
const counter = createCounter(10);
counter.increment(); // 11
counter.increment(); // 12
counter.decrement(); // 11
console.log(counter.getCount()); // 11
console.log(counter.getHistory());
// [{ action: 'increment', value: 11, time: ... }, ...]

Each method in the returned object (increment, decrement, getCount, getHistory) forms a closure over the same lexical environment. They share access to count and history, creating a cohesive unit of encapsulated state.

Multiple Independent Closures

const counterA = createCounter(0);
const counterB = createCounter(100);
 
counterA.increment(); // 1
counterB.increment(); // 101
 
// Each counter has its own independent closure
// counterA's count is separate from counterB's count
console.log(counterA.getCount()); // 1
console.log(counterB.getCount()); // 101

Each call to createCounter creates a new lexical environment. The returned objects close over their respective environments, providing complete isolation between instances.

Architecture and Design Patterns

Data Privacy with Closures

JavaScript's class fields are public by default. Closures provide true privacy—variables that are inaccessible from outside the function scope.

function createBankAccount(owner, initialBalance = 0) {
  // Private state - completely inaccessible from outside
  let balance = initialBalance;
  const transactions = [];
  let frozen = false;
 
  // Private helper function
  function recordTransaction(type, amount) {
    transactions.push({
      type,
      amount,
      balance,
      timestamp: Date.now(),
      id: crypto.randomUUID(),
    });
  }
 
  // Public API
  return {
    get owner() {
      return owner;
    },
 
    get balance() {
      if (frozen) throw new Error('Account is frozen');
      return balance;
    },
 
    deposit(amount) {
      if (frozen) throw new Error('Account is frozen');
      if (amount <= 0) throw new Error('Deposit must be positive');
 
      balance += amount;
      recordTransaction('deposit', amount);
      return balance;
    },
 
    withdraw(amount) {
      if (frozen) throw new Error('Account is frozen');
      if (amount <= 0) throw new Error('Withdrawal must be positive');
      if (amount > balance) throw new Error('Insufficient funds');
 
      balance -= amount;
      recordTransaction('withdrawal', amount);
      return balance;
    },
 
    getStatement() {
      return [...transactions]; // Return copy
    },
 
    freeze() {
      frozen = true;
    },
 
    unfreeze() {
      frozen = false;
    },
  };
}
 
const account = createBankAccount('Alice', 1000);
account.deposit(500);    // 1500
account.withdraw(200);   // 1300
console.log(account.balance); // 1300
 
// These are truly private - no way to access from outside
// console.log(account.balance)  // Works through getter
// account.balance = 999999       // Creates new property, doesn't affect private state
// account.frozen                 // undefined - not exposed

Factory Functions

Closures enable factory functions that produce specialized functions from general-purpose configurations.

function createValidator(rules) {
  // rules is closed over by the returned function
  return function validate(data) {
    const errors = {};
 
    for (const [field, fieldRules] of Object.entries(rules)) {
      const value = data[field];
 
      for (const rule of fieldRules) {
        if (rule.required && (value === undefined || value === null || value === '')) {
          errors[field] = errors[field] || [];
          errors[field].push(`${field} is required`);
          continue;
        }
 
        if (value !== undefined && rule.min !== undefined && value < rule.min) {
          errors[field] = errors[field] || [];
          errors[field].push(`${field} must be at least ${rule.min}`);
        }
 
        if (value !== undefined && rule.max !== undefined && value > rule.max) {
          errors[field] = errors[field] || [];
          errors[field].push(`${field} must be at most ${rule.max}`);
        }
 
        if (value !== undefined && rule.pattern && !rule.pattern.test(value)) {
          errors[field] = errors[field] || [];
          errors[field].push(rule.message || `${field} is invalid`);
        }
      }
    }
 
    return {
      valid: Object.keys(errors).length === 0,
      errors,
    };
  };
}
 
const validateUser = createValidator({
  name: [
    { required: true },
    { min: 2, max: 100 },
  ],
  email: [
    { required: true },
    { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Invalid email format' },
  ],
  age: [
    { min: 0, max: 150 },
  ],
});
 
const result = validateUser({ name: 'Alice', email: 'alice@example.com', age: 30 });
console.log(result); // { valid: true, errors: {} }

Memoization with Closures

Closures enable memoization—caching function results based on arguments to avoid redundant computation.

function memoize(fn) {
  const cache = new Map();
  let hits = 0;
  let misses = 0;
 
  const memoized = function (...args) {
    const key = JSON.stringify(args);
 
    if (cache.has(key)) {
      hits++;
      return cache.get(key);
    }
 
    misses++;
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
 
  memoized.cache = cache;
  memoized.stats = () => ({ hits, misses, size: cache.size });
  memoized.clear = () => {
    cache.clear();
    hits = 0;
    misses = 0;
  };
 
  return memoized;
}
 
// Memoized fibonacci
const fibonacci = memoize(function fib(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
});
 
fibonacci(40); // Fast - uses cached results
console.log(fibonacci.stats()); // { hits: 39, misses: 41, size: 41 }

Debouncing and Throttling

Event handlers frequently use closures for debouncing and throttling, maintaining timer references across multiple function calls.

function debounce(fn, delay) {
  let timeoutId; // Closed over by the returned function
 
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}
 
function throttle(fn, limit) {
  let inThrottle = false;
  let lastArgs = null;
 
  return function (...args) {
    if (!inThrottle) {
      fn.apply(this, args);
      inThrottle = true;
 
      setTimeout(() => {
        inThrottle = false;
        if (lastArgs) {
          fn.apply(this, lastArgs);
          lastArgs = null;
        }
      }, limit);
    } else {
      lastArgs = args;
    }
  };
}
 
// Usage
const handleSearch = debounce(async (query) => {
  const results = await fetchSearchResults(query);
  updateUI(results);
}, 300);
 
const handleScroll = throttle(() => {
  const scrollY = window.scrollY;
  updateScrollIndicator(scrollY);
}, 100);
 
document.getElementById('search').addEventListener('input', (e) => {
  handleSearch(e.target.value);
});
 
window.addEventListener('scroll', handleScroll);

Step-by-Step Implementation

Event Handler Patterns

Closures are fundamental to event-driven programming in JavaScript. Event handlers close over the scope where they're defined, maintaining access to relevant state.

function createTodoList(containerElement) {
  // Private state closed over by event handlers
  const todos = [];
  let nextId = 1;
 
  function render() {
    containerElement.innerHTML = todos.map(todo => `
      <div class="todo ${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
        <input type="checkbox" ${todo.completed ? 'checked' : ''} />
        <span>${todo.text}</span>
        <button class="delete">Ă—</button>
      </div>
    `).join('');
 
    // Attach event handlers that close over todos
    containerElement.querySelectorAll('.todo').forEach(todoEl => {
      const id = parseInt(todoEl.dataset.id);
 
      todoEl.querySelector('input').addEventListener('change', () => {
        const todo = todos.find(t => t.id === id);
        if (todo) {
          todo.completed = !todo.completed;
          render(); // Re-render after state change
        }
      });
 
      todoEl.querySelector('.delete').addEventListener('click', () => {
        const index = todos.findIndex(t => t.id === id);
        if (index > -1) {
          todos.splice(index, 1);
          render();
        }
      });
    });
  }
 
  return {
    add(text) {
      todos.push({ id: nextId++, text, completed: false });
      render();
    },
 
    getTodos() {
      return [...todos];
    },
 
    getStats() {
      return {
        total: todos.length,
        completed: todos.filter(t => t.completed).length,
        pending: todos.filter(t => !t.completed).length,
      };
    },
  };
}
 
const todoList = createTodoList(document.getElementById('app'));
todoList.add('Learn closures');
todoList.add('Build something awesome');

Module Pattern with Closures

The revealing module pattern uses closures to create modules with public and private members.

const UserService = (function () {
  // Private state and methods
  let currentUser = null;
  const cache = new Map();
 
  async function fetchUser(id) {
    if (cache.has(id)) {
      return cache.get(id);
    }
 
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) throw new Error('Failed to fetch user');
 
    const user = await response.json();
    cache.set(id, user);
    return user;
  }
 
  function clearCache() {
    cache.clear();
  }
 
  // Public API
  return {
    async login(email, password) {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });
 
      if (!response.ok) throw new Error('Login failed');
 
      currentUser = await response.json();
      return currentUser;
    },
 
    logout() {
      currentUser = null;
      clearCache();
    },
 
    getCurrentUser() {
      return currentUser;
    },
 
    async getUser(id) {
      return fetchUser(id);
    },
 
    isAuthenticated() {
      return currentUser !== null;
    },
  };
})();

JavaScript Closures Patterns

Real-World Use Cases

Use Case 1: React Hooks

React hooks are closures that maintain state across renders. The useState hook returns a state value and a setter function that close over the component's fiber node.

// Simplified implementation of useState
let currentComponent = null;
let hookIndex = 0;
 
function useState(initialValue) {
  const component = currentComponent;
  const index = hookIndex++;
 
  if (!component.hooks[index]) {
    component.hooks[index] = initialValue;
  }
 
  const setState = (newValue) => {
    // Closure captures component and index
    component.hooks[index] = newValue;
    scheduleRerender(component);
  };
 
  return [component.hooks[index], setState];
}

Use Case 2: Connection Pooling

Database connection pools use closures to manage state across multiple function calls, maintaining connection availability and usage statistics.

function createConnectionPool(connectionString, maxSize = 10) {
  const pool = [];
  const activeConnections = new Set();
  let created = 0;
 
  return {
    async acquire() {
      if (pool.length > 0) {
        const conn = pool.pop();
        activeConnections.add(conn);
        return conn;
      }
 
      if (created < maxSize) {
        const conn = await createConnection(connectionString);
        created++;
        activeConnections.add(conn);
        return conn;
      }
 
      // Wait for a connection to be released
      return new Promise((resolve) => {
        const checkPool = () => {
          if (pool.length > 0) {
            const conn = pool.pop();
            activeConnections.add(conn);
            resolve(conn);
          } else {
            setTimeout(checkPool, 10);
          }
        };
        checkPool();
      });
    },
 
    release(conn) {
      activeConnections.delete(conn);
      pool.push(conn);
    },
 
    stats() {
      return {
        idle: pool.length,
        active: activeConnections.size,
        total: created,
      };
    },
  };
}

Use Case 3: Configuration Management

Application configuration uses closures to create environment-specific settings that are accessible throughout the application.

function createConfig(env) {
  const configs = {
    development: {
      apiUrl: 'http://localhost:3000',
      debug: true,
      logLevel: 'verbose',
    },
    production: {
      apiUrl: 'https://api.example.com',
      debug: false,
      logLevel: 'error',
    },
    test: {
      apiUrl: 'http://localhost:3001',
      debug: true,
      logLevel: 'silent',
    },
  };
 
  const config = configs[env] || configs.development;
 
  return {
    get(key) {
      return config[key];
    },
    getAll() {
      return { ...config };
    },
    isProduction() {
      return env === 'production';
    },
  };
}
 
const config = createConfig(process.env.NODE_ENV);

Best Practices for Production

  1. Understand closure scope is lexical, not dynamic: Closures capture variables by reference from where the function is defined, not where it's called. This is the most common source of confusion with closures.

  2. Be mindful of memory leaks: Closures keep references to their outer scope. If a closure captures a large object that's no longer needed, the garbage collector can't free it. Nullify references when they're no longer needed.

  3. Use closures for true privacy: Unlike JavaScript's # private fields (which are syntactic sugar with limitations), closures provide genuine privacy that can't be circumvented.

  4. Avoid closures in loops without let: The classic loop-with-closure bug occurs when a var-declared loop variable is captured by closures, causing all closures to share the same variable.

// Classic mistake
for (var i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 100);
}
// Output: 5, 5, 5, 5, 5
 
// Correct with let
for (let i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 100);
}
// Output: 0, 1, 2, 3, 4
 
// Alternative with IIFE
for (var i = 0; i < 5; i++) {
  (function (j) {
    setTimeout(() => console.log(j), 100);
  })(i);
}
  1. Prefer closures over classes for simple state: For objects with a small number of methods and private state, closures are often simpler and more memory-efficient than class instances.

  2. Use WeakRef for caches: When caching objects that should be garbage-collected when no other references exist, use WeakRef and WeakMap to prevent memory leaks.

  3. Document closure dependencies: When a function closes over external state, document which variables it depends on. This makes the code easier to understand and refactor.

  4. Test closure behavior thoroughly: Closures can introduce subtle bugs when the captured state changes unexpectedly. Test both the happy path and edge cases where closure state is mutated.

Common Pitfalls and Solutions

PitfallImpactSolution
Loop variable capture with varAll closures share final loop valueUse let or IIFE to create new scope per iteration
Memory leaks from large closuresApplication memory grows unboundedNullify references to large objects when done
Unexpected this bindingMethods lose their contextUse arrow functions or .bind()
Stale closure values in ReactHooks return outdated stateUse functional updates or refs for mutable values
Closure over module-level variablesShared state between test runsCreate fresh closures in beforeEach
Circular references in closuresPrevents garbage collectionBreak circular references explicitly
Closure confusion with asyncAsync callbacks see stale variablesUse let or create new scope for each async operation

Performance Optimization

Closure vs Object Performance

Closures and objects have different performance characteristics that matter in hot paths.

// Closure-based approach - private state in closure
function createCounterClosure() {
  let count = 0;
  return {
    increment: () => ++count,
    getCount: () => count,
  };
}
 
// Class-based approach - private field
class CounterClass {
  #count = 0;
  increment() { return ++this.#count; }
  getCount() { return this.#count; }
}
 
// Benchmark
const start1 = performance.now();
const counter1 = createCounterClosure();
for (let i = 0; i < 1_000_000; i++) counter1.increment();
console.log(`Closure: ${performance.now() - start1}ms`);
 
const start2 = performance.now();
const counter2 = new CounterClass();
for (let i = 0; i < 1_000_000; i++) counter2.increment();
console.log(`Class: ${performance.now() - start2}ms`);
// Problem: Closure keeps reference to entire outer scope
function processData(largeDataset) {
  const summary = computeSummary(largeDataset); // Small result
 
  // This closure keeps largeDataset in memory
  return function getSummary() {
    return summary;
  };
}
 
// Solution: Only capture what you need
function processData(largeDataset) {
  const summary = computeSummary(largeDataset);
 
  // largeDataset can be garbage collected
  largeDataset = null;
 
  return function getSummary() {
    return summary;
  };
}

Comparison with Alternatives

FeatureClosuresClassesModulesWeakMap
PrivacyTrue privacy# private fieldsModule scopeObject-keyed
MemoryPer-instancePer-instanceSharedPer-object
PerformanceGoodExcellentExcellentGood
InheritanceManual delegationextendsN/AN/A
DebuggingHarder (no names)EasierEasierHarder
SerializationCannot serializeCan serializeN/ACannot serialize

Advanced Patterns

Closure-Based State Machines

function createStateMachine(initialState, transitions) {
  let state = initialState;
  const listeners = new Set();
 
  return {
    getState: () => state,
 
    transition(action) {
      const nextState = transitions[state]?.[action];
      if (!nextState) {
        throw new Error(`Invalid transition: ${state} -> ${action}`);
      }
 
      const prevState = state;
      state = nextState;
      listeners.forEach(listener => listener(state, prevState, action));
    },
 
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener); // Return unsubscribe closure
    },
 
    canTransition(action) {
      return !!transitions[state]?.[action];
    },
  };
}
 
const trafficLight = createStateMachine('red', {
  red: { next: 'green' },
  green: { next: 'yellow' },
  yellow: { next: 'red' },
});
 
trafficLight.subscribe((state, prev) => {
  console.log(`Light changed: ${prev} → ${state}`);
});
 
trafficLight.transition('next'); // red -> green
trafficLight.transition('next'); // green -> yellow

Partial Application and Currying

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }
 
    return function (...moreArgs) {
      // Closure captures args and moreArgs
      return curried.apply(this, [...args, ...moreArgs]);
    };
  };
}
 
const add = curry((a, b, c) => a + b + c);
 
const add10 = add(10);
const add10And20 = add10(20);
 
add10And20(30); // 60
add(1)(2)(3);   // 6
add(1, 2)(3);   // 6

Future Outlook

Closures remain fundamental to JavaScript and are unlikely to be superseded. The TC39 proposal for explicit resource management (using keyword) adds cleanup semantics that complement closure patterns. Records and Tuples (stage 2) will provide immutable data structures that interact cleanly with closures.

The rise of React Server Components introduces new closure considerations—closures that only run on the server cannot reference client-only APIs. Understanding closure scope becomes even more important as JavaScript spans server and client environments.

Conclusion

Closures are not a feature you learn once and master—they're a fundamental mechanism that underlies nearly every JavaScript pattern you use daily. Understanding closures at the engine level—how execution contexts, lexical environments, and scope chains interact—transforms them from mysterious to predictable.

The key insights are: closures capture variables by reference from their lexical scope, each function call creates a new lexical environment, and closures prevent garbage collection of captured variables. Armed with this understanding, you can leverage closures for data privacy, factory functions, memoization, and sophisticated state management patterns.

Use closures deliberately: prefer them for simple state encapsulation over classes, leverage them for partial application and function composition, and be mindful of their memory implications. The patterns in this guide—validators, debouncing, memoization, state machines—are just the beginning. Closures enable an entire programming paradigm within JavaScript that, once mastered, makes you a significantly more effective developer.