Module 3 · Functions & Scope Mastery ⏱ 22 min

Callbacks & Closures

By the end of this lesson you will be able to:
  • Pass functions as callbacks to run code later
  • Preserve private state with lexical closures
  • Build factory functions that return configured behaviour
  • Recognise the classic loop-closure trap

So far your code has run top to bottom in one straight line. Real programs are messier: you click a button and something happens later, a timer fires after a delay, or you ask the browser to run a function once data arrives. In each case you hand a function to someone else and say "call this back when you're ready."

A callback is a function passed as an argument to another function so it can be invoked later. Because functions are first-class values in JavaScript — they can be stored in variables, passed around, and returned from other functions — callbacks are the glue that holds asynchronous code together.

Closures are the other half of the story. A closure lets a function remember the variables from its surrounding scope even after that scope has finished executing. Together, callbacks and closures are the foundation of privacy, factories, and most async patterns in JavaScript.

flowchart TD
  OUT["makeCounter()
count = 0"] --> RET["returns inner fn"]
  RET --> CLO["closure holds
count"]
  CLO --> CALL["next()
count += 1"]
  style OUT fill:#e0900b,color:#fff
  style CLO fill:#e0900b,color:#fff
A closure keeps its surrounding variables alive after the outer function returns.

Functions as values

Before you can pass a function, you have to believe it is just another value. These two snippets do the same job, but the second stores the function in a variable first:

function greet(name) {
  return "Hello, " + name;
}

const greet = function (name) {
  return "Hello, " + name;
};

Once it lives in a variable, you can hand it to another function. Array methods like map and filter already do this; setTimeout does it for timing, and event listeners do it for interaction. The pattern is everywhere.

When you pass a function, you are passing a reference to the function object, not a copy of its body. That means the receiving function can store it, call it multiple times, or ignore it entirely. Understanding that functions are ordinary objects — they just happen to be callable — makes callbacks feel less magical.

Callbacks in action: setTimeout runs a function later.
function sayLater(message) {
  setTimeout(() => {
    console.log(message);
  }, 10);
}
sayLater("Hello from the future");

function runTwice(fn) {
  fn();
  fn();
}
runTwice(() => console.log("tick"));

Closures: remembering where you came from

So how does that remembering actually work? When JavaScript creates a function, it bundles up the scope it was born in and carries that bundle along for the function's whole life. That bundle is the closure. The variables are not copied — the inner function holds a live reference to the very same bindings, which is why two closures over the same scope see each other's changes.

This is how factory functions work. You call an outer function, it sets up some local state, and it returns an inner function that still has access to that state:

function makeCounter() {
  let count = 0;
  return function () {
    count += 1;
    return count;
  };
}

Even though makeCounter finished running and its local scope technically ended, the returned function keeps a reference to count. Each call increments the same hidden variable.

The mechanism is straightforward: every function carries a hidden pointer to the lexical environment where it was created. When you call the function, JavaScript looks up variables in that environment if they are not found in the local scope. Because the environment persists as long as any function references it, the variables stay alive.

flowchart TD
  subgraph A["makeCounter() call A"]
    CA["count = 0"] --> FA["nextA()"]
  end
  subgraph B["makeCounter() call B"]
    CB["count = 0"] --> FB["nextB()"]
  end
  FA -->|"nextA()==> 1, 2, 3..."| RA["independent"]
  FB -->|"nextB()==> 1, 2..."| RB["independent"]
  style A fill:#1e293b,color:#fff
  style B fill:#1e293b,color:#fff
Each call to makeCounter creates a separate closure with its own count.
Two counters share the same code but independent hidden state.
function makeCounter() {
  let count = 0;
  return function () {
    count += 1;
    return count;
  };
}
const a = makeCounter();
const b = makeCounter();
console.log(a());
console.log(a());
console.log(b());
console.log(a());

Data privacy with closures

Because the inner function is the only thing that can touch the outer variable, closures give you data privacy without classes or special keywords. The variable cannot be read or written from outside — only the returned function can change it.

This pattern appears everywhere: event handlers that remember which button they belong to, timers that track how many times they fired, and module patterns that hide implementation details. It is one of the most powerful ideas in JavaScript, and it emerges naturally from the rules of lexical scope.

A practical example is a validator builder. Instead of writing one giant validation function, you write a factory that returns small, focused validators, each closing over its own threshold or message:

function makeMinValidator(min) {
  return function (value) {
    return value >= min;
  };
}
const isAdultAge = makeMinValidator(18);

Here min is locked inside the closure. The returned function is small, testable, and carries its configuration with it.

A validator factory: each closure carries its own threshold.
function makeMinValidator(min) {
  return function (value) {
    return value >= min;
  };
}
const isAdultAge = makeMinValidator(18);
const isPassingScore = makeMinValidator(60);
console.log(isAdultAge(21));
console.log(isAdultAge(16));
console.log(isPassingScore(75));
console.log(isPassingScore(45));
Exercise

What does this print? Each call to the returned function increments the same hidden counter.

function makeCounter() {
  let count = 0;
  return function () {
    count += 1;
    return count;
  };
}
const c = makeCounter();
console.log(c());
console.log(c());
Exercise

Write makeSecretHolder(secret) that returns an object with two methods: get() returns the secret, and set(newSecret) updates it. The secret must not be accessible from outside the object.

function makeSecretHolder(secret) {
  // return an object with get() and set(newSecret)
}
Exercise

Write makeMultiplier(factor) that returns a new function. The returned function takes one number and multiplies it by factor. So makeMultiplier(3)(4) should return 12.

function makeMultiplier(factor) {
  // return a function
}
Exercise

This loop is supposed to print 0, 1, 2 with a small delay, but it prints 3 three times because of a closure bug. Fix it by changing exactly one keyword.

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 10);
}
Exercise

Write makeAdder(initial) that returns a function. The returned function takes a number, adds it to a running total that starts at initial, and returns the new total. So const add = makeAdder(10); add(5); add(3); should return 18 on the second call.

function makeAdder(initial) {
  // return a function that adds to a running total
}

Recap

  • A callback is a function passed to another function to be called later.
  • A closure lets an inner function remember variables from its outer scope even after the outer function finishes.
  • Closures enable data privacy — hide state inside a factory and expose only the methods you want.
  • var in a loop creates one shared variable; let creates a fresh binding per iteration.
  • Factory functions that return configured closures are a powerful, testable pattern for building validators, counters, and stateful utilities.

Next you'll learn how to branch your code with conditionals and repeat work with loops.

Checkpoint quiz

What does a closure allow a function to do?

In the loop-closure trap, why does var behave differently from let?

Go deeper — technical resources