Asynchronous JavaScript & Event Loop
- JavaScript
- Event Loop
- Promises
- V8
The Event Loop is part of a bigger JavaScript runtime environment — a web browser or Node.js. Each runtime has common elements that are essential to JavaScript execution.
JavaScript Runtime & Engine
JavaScript Engine: nowadays there are many JavaScript engines — Chrome's V8, Mozilla's SpiderMonkey, Safari's JavaScriptCore. Most engines work the same way at a high level.
In the case of V8, it is not just a compiler. It is a collection of components that together take JavaScript source code and execute it efficiently.
- Parser takes JavaScript code and turns it into an
AST(Abstract Syntax Tree). - Interpreter takes the
ASTand generates bytecode, then executes that bytecode. This allows V8 to start executing code relatively quickly without waiting for everything to be compiled into highly optimized machine code. - TurboFan: when V8 identifies hot code (code that runs frequently), TurboFan compiles it into optimized machine code. V8 uses a technique called speculative optimization, which assumes various things about the code while it is being executed to optimize further.
Sometimes these assumptions turn out to be false — those situations are also handled by V8 through deoptimization strategies.
- Garbage Collector: V8 manages memory automatically. It keeps a memory heap which stores referenced objects and data structures. When an object is no longer reachable, V8's garbage collector reclaims its memory.
Execution Context
Environment in which a piece of JavaScript is executed. Stores all the necessary information for some code to be executed.
Each execution context (EC) goes through two phases, in order:
- Creation Phase: scan the code, set up memory for variables/functions but don't run anything yet.
- Execution Phase: actually run the code, line by line, using the memory laid out in phase 1.
There are two kinds of Execution Context:
- Global Execution Context (GEC): created once, when your script starts. Sits on the bottom of the call stack for the whole program.
- Function Execution Context (FEC): created fresh every time a function is called. Pushed onto the call stack on invocation, popped off when the function returns.
Global Execution Context
Creation Phase
- Create the
globalobject (windowin browsers,globalin Node, orglobalThisuniversally). - Set
thisto point at that global object (in non-strict mode;undefinedin modules/strict mode). - Set up memory (Variable Environment + Lexical Environment) and scan the code top to bottom.
- Function declarations are hoisted completely — the entire function body is stored in the memory heap, callable before the line it's written on.
vardeclarations are hoisted, but only the name is reserved, initialized toundefined.let/constdeclarations are hoisted too (the engine knows the name exists), but they're left uninitialized, sitting in the Temporal Dead Zone (TDZ). Touching them before their declaration line throws aReferenceError.
Execution Phase
Code now runs line by line. Assignments actually happen for the declared variables. When execution reaches a let/const line, that binding exits the TDZ and becomes usable. When it hits a function call, a new FEC is created and pushed onto the call stack, execution jumps there, and resumes in the GEC once that call returns.
Function Execution Context
Creation Phase
thisbinding: determined by how the function is called (implicit object binding,call,apply,bind) orundefined/global in a plain call — not where it's defined. (Arrow functions skip this — they inheritthislexically from their enclosing scope, with nothisof their own.)argumentsobject: array-like collection of all passed args (regular functions only; arrow functions don't get one).- Parameters are treated like
vars, initialized to passed-in values (orundefinedif not passed). - Outer environment reference: a pointer to the environment the function was defined in.
- Same hoisting rules as global: inner function declarations fully hoisted, inner
vars becomeundefined, innerlet/constsit in the TDZ.
Execution Phase
Same as global: run top to bottom, assign real values, resolve TDZ bindings as their lines are reached, push new FECs for any nested calls.
Event Loop
The event loop waits for the call stack to be empty — meaning all synchronous work is done. Then it first checks the microtask queue and executes functions inside of it, and whenever the microtask queue is empty it moves to the task queue (callback queue).
Promise Execution
let myPromise = new Promise((resolve, reject) => {
// executor (the producing code)
});When the new Promise constructor is executed (which it will be as soon as we declare myPromise), a new Promise object is created in memory. The function inside of the new Promise constructor is called the executor. When myPromise is created, the executor runs automatically and attempts to perform a job which can take time.
There are two arguments in the executor function, both of which are callbacks provided by JavaScript itself:
resolve(value): if the job finishes successfully,resolveis called with the resultvalue.reject(error): if an error occurred,erroris the error object.
The Promise object created in memory has internal slots such as:
[[PromiseState]]: pending, fulfilled, rejected.[[PromiseResult]]: undefined, the value passed toresolvein case of success, the error passed torejectin case of failure.[[PromiseIsHandled]]: boolean, initialized tofalse, then set totrueafter the promise has been handled.
What makes promises special are the next two fields and how they work. These fields contain promiseReactionRecords which we create using .then and .catch methods.
[[PromiseFulfilReactions]][[PromiseRejectReactions]]
new Promise((resolve, reject) => {
// some async stuff
// In case of success call: resolve("done")
// In case of an error call: reject("fails")
})
.then((result) => console.log(result))
.catch((error) => console.log(error));The above two, depending upon the execution of the executor function, create a promiseReaction which holds various fields including [[Handler]].
A [[Handler]] field simply contains the code of the then or catch block.
In case of success, resolve("done") is called and its value (here the string "done") is passed down to the callback in the handler inside of the promiseReaction. This also changes PromiseState to fulfilled and PromiseResult to the value passed to resolve (which is "done"). In fact, the PromiseResult is what becomes the input for the [[Handler]]. Then the [[Handler]] is added to the microtask queue. This is where the async part of promises comes into action.
In case of any error, reject("fails") is called with PromiseState set to rejected and PromiseResult set to the value passed to reject (which is "fails"). After that, the execution of promiseReaction and [[Handler]] is similar to the success case, with PromiseRejectReactions holding the promiseReaction this time.
Worked Example: Promise + setTimeout
new Promise((resolve) => {
setTimeout(() => resolve("Done!"), 100);
}).then((result) => console.log(result));-
new Promise()is added to the call stack and a newPromiseObjectis created in memory.[[PromiseState]]:pending,[[PromiseResult]]:undefined,[[PromiseFulfilReactions]]: empty,[[PromiseRejectReactions]]: empty,[[PromiseIsHandled]]:false. -
The executor function is called. Its first line —
setTimeout— is executed and added to the call stack. -
setTimeoutschedules the timer and holds the callback passed inside of it as its first argument. -
The constructor, executor, and
setTimeoutall pop off the call stack. -
After that,
thenis called, which creates apromiseReactionRecordwith the callback as handler. -
thenis also popped off the call stack. The engine waits for the timer started bysetTimeoutto finish, after which the callback is added to the callback queue. -
The event loop waits for the call stack to be empty before checking the microtask queue followed by the callback queue. Since the call stack is empty here, the callback is pushed to the call stack by the event loop and
resolveis called. -
Calling
resolvesets the slots in thepromiseObjectin memory:PromiseStateto"fulfilled",PromiseResultto"Done!", and thepromiseReactionRecordis populated with the handler containing the code from thethenblock. -
In the meantime the script keeps running and performing tasks on the main thread. When all tasks are completed, the event loop checks the microtask queue, where the
promiseReactionRecordhandler sits with thePromiseResultvalue passed in as input. -
The code in the microtask queue is pushed onto the call stack by the event loop and executed, which prints
"Done!"to the console.
Initial script/task
│
├── new Promise()
├── executor()
├── setTimeout()
└── .then()
│
▼
script finishes
│
▼
call stack empty
│
▼
no microtasks yet
│
▼
wait for timer
│
▼
timer expires
│
▼
timer callback becomes a task
│
▼
callback executes
│
└── resolve("Done!")
│
▼
Promise fulfilled
│
▼
reaction scheduled
│
▼
Microtask Queue
│
▼
`.then()` handler
│
▼
console.log("Done!")