Mastering JavaScript Closures: A Deep Dive ✨

Bhaskar Pandey
11 min readJun 12, 2024

--

JavaScript closures are one of the most stubborn concepts to grasp. You may use closures in your code a lot without realizing that you are using them.

A diagram showing visually what closures are…
JavaScript closures

A lot of credit goes to Will Sentance and Frontend master teaching the course JavaScript: The Hard Parts by platform.

To understand closures, we first need to understand execution context.

What is execution context?

  • Execution context: In JavaScript, the term “execution context” refers to the environment in which a piece of code is executed. It consists of variables, functions, objects, and other data that a piece of code has access to during runtime.
Execution context visually
Execution context

Types of Execution context:

  • Global Execution Context: This is the default or outermost execution context. It’s created when your script is executed, and it’s where code that’s not inside any function is executed. In a web browser, the global execution context is associated with the window object.
  • Function Execution Context: Each time a function is called, a new execution context is created for that function. This context includes all the variables, objects, and functions defined within the function. When the function finishes executing, its execution context is popped off the execution stack (call stack).

Function Execution Overview

When a function is called, JavaScript performs a series of well-defined steps to ensure the function executes correctly. Here’s a detailed breakdown of the process:

  1. Start: Creation of the Execution Context
  • Execution Context: When a function is called, JavaScript creates a new execution context specifically for that function. This context contains all the necessary parameters, variables, and the function’s code.
  • Call Stack: This new execution context is then pushed onto the call stack, which is a mechanism JavaScript uses to manage function invocation order.
  1. Execution: Line-by-Line Processing
  • Line-by-Line Execution: Within the function’s execution context, JavaScript processes the function's code line by line. This involves handling variables, performing calculations, and executing any other operations defined within the function.
  • Scope Chain and Closures: During execution, the function has access to its own local variables, the parameters passed to it, and any variables from its outer scope, thanks to closures.
  1. End: Cleanup and Context Removal
  • Popping off the Call Stack: Once the function has completed execution, its execution context is popped off the call stack. This means the context is removed, and JavaScript returns to executing the code from where the function was called.
  • Local Variable Cleanup: The local variables and parameters defined within the function’s execution context are destroyed, freeing up memory and resources.

An example for clarity:

let’s break down the execution of the following function in a detailed manner:

1: const num = 3;
2: function multiplyBy2(inputNumber) {
3: const result = inputNumber * 2;
4: return result;
5: }
6: const output = multiplyBy2(num);
7: const newOutput = multiplyBy2(10);
8: console.log(output);
9: console.log(newOutput);
  1. Line 1: We declare a new constant variable num in the global execution context and assign it the number 3.
  2. Lines 2–5: Here, we declare a new function named multiplyBy2. We create a new variable named multiplyBy2 in the global execution context, and we assign a function definition to it. This function takes a parameter inputNumber and contains code to multiply it by 2, store the result in a variable result, and return result. The code inside the function is not evaluated yet, just stored for future use.
  3. Line 6: This line looks simple but has a lot happening. First, we declare a new variable in the global execution context and label it output. Initially, this variable is undefined.
  4. Line 6 (continued): We are about to assign a new value to output by calling the function multiplyBy2. The JavaScript engine looks up multiplyBy2 in the global execution context, finds the function definition, and prepares to execute it. The variable num is passed as an argument. The engine finds num in the global execution context with the value 3 and passes this value to the function.
  5. New execution context for multiplyBy2: A new local execution context is created, which we’ll call the multiplyBy2 execution context. This context is pushed onto the call stack. The first thing to do here is to handle the function parameters. A new variable inputNumber is declared in this local execution context, and it is assigned the value 3 (the argument passed).
  6. Line 3: Within the local execution context, we declare a new constant variable result. Initially, result is undefined. Then, the expression inputNumber * 2 is evaluated. The engine looks up inputNumber, finds it in the local execution context with the value 3, and multiplies it by 2, resulting in 6. This value is then assigned to result.
  7. Line 4: We return the value of result, which is 6. The local execution context ends here. Variables inputNumber and result are destroyed. The context is popped off the call stack, and the return value (6) is returned to the calling context (global execution context).
  8. Line 6 (continued): The returned value (6) is assigned to output.
  9. Line 7: Similarly, we declare another variable newOutput in the global execution context and assign it the result of calling multiplyBy2 with the argument 10. The engine again finds multiplyBy2, calls it, and passes 10 as the argument.
  10. New execution context for multiplyBy2 (second call): A new local execution context is created and pushed onto the call stack. The parameter inputNumber is assigned the value 10.
  11. Line 3: In this local execution context, a new constant variable result is declared and initialized to undefined. The expression inputNumber * 2 is evaluated. The engine finds inputNumber with the value 10, multiplies it by 2, resulting in 20, and assigns this value to result.
  12. Line 4: The function returns result, which is 20. The local execution context ends, variables inputNumber and result are destroyed, and the context is popped off the call stack. The return value (20) is returned to the calling context.
  13. Line 7 (continued): The returned value (20) is assigned to newOutput.
  14. Line 8: We log the value of output to the console. The console displays 6.
  15. Line 9: We log the value of newOutput to the console. The console displays 20.

This detailed explanation illustrates how JavaScript handles variable declarations, function definitions, and the creation and destruction of execution contexts. We haven’t touched on closures yet, but this sets the stage for understanding them by showing the basics of how functions and their scopes work.

you can use something like this to see code execution: JavaScript Visualizer (ui.dev)

Let’s run some code:

As we have learnt about how a function executes from the previous example. Let’s take a look a this function and try to figure out what will happen.

1: function createCounter() {
2: let counter = 0
3: const myFunction = function() {
4: counter = counter + 1
5: return counter
6: }
7: return myFunction
8: }
9: const increment = createCounter()
10: const c1 = increment()
11: const c2 = increment()
12: const c3 = increment()
13: console.log('example increment', c1, c2, c3)

let’s break down the execution of the createCounter function in detail, step by step:

  1. Lines 1–8: We start by defining a function named createCounter. In the global execution context, we declare a new variable createCounter and assign it the function definition that spans lines 1 through 8. The code inside the function is not executed yet, just stored for future use.
  2. Line 9: We declare a new variable increment in the global execution context. Initially, this variable is undefined.
  3. Line 9 (continued): We call the createCounter function and assign its returned value to increment. The JavaScript engine looks up createCounter in the global execution context, finds the function definition, and prepares to execute it.
  4. New execution context for createCounter: A new local execution context is created, named the createCounter execution context. This context is pushed onto the call stack.
  5. Line 2: Inside this local context, we declare a variable counter and initialize it to 0.
  6. Lines 3–6: We declare a new constant variable myFunction and assign it a function definition. This function will increment the counter variable and return its value.
  7. Line 7: The myFunction function is returned by createCounter. The local execution context for createCounter ends, and the context is popped off the call stack. The variables counter and myFunction no longer exist in this context.
  8. Line 9 (continued): The returned function (myFunction along with its closure) is assigned to the variable increment. Now, increment contains the function definition that was stored in myFunction.
  9. Line 10: We declare a new variable c1 in the global execution context.
  10. Line 10 (continued): We call the increment function and assign its return value to c1. The JavaScript engine looks up increment, finds the function definition, and prepares to execute it.
  11. New execution context for increment: A new local execution context is created for this function call, named the increment execution context. This context is pushed onto the call stack.
  12. Line 4: Inside this local context, we update counter by incrementing it by 1. We look for counter in the local execution context but don’t find it. So, we look in the global context and Still no counter. JavaScript interprets this as counter = undefined + 1 and creates a new local variable counter with the value 1 (since undefined acts like 0 here).
  13. Line 5: The function returns the value of counter, which is 1. This ends the local execution context, and counter is destroyed.
  14. Line 10 (continued): The returned value (1) is assigned to c1.
  15. Line 11: We repeat steps 9–14 for c2. The increment function is called again, This time again 1 is returned and assigned to c2.
  16. Line 12:We repeat steps 9–14 for c3. The increment function is called again, This time again 1 is returned and assigned to c3.
  17. Line 13: Finally, we log the values of c1, c2, and c3 to the console. The console displays example increment 1 1 and 1.

Try running the code yourself and see what happens. You might expect it to log 1, 1, and 1 based on the explanation above, but instead, it logs 1, 2, and 3. What’s going on here?

It turns out the increment function somehow remembers the value of counter. How does that work?

Is counter part of the global execution context? Try console.log(counter) and you’ll see undefined. So that’s not it.

Maybe when you call increment, it somehow goes back to the function where it was created (createCounter)? But that doesn’t make sense because the variable increment contains the function definition, not the context it came from. So that’s not it either.

There must be another mechanism at play here.
And there is: “The Closure”. This is the missing piece.

Here’s how it works: Whenever you declare a new function and assign it to a variable, you store not just the function definition but also a closure. The closure contains all the variables that are in scope when the function is created. It’s like a backpack. A function definition comes with a little backpack that stores all the variables that were in scope at the time the function was created.

So, when you call increment, it’s not just using the function definition—it’s also using the variables that were in scope when the function was defined. That’s why counter keeps its value between calls to increment. It’s not part of the global execution context, but it’s remembered because of the closure.

So our explanation above was All wrong, let’s try it again, but correctly this time.

1: function createCounter() {
2: let counter = 0
3: const myFunction = function() {
4: counter = counter + 1
5: return counter
6: }
7: return myFunction
8: }
9: const increment = createCounter()
10: const c1 = increment()
11: const c2 = increment()
12: const c3 = increment()
13: console.log('example increment', c1, c2, c3)

let’s break down the execution of the createCounter function in detail, step by step:

  1. Lines 1–8: We start by defining a function named createCounter. In the global execution context, we declare a new variable createCounter and assign it the function definition that spans lines 1 through 8. The code inside the function is not executed yet, just stored for future use.
  2. Line 9: We declare a new variable increment in the global execution context. Initially, this variable is undefined.
  3. Line 9 (continued): We call the createCounter function and assign its returned value to increment. The JavaScript engine looks up createCounter in the global execution context, finds the function definition, and prepares to execute it.
  4. New execution context for createCounter: A new local execution context is created, named the createCounter execution context. This context is pushed onto the call stack.
  5. Line 2: Inside this local context, we declare a variable counter and initialize it to 0.
  6. Lines 3–6: We declare a new constant variable myFunction and assign it a function definition. This function will increment the counter variable and return its value. Importantly, a closure is created here. The closure includes the counter variable, capturing its current value (0) within the function definition.
  7. Line 7: The myFunction function (including its closure) is returned by createCounter. The local execution context for createCounter ends, and the context is popped off the call stack. The variables counter and myFunction no longer exist in this context. However, myFunction retains access to counter via its closure.
  8. Line 9 (continued): The returned function (myFunction along with its closure) is assigned to the variable increment. Now, increment contains the function definition that was stored in myFunction.
  9. Line 10: We declare a new variable c1 in the global execution context.
  10. Line 10 (continued): We call the increment function and assign its return value to c1. The JavaScript engine looks up increment, finds the function definition, and prepares to execute it.
  11. New execution context for increment: A new local execution context is created for this function call, named the increment execution context. This context is pushed onto the call stack. The first thing we do in this local context is use the closure.
  12. Line 4: Inside this local context, we update counter by incrementing it by 1. Since counter is in the closure, its value is retrieved (which is 0), incremented to 1, and updated in the closure.
  13. Line 5: The function returns the updated value of counter, which is 1. The local execution context for increment ends, and the context is popped off the call stack.
  14. Line 10 (continued): The returned value (1) is assigned to c1.
  15. Line 11: We repeat steps 9–14 for c2. The increment function is called again, the closure's counter value (1) is incremented to 2, and 2 is returned and assigned to c2.
  16. Line 12: We repeat steps 9–14 for c3. The increment function is called again, the closure's counter value (2) is incremented to 3, and 3 is returned and assigned to c3.
  17. Line 13: Finally, we log the values of c1, c2, and c3 to the console. The console displays example increment 1 2 3.

So, what’s happening here? The key is that when a function is declared, it contains not just the function definition but also a closure. The closure is a collection of all the variables that were in scope at the time the function was created.

You might wonder if any function has a closure, even functions created in the global scope. The answer is yes. Functions created in the global scope do have a closure, but since they’re in the global scope, they have access to all the global variables, making the closure concept less relevant.

Closures become really important when a function returns another function. The returned function has access to variables that aren’t in the global scope but exist in its closure.

In summary, the increment function remembers the value of counter through its closure. Each call to increment updates and returns the counter value, demonstrating how closures allow functions to retain access to their own lexical scope even after the function that created them has finished executing. This is the essence of closures in JavaScript: they enable functions to "remember" the environment in which they were created.

Conclusion

There is a great analogy made by Will Sentance in the course:

When a function gets created and passed around or returned from another function, it carries a backpack with it. And in the backpack are all the variables that were in scope when the function was declared.

I hope you understand closures now. Thank you🙌

--

--

Bhaskar Pandey
Bhaskar Pandey

Written by Bhaskar Pandey

I love learning about software engineering, Web development, and leadership in tech.

Responses (2)