한국어 | English | 日本語
Senior Web Application Developer (8.8+ years)
Tech & Dev
engineering
Focusing on web frontend and backend development

Understanding 'Hoisting' and 'Closures' through the JavaScript Engine Execution Process

Newcomers to JavaScript are often surprised by its ease of use. While C and Java required compilation every time, JavaScript allows immediate coding right in the browser's developer console. This highlights its lightweight nature and user-friendliness. But how exactly does code execute within a JavaScript engine (specifically V8)? Understanding this process helps us grasp the principles behind Hoisting and Closures, rather than just memorizing them, as they relate to variable and function interactions.
We'll briefly explore what a JavaScript engine (responsible for executing our .js files) is and how it executes these files. Then, we'll revisit the concepts of 'Hoisting' and 'Closures,' which we often just memorized, to understand them fundamentally.

JavaScript

JavaScript is one of the three core elements of a web page.

JavaScript can be executed synchronously by simply declaring and calling functions, or it can be set to perform asynchronously at specific event times via callbacks.

Various web browsers like Chrome, Internet Explorer, and Safari each have their own HTML, CSS, and JavaScript engines. Among them, a prominent JavaScript engine is V8, used in Chrome, which will be discussed in this article. Incidentally, Node.js is created by taking the JavaScript engine from a browser and combining it with libuv, an asynchronous event processing library, to form a server. It’s really that simple.

Interpreted Language

JavaScript is a scripting language, and because it undergoes interpretation, you can see the results line by line directly in the browser console. Like any script, it can also be made into a single file and executed like a batch script, undergoing a brief compilation followed by interpretation.

When JavaScript executes a .js file, it first goes through a JIT (Just-In-Time) compilation process that scans only variable and function declarations before execution. JIT compilation differs from the AOT (Ahead-of-Time) compilation process (https://dev.to/deanchalk/comment/8h32) that generates intermediate code in commonly known compiled languages like C++ and Java. Although JavaScript is taught as an interpreted language, leading some to believe it has no compilation phase, it strictly does have a compilation phase. However, merely having a compilation phase doesn’t make JavaScript a compiled language, as it doesn’t align with the definition of a compiled language. Thus, it’s more appropriate to call it an interpreted language.

JIT Compilation in V8

Similar to the compilation of other programming languages, the V8 engine compiles JavaScript by generating ASTs and converting them into bytecode. To improve efficiency, V8 has its own compiler solution that caches bytecode that would otherwise be repeatedly recompiled.

The process of ASTs and bytecode caching is beyond the scope of this article, so we will only briefly touch upon it.

JavaScript Engine and Runtime

The execution of a programming language naturally involves an engine that transforms it into executable code, loads it into memory, and manages its execution. The JavaScript engine acts as the JavaScript executor. When Web APIs (such as setTimeout, which uses the Kernel) are added to provide a rich JavaScript experience, it becomes the browser we use.

JavaScript Engine

A JavaScript engine is divided into two memory components:

Variables and functions are stored in the Heap, and the order of function calls is stored in the Stack, which are then executed sequentially by a single thread.

Here, the Stack in JavaScript is somewhat different from other programming languages. In other languages, the Call Stack stores not only function execution information but also local variables and parameter information for each function execution. This memory area for each function within the Call Stack is sometimes called a Context Scope, as it holds all the context needed for function execution. In contrast, JavaScript’s Call Stack only stores function execution order pointers, while all function and variable contexts corresponding to the Context Scope are stored in the Heap. Astute readers might notice that this implies all function variables are gathered indiscriminately in a single Heap space. This is the conceptual starting point for the concepts of Hoisting and Closures.

JavaScript Runtime

When we hear about JavaScript, asynchronous operations are often mentioned tirelessly, but how does a single thread handle asynchronous tasks? This is possible because the JavaScript runtime, which we’ll explain below, includes three components that help with ‘asynchronous execution,’ ‘storing asynchronous results,’ and ‘bringing those results back to the current single thread.’

The JavaScript runtime adds the following three components to the JavaScript engine:

JavaScript Engine Execution Process

The JavaScript engine executes code in two phases: the ‘JIT Compilation Phase’ and the ‘Execution Phase.’ Now, based on what we’ve read and learned so far, we will examine how JavaScript code is executed within the engine. We’ll use the terms explained above, so if any terms are unclear, please review the preceding content.

Compilation Phase

To make the Compilation Phase easy to understand, we can think of it as the process of loading into the Heap. As previously explained, the Heap is where the JavaScript engine stores internal parameters, variables, and functions necessary for function execution (unlike languages that store them on the Stack). With so many functions defined and called during JavaScript execution, are all parameters and variables within those functions stored in “a single Heap”? How are they distinguished then?

When a function is executed (which we will learn about in the Execution Phase), a Scope is created for that function. Function parameters and local variables are defined and stored within this Scope during the compilation phase. Since the Scope of parameters and variables is defined during the compilation phase, it is later referred to as Lexical Scope.

Let’s examine the compilation process using the following JavaScript file as an example.

var a = 2;
b = 1;

function f(z) {
  b = 3;
  c = 4;
  var d = 6;
  e = 1;

  function g() {
    var e = 0;
    d = 3 * d;
    return d;
  }

  return g();
  var e;
}

f(1);
  1. For the initial execution of JavaScript, the Global Scope (window) area for the main() function is created in the Heap.

When JavaScript starts, the very first function executed is main(), and it begins by creating a Global Scope, called window, in the Heap. You might recall using window for global variable definitions. This is possible precisely because it is the Global Scope itself. Henceforth, Scope areas loaded into the Heap will be represented as follows:

# Global Scope (window)
-
-

Next, variable and function declarations are loaded into the newly created Global Scope (window) area in the Heap.

  1. var a is a variable declaration, so a is loaded into the Global Scope (window) area.
  2. b = 1 is a variable assignment, so it is not loaded into the Heap (Scope) at this stage.
# Global Scope (window)
- a =                                           <-- var a = 2;
-
  1. function f(z) is a function declaration, so f is loaded into the Global Scope (window) area.
    • When a function is loaded, a pointer to the bytecode (blob) of function f is also loaded.
# Global Scope (window)
- a =
- f = a pointer on f functions bytecode        <-- function f(z) {

The Compilation Phase for the main() function, the very first function to execute in JavaScript, concludes here. Once this function’s (main()) Compilation Phase is finished, execution immediately returns to the very first line of the function and performs the Execution Phase (i.e., function execution) using the Scope Heap that has just been loaded.

Execution Phase

While the preceding Compilation Phase only involved variable and function declarations, this phase handles variable assignments. This means it needs to find where a variable declaration was made previously and then assign a value to that found declaration. So, what happens if a variable declaration isn’t found?

Scope Chain

If a declaration for the variable to be assigned is not found within its own (function’s) Scope Heap, the following procedure occurs:

This process, of sequentially searching for a variable declaration across Scopes, is called the Scope Chain. If, after searching all the way up to the Global Scope Heap, the variable still doesn’t exist, a new variable is declared and assigned simultaneously in the Global Scope Heap. Ultimately, it becomes a global variable.


Continuing with the example, let’s explain the Execution Phase:

# Global Scope (window)
- a =
- f = a pointer for f functions bytecode
  1. a = 2 is a variable assignment, so it finds the variable a and assigns the value 2.
    • Variable a exists in the Global Scope (window) area.
# Global Scope (window)
- a = 2                                         <-- var a = 2;
- f = a pointer for f functions bytecode
  1. b = 1 is a variable assignment, so it finds the variable b and assigns the value 1.
    • Variable b does not exist in the Global Scope (window) area.
      • Therefore, a new variable b is declared and assigned 1 simultaneously.
# Global Scope (window)
- a = 2
- f = a pointer for f functions bytecode
- b = 1                                         <-- b = 1;
  1. f(1) is a function call, so it checks for the declaration of f() and executes it.
    • Function f() exists in the Global Scope (window) area.
      • To execute the f() function, another Compilation Phase and Execution Phase are required.
        • A new Local Execution Scope area for f() is created in the Heap.
        • For the Scope Chain, it holds a pointer to the parent function’s Scope that called it.
          • (hidden) A pointer for previous scope
# Global Scope (window)
- a = 2
- f = a pointer for f functions bytecode
- b = 1

# Local Execution Scope for f()                 <-- f(1);
+ (hidden) A pointer for previous scope (= Global Scope (window))
-
-
  1. When the f(1) function is executed, in the newly created Local Execution Scope,
    • Variables and function declarations are loaded again through the Compilation Phase, resulting in the following:
# Global Scope (window)
- a = 2
- f = a pointer for f functions bytecode
- b = 1

# Local Execution Scope for function f()
+ (hidden) a pointer for previous scope (= Global Scope (window))
- z =
- d =
- e =
  1. After completing the Execution Phase of f(1),
    • Variable assignments are made, and another Local Execution Scope for function g() is created as shown below.
# Global Scope (window)
- a = 2
- f = a pointer for f functions bytecode
- b = 3

# Local Execution Scope for function f()
+ (hidden) a pointer for previous scope (= Global Scope (window))
- z = 1
- d = 6
- e = 1
- c = 4

# Local Execution Scope for function g()
+ (hidden) a pointer for previous scope (= Local Execution Scope for function f())
- e =

From the g() function onwards, it would be beneficial for the reader to try writing it themselves as a review. (I’m definitely not just being lazy.)

JavaScript Variable Characteristics

In fact, the reason for learning about the JavaScript engine execution process was not only to understand the process itself but also as a build-up to explain the following concepts. Based on the JavaScript engine’s operation, several unique characteristics of JavaScript have emerged, let’s explore them all.

Lexical Scope

Programming languages differ in their Scope terminology depending on when variable scopes are defined. If defined at compile time, it’s called “Static Scope”; if at runtime, it’s “Dynamic Scope”. In JavaScript, since parameters and variables are all bound to the Scope of the function where they are located at the time of their definition during the compilation phase (not Execution Phase, which was likely a typo in the original Korean), it is also called “Lexical Scope”. The term Lexical refers to something being created, or the time of creation, and since parameters, variables, and functions all follow their Scope at the time they are created, i.e., defined, it is called Lexical Scope. Thus, Lexical Scope can also be referred to as Static Scope.

In the example below, function b() and variable var num = 1; are both defined within the same main() function’s Scope, the Global Scope (window). This is why function b() can access var num = 1; at any time.

var num = 1;

function b() {
  console.log(num);
}

b();

The above example is easy to understand, but the example below, despite only adding one function, can cause momentary confusion.

var num = 1;

function a() {
  var num = 10;
  b();
}

function b() {
  console.log(num);
}

a();

The execution result of a() is 1, not 10. The reason is that function b() does not care about

Recalling this, function b(), defined in the Global Scope, will refer to var num = 1; which is also defined in the Global Scope. In general, with common programming languages, one might mistakenly think 10 would be the result due to thinking about runtime scope.

Variable Shadowing

Lexical Scope naturally leads to a situation where, even if variables with the same name are defined, only the closest function’s Scope (Local Execution Scope for function) will be used. Variables with the same name defined in a parent function (that called the current function) are ignored. If the variable being sought exists in the closest function’s Scope, there’s no need for a Scope Chain search. This implies that anything beyond the variable in the closest function’s Scope is not needed and will not be known, hence it’s called Variable Shadowing.

Hoisting: Variables, Functions

Because variable and function declarations are processed first during the compilation phase, followed by variable assignment and function execution during the execution phase, the code will run without errors even if a variable declaration appears below its assignment, or a function declaration appears below its call. This is because the variable and function declarations have already been processed. The concept that variable and function declarations seemingly “move up” regardless of where they are placed is called Hoisting.

console.dir(exampleV); // output: undefined
// → 'Variable declared', but no value assigned at this point.
console.dir(exampleF); // output: f exampleF(x)
console.log(exampleF(2)); // output: 2

var exampleV = 1;
function exampleF(x) {
  return x;
}

Functions can be defined using ‘function declarations’ and ‘function expressions.’ If you’ve correctly understood the compilation process, you can determine which definition type is subject to hoisting and which is not.

console.dir(functionDeclare); // output: f functionDeclare(x)
console.dir(functionExpression); // output: undefined
// → 'Variable declared', but no function assigned at this point.

function functionDeclare(x) {
  return x;
} // Function Declaration
var functionExpression = function (x) {
  return x;
}; // Function Expression

In the case of a ‘function expression,’ only the var functionExpression variable is declared during compilation, so the function itself is undefined.

Block Scope: const, let

Previously, 1) Lexical Scope and 2) Hoisting were often sources of errors because they differ from how we commonly expect programming languages to behave.

  1. Function-level Scope (Lexical Scope + Scope Chain) = All variables declared outside the function are accessible.
var a = 1;

if (true) {
  var a = 2;
}

console.log(a); // 2 - Variable pollution for the window function (like a global variable).
  1. Hoisting = Only declarations are hoisted; if an assignment hasn’t occurred, undefined is returned.
  2. Duplicate Declarations Allowed = var allows duplicate declarations like var hello = 1; var hello = 2;. When combined with points 1 and 2, this can become extremely confusing.

To rescue our brains from this challenging world, ES6 introduced the golden variable keywords: const and let. Variables defined with const and let are scoped to a Block Scope, unlike the previous function-unit Scope. This means:

  1. Block-level Scope = Variables within a block are only accessible there, preventing pollution of variables in other Scopes.
let b = 1;

if (true) {
  let b = 2;
}

console.log(b); // 1 - The 'let' variable is only valid within the if-block. It does not pollute variables outside the block.
  1. No Hoisting = Instead of showing undefined (meaning “declared but not assigned”), it throws a Uncaught ReferenceError: ... is not defined error, indicating that the declaration itself has not occurred yet.
  2. No Duplicate Declarations = Attempting a duplicate declaration will throw a Uncaught SyntaxError: Identifier ... has already been declared error.

The advent of const and let enabled defining and using variables within block-level constructs like if and for loops. They prevent any pollution.

Garbage Collection

By now, it should be ingrained that in JavaScript, a function-unit Context Scope is created in the Heap every time a new function is called. Since a Context Scope is only valid for the duration of its function call, once that function’s call ends, its Scope is removed from the Heap. This memory cleanup is called Garbage Collection. When the JavaScript file execution is entirely finished, the main() function, which was called first, also ends, and consequently, the Global Scope (Window) disappears. Thus, JavaScript performs Garbage Collection purely based on the Reachability of functions (pointers). It’s sufficient to know that it employs a simple “Mark And Sweep” strategy, unlike languages like Swift or Java, which use a “Reference Count” strategy for Garbage Collection.

Closure

In Java, classes use private variables to achieve Encapsulation. This prevents external access to variables within a class; changes to variables are only possible through publicly exposed functions. Encapsulation is a fundamental concept not only in Object-Oriented Programming (OOP) but also in Domain-Driven Design (DDD). Unfortunately, JavaScript classes, unlike Java classes, do not natively support Encapsulation. Of course, there was a convention where variables prefixed with _ were implicitly considered private, but since objects could still be logged to the console, it was ultimately ineffective. More recently, JavaScript introduced the # prefix for class variables, which operates like a pseudo-private variable. However, because #variableName variables are defined as such, they can still be exposed via Object.getOwnPropertySymbols(), making it not a fundamental solution.

This is where JavaScript’s Closures come in handy. By leveraging Lexical Scope, where the variables a function can reference are determined by the Scope in which the function is defined, we can simply return the “function definition” itself. This allows us to achieve Encapsulation. Before explaining Encapsulation further, let’s understand how to define a Closure.

var closureTest = function () {
  return function () {
    console.log("This is innerFunction.");
  };
};

Or

var closureTest = function () {
  function innerFunction() {
    console.log("This is innerFunction.");
  }
  return innerFunction;
};

This method of returning a function definition is called a Closure.

Variables defined within the Lexical Scope where a function is defined are accessible from that defined function. By defining the variable we want to keep private inside a Closure’s function definition, we can achieve Encapsulation.

var closureTest = function () {
  var cannotBeAccessedFromOuter = "This is innerFunction.";

  return function () {
    console.log(cannotBeAccessedFromOuter);
  };
};

var closure = closureTest();
closure(); // output: This is innerFunction.
console.log(closure.cannotBeAccessedFromOuter); // output: undefined

Private variables are also referred to as ‘unexposed variables.’ In the example above, the unexposed variable held a fixed value, but it can also be defined in a mutable state. This is a coding technique consistent with Java’s object-oriented programming style. Furthermore, let’s define not just one function, but several. Let’s define the counter function, which is cited countless times in Closure explanations. While one might always think of Closures as returning a function, they can also return an object as follows:

var counter = function () {
  var count = 0;

  return {
    increase: function (number) { // Added 'number' parameter
      count += number;
    },
    decrease: function (number) { // Added 'number' parameter
      count -= number;
    },
    show: function () {
      console.log(count);
    },
  };
};

var counterClosure = counter();
counterClosure.increase(1);
counterClosure.show(); // output: 1
counterClosure.decrease(1);
counterClosure.show(); // output: 0

The only way to modify the count private variable within counterClosure is by calling its functions, and it can only be viewed through its functions. This also means that the count variable is accessible from anywhere if accessed through the increase(), decrease(), or show() functions. This implies that the Reachability of the count variable is always open, and the JavaScript engine can never determine when to perform Garbage Collection.

Therefore, the counterClosure object will not be deleted from memory even after its execution is complete. The fact that the count variable is always ready for use means that while it exists within the counter function, it is also referenced globally. As a result, it continues to exist until the global scope where the counter function was defined is closed, leading to a memory leak.

Closures, as described, have the critical disadvantage of not being Garbage Collected. However, to resolve this, to allow for Garbage Collection, you can assign null to the counterClosure variable to remove references to both the variable and the function (deleting its Reachability).

counterClosure = null;

Another approach for functions that don’t require reusability is to use an IIFE (Immediately Invoked Function Expression).

(function hello() { /* ... */ })();
(function makePrivateFunc() {
  const message = "private data";
  const privateFunc = function () {
    console.log(`${message} can also be implemented through an IIFE`);
  };
  privateFunc();
})();

In summary, we’ve briefly explored how the JavaScript engine operates in two phases, ‘compilation’ and ‘execution,’ along with the concepts of function execution (Context Scope) and variable definition (Lexical Scope). While going into more detail would make it infinitely complex, for web application developers, understanding these concepts should be sufficient for practical development and interviews.


  1. Difference between let and const
  2. Difference between var, let, and const ⏤ Variable Declaration and Assignment, Hoisting, Scope
  3. Function Scope
  4. JavaScript VM internals, EventLoop, Async and ScopeChains
Understanding 'Hoisting' and 'Closures' through the JavaScript Engine Execution Process
Author
Aaron
Posted on
Licensed Under
CC BY-NC-SA 4.0
CC BY-NC-SA 4.0
More in this category
Recent posts
The Erosion of Conversational Muscle and Communication Styles by LLM Filters
In an era where LLM tools, which filter out conversational impoliteness and deliver refined responses, have become commonplace, are we truly engaging in more thoughtful conversations? This article examines the phenomenon of conversational ability, which should be honed through countless failures in real-time communication, degenerating due to reliance on external tools. It further explores the potential societal anxieties and shifts in generational behavioral patterns that this trend may bring.
Optimal Timing and Strategy for Salary Negotiation with Senior Candidates
Salary negotiation is more than just an exchange of figures; it's a strategic dance of psychological timing. This analysis explores why engaging in a gradual negotiation process from the initial stages of recruitment, rather than waiting until after a final offer (when candidates tend to adopt a more calculative stance), proves more efficient for companies and fosters a more honest sharing of resources.
The Limits of the Rule of Law and Human Diversity
The belief that all human actions can be regulated by a single legal system may be an act of hubris. This article offers a sharp analysis of the paradox of the rule of law faced by humanity, which, having escaped the hierarchical controls of the Middle Ages, has now embraced infinite modern freedom. It further examines the deepening social coercion and the demonization of others that arise under the guise of diversity.
토스트 예시 메세지