Skip to main content

🔭 Function Scope and Execution Context

Every variable in JavaScript lives somewhere, and every function runs inside an environment that decides what it can see. Master scope, the scope chain, the call stack, this, and closures, and JavaScript's most confusing bugs turn into predictable rules.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Describe global, function, and block scope and how the scope chain resolves a variable
  • Explain hoisting and the temporal dead zone for let/const
  • Trace an execution context through the call stack
  • Predict the value of this under the five binding rules
  • Define a closure and use it for private state and function factories

Estimated Time: 40–50 minutes  •  Difficulty: Intermediate

Hands-on: Build a private counter with a closure and explain why it works.

In This Lesson

Scope & Context Overview

Two related ideas govern how JavaScript runs your code:

  • Scope answers "which variables can this code see?"
  • Execution context answers "what is the environment this code runs in — its variables, its scope chain, and its this?"
flowchart TD A[JavaScript Execution Model] A --> B[Scope] A --> C[Execution Context] B --> D[Variable visibility] B --> E[Lexical environment] C --> F[Creation phase] C --> G[Execution phase] C --> H["'this' binding"]
💡 The office-building analogy: Scopes are floors and rooms, each with its own equipment (variables). Execution contexts are the active work sessions happening in those rooms. The call stack is the elevator log of which floors you've stepped into. And a closure is a key you carry out of a room that still opens it later.

Types of Scope

Global scope

Variables declared outside any function or block are global and visible everywhere. Keep these to a minimum — global state is where bugs breed.

const appName = 'MyApp'; // global

function showInfo() {
  console.log(appName);  // reachable from inside
}
showInfo(); // "MyApp"

Function scope

Anything declared inside a function is invisible outside it. Variables declared with var are scoped to the whole function.

function calculateTotal(items) {
  let total = 0;
  for (let i = 0; i < items.length; i++) {
    total += items[i].price;
  }
  return total;
}
// console.log(total); // ReferenceError — total is not visible here

Block scope

Variables declared with let and const are confined to the nearest pair of curly braces — a loop body, an if, or a standalone block. (This is a key difference from var, which ignores blocks.)

function processUsers(users) {
  const valid = [];
  for (let i = 0; i < users.length; i++) {
    const user = users[i];         // block-scoped to the loop
    if (user.active) {
      let message = `Processing ${user.name}`; // block-scoped to the if
      console.log(message);
      valid.push(user);
    }
    // console.log(message); // ReferenceError — out of the if block
  }
  return valid;
}

📖 Lexical scope

JavaScript uses lexical (static) scoping: a function's scope is fixed by where it is written in the source, not where it is called. An inner function can always see the variables of the functions it is nested inside.

The Scope Chain

When you use a variable, JavaScript looks for it in the current scope. If it isn't there, it looks in the enclosing scope, then the next one out, all the way to global. This series of links is the scope chain. The search only ever goes outward, never inward.

Nested scopes and the scope chain Global scope contains an outer function scope, which contains an inner function scope. Inner code can read variables from all enclosing scopes but outer code cannot read inner variables. Global scope const globalVar outer() scope const outerVar inner() scope const innerVar reads innerVar ✓ reads outerVar ✓ reads globalVar ✓ cannot read innerVar ✗
Figure 1 — Variable lookup climbs outward through the scope chain; enclosing scopes cannot reach into nested ones.
const globalV = 'global';

function outer() {
  const outerV = 'outer';
  function inner() {
    const innerV = 'inner';
    console.log(innerV, outerV, globalV); // all reachable
  }
  inner();
  // console.log(innerV); // ReferenceError — cannot look inward
}
outer();

Variable shadowing

If an inner scope declares a variable with the same name as an outer one, the inner one shadows the outer — the outer stays untouched, it's just hidden while inside.

const value = 'global';
function outer() {
  const value = 'outer';   // shadows global
  function inner() {
    const value = 'inner'; // shadows outer
    console.log(value);    // "inner"
  }
  inner();
  console.log(value);      // "outer"
}
outer();
console.log(value);        // "global"

Hoisting & the TDZ

Hoisting is JavaScript registering declarations at the top of their scope during the creation phase, before any code runs. Different declarations hoist differently:

DeclarationHoisted?Usable before its line?
Function declarationFully (name + body)Yes
varName only, value = undefinedYes, but value is undefined
let / constName only, uninitializedNo — throws (Temporal Dead Zone)
// var: hoisted but undefined
console.log(a); // undefined
var a = 'hi';

// function declaration: fully hoisted
sayHi();        // "Hello!" works
function sayHi() { console.log('Hello!'); }

// let/const: in the Temporal Dead Zone until declared
// console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 'there';

⚠️ The Temporal Dead Zone (TDZ)

Between the top of a block and the line where a let/const is declared, the variable exists but cannot be touched. This is a feature: it catches use-before-declare mistakes that var would silently hide with undefined.

Execution Context & Call Stack

An execution context is the environment a piece of code runs in. There's one global context, and a new function context is created on every call. Each context is built in two phases:

  1. Creation phase — set up the variable environment, wire the scope chain, and determine this.
  2. Execution phase — run the code line by line, assigning values and calling functions.

JavaScript tracks these contexts with the call stack: calling a function pushes a context on top; returning pops it off. The context on top is the one currently running.

flowchart TD A["printSquare(4) pushed"] --> B["square(4) pushed"] B --> C["multiply(4,4) pushed"] C --> D["multiply returns 16 → popped"] D --> E["square returns 16 → popped"] E --> F["printSquare logs 16 → popped"]
function multiply(a, b) { return a * b; }
function square(n)      { return multiply(n, n); }
function printSquare(n) { console.log(square(n)); }

printSquare(4); // 16
// Stack grows: global → printSquare → square → multiply
// then unwinds as each returns

⚠️ Stack overflow

Recursion with no base case keeps pushing contexts until the stack's limit is hit:

function boom() { boom(); } // RangeError: Maximum call stack size exceeded

The this Keyword

The value of this is decided by how a function is called, not where it's written (with one exception — arrow functions). There are five rules:

1. Default binding

function show() { console.log(this); }
show(); // global object (or undefined in strict mode)

2. Implicit binding (method call)

const user = {
  name: 'Alice',
  greet() { console.log(this.name); }
};
user.greet();               // "Alice" — this is user

const fn = user.greet;
fn();                       // undefined — now a plain call

3. Explicit binding (call / apply / bind)

function introduce(greeting) { console.log(`${greeting}, I'm ${this.name}`); }
const alice = { name: 'Alice' };

introduce.call(alice, 'Hello');   // Hello, I'm Alice
introduce.apply(alice, ['Hi']);   // Hi, I'm Alice
const boundIntro = introduce.bind(alice);
boundIntro('Hey');                // Hey, I'm Alice

4. Constructor binding (new)

function User(name) { this.name = name; }
const u = new User('Alice');
console.log(u.name); // "Alice" — this is the new instance

5. Arrow functions (lexical this)

Arrow functions ignore all of the above and inherit this from where they're defined — the fix for callbacks that lose their object:

const team = {
  name: 'Awesome Team',
  members: ['Alice', 'Bob'],
  showBroken() {
    this.members.forEach(function (m) {
      console.log(`${m} in ${this.name}`); // this.name is undefined
    });
  },
  showFixed() {
    this.members.forEach(m => {
      console.log(`${m} in ${this.name}`); // this is team ✓
    });
  }
};
team.showFixed(); // "Alice in Awesome Team", "Bob in Awesome Team"

💡 Fixing "lost this" three ways

Inside an async callback you can preserve this with (1) an arrow function, (2) saving const self = this;, or (3) .bind(this). The arrow function is the modern default.

Closures

A closure is formed when an inner function keeps access to variables from its outer function even after that outer function has returned. The inner function "remembers" the environment it was born in. Closures are a direct consequence of lexical scoping plus the scope chain.

flowchart TD A[Outer function runs] --> B[Inner function defined] B --> C[Outer function returns] C --> D[Inner function still reads
the outer variables]
function createGreeting(greeting) {
  return function (name) {
    return `${greeting}, ${name}!`; // remembers `greeting`
  };
}

const sayHello = createGreeting('Hello');
const sayHi = createGreeting('Hi');
console.log(sayHello('Alice')); // "Hello, Alice!"
console.log(sayHi('Bob'));      // "Hi, Bob!"

Practical use 1: private state

function createCounter() {
  let count = 0; // private — unreachable from outside
  return {
    increment() { return ++count; },
    decrement() { return --count; },
    getValue()  { return count; }
  };
}

const counter = createCounter();
counter.increment(); counter.increment();
console.log(counter.getValue()); // 2
console.log(counter.count);      // undefined — encapsulated

Practical use 2: function factories

function createMultiplier(factor) {
  return n => n * factor;
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5), triple(5)); // 10 15

⚠️ The classic loop-and-closure gotcha

With var, every closure in a loop shares one variable and ends up seeing its final value. Fix it by using let, which creates a fresh binding per iteration:

// ❌ var → all print 3
const bad = [];
for (var i = 0; i < 3; i++) bad.push(() => console.log(i));
bad.forEach(fn => fn()); // 3, 3, 3

// ✅ let → fresh binding each time
const good = [];
for (let j = 0; j < 3; j++) good.push(() => console.log(j));
good.forEach(fn => fn()); // 0, 1, 2

Hands-on Exercise

🏋️ A Private Bank Account

Objective: Use a closure to protect data that can only be changed through a controlled API.

Instructions:

  1. Write createAccount(initial) that keeps a private balance.
  2. Return deposit(amount), withdraw(amount), and getBalance().
  3. Reject withdrawals larger than the balance and negative amounts.
  4. Prove from the outside that balance cannot be read or set directly.
💡 Hint

Declare let balance = initial; inside createAccount. Because only the returned methods close over it, nothing else in your program can reach that variable.

✅ Sample solution
function createAccount(initial = 0) {
  let balance = initial; // private via closure

  return {
    deposit(amount) {
      if (amount <= 0) return 'Amount must be positive';
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      if (amount <= 0) return 'Amount must be positive';
      if (amount > balance) return 'Insufficient funds';
      balance -= amount;
      return balance;
    },
    getBalance() { return balance; }
  };
}

const acct = createAccount(100);
console.log(acct.deposit(50));   // 150
console.log(acct.withdraw(30));  // 120
console.log(acct.withdraw(999)); // "Insufficient funds"
console.log(acct.getBalance());  // 120
console.log(acct.balance);       // undefined — truly private

Why it works: each returned method closes over the same balance. The variable outlives the createAccount call because those methods still reference it, but no outside code has a name for it.

🎯 Quick Quiz

Question 1: When JavaScript can't find a variable in the current scope, where does it look next?

Question 2: What determines the value of this in a regular function?

Question 3: Why can a closure still read a variable after the outer function has returned?

Summary & What's Next

🎉 Key Takeaways

  • JavaScript has global, function, and block scope; the scope chain resolves names outward only.
  • Hoisting lifts declarations; let/const sit in the temporal dead zone until declared.
  • Each call creates an execution context tracked on the call stack.
  • this depends on the call site for regular functions; arrow functions inherit it lexically.
  • Closures remember their birth scope — the basis of private state and function factories.

📚 Further Reading

🚀 What's Next?

You've now covered how to declare functions, feed them data, and reason about where their variables live. Time to put it all together in the Weekend Project: JavaScript Fundamentals, where you'll build something real from these building blocks.