Have you ever wondered how browsers read and execute JavaScript code? It looks mysterious, but in this post you can get an idea of what is happening under the hood.
Let's start our journey into the language with an excursion into the wonderful world of JavaScript engines.
Open the console in Chrome and go to the Sources tab. You will see several sections, and one of the most interesting is called
Call Stack (in Firefox, you will see Call Stack when you set breakpoint in the code):

')
What is a call stack? It seems that a lot of things are happening here, even for the sake of executing a couple of lines of code. In fact, JavaScript does not come in a box with every browser. There is a large component that compiles and interprets our JavaScript code — this is a JavaScript engine. The most popular are V8, it is used in Google Chrome and Node.js, SpiderMonkey in Firefox, JavaScriptCore in Safari / WebKit.
Today, JavaScript engines are great examples of software engineering, and it will be almost impossible to talk about all aspects. However, the main work on code execution is done for us by only a few engine components: Call Stack (Call Stack), Global Memory (Global Memory) and Execution Context (execution context). Ready to meet them?
Content:
- JavaScript engines and global memory
- JavaScript engines: how do they work? Global execution context and call stack
- Javascript is single threaded and other funny stories
- Asynchronous JavaScript, callback queue, and event loop
- Callback hell and promises ES6
- Creating and working with JavaScript promises
- Error handling in ES6 promises
- Combinators ES6 promises: Promise.all, Promise.allSettled, Promise.any and others
- ES6 promises and microtask queue
- JavaScript engines: how do they work? Asynchronous evolution: from promises to async / await
- JavaScript engines: how do they work? Results
1. JavaScript engines and global memory
I said that JavaScript is a compiled and interpreted language at the same time. Believe it or not, JavaScript engines actually compile your code microseconds before it is executed.
Magic some, yes? This magic is called JIT (Just in time compilation). It is in itself a great topic for discussion, even a book will not be enough to describe the work of JIT. But for now we’ll skip theory and focus on the execution phase, which is no less interesting.
First look at this code:
var num = 2; function pow(num) { return num * num; }
Suppose I ask you how this code is processed in the browser? What will you answer? You can say: “browser reads code” or “browser executes code”. In reality, everything is not so simple. First, the code is not read by the browser, but by the engine.
The JavaScript engine reads the code , and as soon as it defines the first line, it puts a couple of links in
global memory .
Global memory (also called a heap) is an area in which the JavaScript engine stores variables and function declarations. And when he reads the above code, two binders will appear in the global memory:

Even if the example contains only a variable and a function, imagine that your JavaScript code is executed in a larger environment: in a browser or in Node.js. In such environments, there are many pre-defined functions and variables that are called global. Therefore, global memory will contain much more data than just
num
and
pow
, keep in mind.
Nothing is being performed at the moment. Let's now try to fulfill our function:
var num = 2; function pow(num) { return num * num; } pow(num);
What will happen? And something interesting will happen. When calling a function, the JavaScript engine will allocate two sections:
- Global Execution Context
- Call Stack
What are they?
2. JavaScript engines: how do they work? Global execution context and call stack
You've learned how the JavaScript engine reads variables and function declarations. They fall into the global memory (a bunch).
But now we are performing a JavaScript function, and the engine should take care of this. How? Each JavaScript engine has a
key component called a call stack .
This is a stack data structure : elements can be added to it from above, but they cannot be excluded from the structure as long as there are other elements above them. This is exactly how JavaScript functions are arranged. When executed, they cannot leave the call stack if there is another function in it. Pay attention to this, because this concept helps to understand the statement "JavaScript is single-threaded."
But back to our example.
When calling a function, the engine sends it to the call stack :

I like to present the call stack as a stack of Pringles chips. We can not eat chips from the bottom of the stack, until we eat those that lie on top. Fortunately, our function is synchronous: it is just a multiplication that is quickly calculated.
At the same time, the engine places the
global execution context in memory, this is the global environment in which the JavaScript code is executed. Here's what it looks like:

Imagine a global execution context in the form of a sea in which global JavaScript functions float like fish. How cute! But this is only half the story. What if our function has nested variables, or internal functions?
Even in the simple case, as shown below, the JavaScript engine creates a
local execution context :
var num = 2; function pow(num) { var fixed = 89; return num * num; } pow(num);
Notice that I added the variable
fixed
to the
pow
function. In this case, the local execution context will contain a section for
fixed
. I am not good at drawing small rectangles inside other small small rectangles, so use your imagination.
Next to
pow
local execution context will appear, inside a green rectangle section located inside the global execution context. Imagine also how for each nested function within the nested function the engine creates other local execution contexts. All these sections-rectangles appear very quickly! Like a matryoshka!
Let's now go back to the single-threaded story. What does this mean?
3. JavaScript is single-threaded, and other fun stories.
We say that
JavaScript is single-threaded because only one call stack handles our functions . Let me remind you that functions cannot leave the call stack if other functions expect execution.
This is not a problem if we work with synchronous code. For example, the addition of two numbers is synchronous and is calculated in microseconds. What about network calls and other interactions with the outside world?
Fortunately,
JavaScript engines are designed to work asynchronously by default . Even if they can perform only one function at a time, slower functions can be performed by an external entity — in our case, this is a browser. We will discuss this below.
At the same time, you know that when the browser loads some JavaScript code, the engine reads this code line by line and performs the following steps:
- Puts variables and function declarations into global memory (heap).
- Sends a call to each function to the call stack.
- Creates a global execution context in which global functions are executed.
- Creates many small local execution contexts (if there are internal variables or nested functions).
Now you have a general idea of the mechanics of synchronicity underlying all JavaScript engines. In the next chapter we will talk about how asynchronous code works in JavaScript and why it works that way.
4. Asynchronous JavaScript, callback queue and event loop
Thanks to global memory, execution context and call stack, synchronous JavaScript code is executed in our browsers. But we forgot something. What happens if you need to perform some asynchronous function?
By asynchronous function, I mean every interaction with the outside world, which may take some time to complete. Calling a REST API or timer is asynchronous, because it can take seconds to execute them. Thanks to the elements in the engine, we can handle such functions without blocking the call stack and the browser. Do not forget, the call stack can execute only one function at a time, and
even one blocking function can literally stop the browser . Fortunately, JavaScript engines are “smart,” and with a little browser help can sort such things out.
When we perform an asynchronous function, the browser takes it and executes it for us. Take this timer:
setTimeout(callback, 10000); function callback(){ console.log('hello timer!'); }
I am sure that even though you have already seen
setTimeout
hundreds of times, you may not know that
this function is not built into JavaScript . So, when JavaScript appeared, there was no
setTimeout
function in it. In fact, it is part of the so-called browser APIs, a collection of handy tools that the browser provides us. Wonderful! But what does this mean in practice? Since
setTimeout
belongs to browser APIs, this function is executed by the browser itself (for an instant it appears on the call stack, but is immediately removed from there).
After 10 seconds, the browser takes the callback function that we passed to it, and puts it in the
callback queue . At the moment, two more sections-rectangles appeared in the JavaScript engine. Take a look at this code:
var num = 2; function pow(num) { return num * num; } pow(num); setTimeout(callback, 10000); function callback(){ console.log('hello timer!'); }
Now our scheme looks like this:

setTimeout
is executed within the context of the browser. After 10 seconds, the timer starts and the callback function is ready for execution. But first, it must go through the callback queue. It is a data structure in the form of a queue, and, as its name indicates, is an ordered queue of functions.
Each asynchronous function must go through a callback queue before it enters the call stack. But who sends the function further? This makes a component called
the event loop .
So far, the event loop deals only with one thing: it checks if the call stack is empty. If there is any function in the callback queue and if the call stack is free, then it's time to send the callback to the call stack.
After that, the function is considered complete. This is the general scheme for processing asynchronous and synchronous code by the JavaScript engine:

Let's say
callback()
is ready for execution. After the
pow()
execution is complete,
the call stack is released and the event loop sends a callback()
. And that's it! Although I have simplified everything a bit, if you understand the above scheme, you can understand all of JavaScript.
Remember: the
browser APIs, the callback queue, and the event loop are pillars of asynchronous JavaScript .
And if you're interested, you can watch Philip Roberts’s curious video “What the heck is the event loop anyway”. This is one of the best explanations of the cycle of events.
But we have not finished with the topic of asynchronous JavaScript. In the following chapters, we will look at ES6 promises.
5. Callback hell and ES6 promises
Callback functions are used in JavaScript everywhere, in both synchronous and asynchronous code. Consider this method:
function mapper(element){ return element * 2; } [1, 2, 3, 4, 5].map(mapper);
mapper
is a callback function that is passed inside the
map
. The code given is synchronous. Now consider this interval:
function runMeEvery(){ console.log('Ran!'); } setInterval(runMeEvery, 5000);
This code is asynchronous, because inside
setInterval
we pass the runMeEvery callback. Callbacks are used throughout JavaScript, so we have a problem for years, called “callback hell” - “hell callbacks”.
The term
Callback hell in JavaScript is applied to the “style” of programming, in which callbacks are invested in other callbacks that are embedded in other callbacks ... Because of the asynchronous nature, JavaScript programmers have long been trapped.
To be honest, I have never created a large pyramid of callbacks. Perhaps because I appreciate the readable code and always try to adhere to its principles. If you hit the callback hell, it means that your function does too much.
I will not speak in detail about callback hell, if you are interested, then go to the site
callbackhell.com , where this problem is investigated in detail and various solutions are proposed. And we will talk about
ES6 promises . This is a JavaScript addon designed to solve the hell callback problem. But what are “promises”?
Promis in JavaScript is a representation of a future event . A promis can be completed successfully, or in the jargon of programmers a promis will be “resolved” (resolved). But if the promise finishes with an error, then we say that it is in the “rejected” state. Also, promises have a default state: each new promise starts in the “pending” state. Is it possible to create your own promise? Yes. We will discuss this in the next chapter.
6. Creating and working with JavaScript promises
To create a new promise, call the constructor, passing the callback function to it. It can take only two parameters:
resolve
and
reject
. Let's create a new promise that will be resolved in 5 seconds (you can test the examples in the browser console):
const myPromise = new Promise(function(resolve){ setTimeout(function(){ resolve() }, 5000) });
As you can see,
resolve
is a function that we call for the promise to succeed. And the
reject
will create a rejected promise:
const myPromise = new Promise(function(resolve, reject){ setTimeout(function(){ reject() }, 5000) });
Note that you can ignore
reject
, because this is the second parameter. But if you intend to use
reject
, you
cannot ignore resolve
. That is, the following code will not work and will end with the allowed promis:
Now the promises don't look so useful, right? These examples do not output anything to the user. Let's add something. Both allowed, rejected promises can return data. For example:
const myPromise = new Promise(function(resolve) { resolve([{ name: "Chris" }]); });
But we still see nothing.
To extract data from the promise, you need to associate the promise with the then
method . He takes a callback (which is irony!), Which receives actual data:
const myPromise = new Promise(function(resolve, reject) { resolve([{ name: "Chris" }]); }); myPromise.then(function(data) { console.log(data); });
As a JavaScript developer and consumer of someone else's code, you mostly interact with external promises. The creators of the libraries most often wrap the legacy code in the constructor of promises, thus:
const shinyNewUtil = new Promise(function(resolve, reject) {
And if necessary, we can also create and resolve a promise by calling
Promise.resolve()
:
Promise.resolve({ msg: 'Resolve!'}) .then(msg => console.log(msg));
So let me remind you that promises in javascript are a bookmark for an event that will occur in the future. The event starts in a “pending decision” state, and may be successful (allowed, executed) or unsuccessful (rejected). Promis can return data that can be retrieved by attaching to the
then
promis. In the next chapter, we will discuss how to deal with errors coming from promises.
7. Error Handling in ES6 Promises
Processing errors in JavaScript has always been easy, at least in synchronous code. Take a look at an example:
function makeAnError() { throw Error("Sorry mate!"); } try { makeAnError(); } catch (error) { console.log("Catching the error! " + error); }
The result will be:
Catching the error! Error: Sorry mate!
As expected, the error got into the
catch
. Now let's try the asynchronous function:
function makeAnError() { throw Error("Sorry mate!"); } try { setTimeout(makeAnError, 5000); } catch (error) { console.log("Catching the error! " + error); }
This code is asynchronous due to
setTimeout
. What happens if we do it?
throw Error("Sorry mate!"); ^ Error: Sorry mate! at Timeout.makeAnError [as _onTimeout] (/home/valentino/Code/piccolo-javascript/async.js:2:9)
Now the result is different. The error was not caught by the
catch
, but rose freely up the stack. The reason is that
try/catch
works only with synchronous code. If you want to know more, then this problem is discussed in detail
here .
Fortunately, with promises, we can handle asynchronous errors as if they were synchronous. In the last chapter, I said that calling
reject
leads to rejection of promise:
const myPromise = new Promise(function(resolve, reject) { reject('Errored, sorry!'); });
In this case, we can handle errors with the help of the
catch
handler, pulling (again) the callback:
const myPromise = new Promise(function(resolve, reject) { reject('Errored, sorry!'); }); myPromise.catch(err => console.log(err));
In addition, you can call
Promise.reject()
to create and discard the promise in the right place:
Promise.reject({msg: 'Rejected!'}).catch(err => console.log(err));
I recall: the
then
handler is executed when the promise is executed, and the
catch
handler is executed for rejected promises. But this is not the end of the story. Below we will see how
async/await
works great with
try/catch
.
8. Combinators of ES6 promises: Promise.all, Promise.allSettled, Promise.any and others
Promises are not designed to work alone. Promise API offers a number of methods for
combining promises . One of the most useful
is Promise.all , it takes an array of promises and returns one promise. The only problem is that Promise.all is rejected if at least one promise is rejected in the array.
Promise.race allows or rejects as soon as one of the promises in the array receives the corresponding status.
In more recent versions of the V8, two new combinators will also be introduced:
Promise.allSettled
and
Promise.any
.
Promise.any is still at the early stage of the proposed functionality, at the time of this writing is not supported. However, in theory, he will be able to signal whether any promise has been performed. The difference from
Promise.race
is that
Promise.any does not deviate, even if one of the promises is rejected .
Promise.allSettled
even more interesting. He also takes an array of promises, but does not “short,” if one of the promises is rejected. It is useful when you need to check whether all the promises in the array passed into some stage, regardless of the presence of rejected promises. It can be considered the opposite of
Promise.all
.
9. ES6 promises and microtask queue
If you remember from the previous chapter, each asynchronous callback function in JavaScript is placed in a callback queue before it enters the call stack. But callback functions passed to promis have a different fate: they are processed by a microtask queue (Microtask Queue), and not by a task queue.
And here you need to be careful: the
microtask queue precedes the call queue . Callbacks from the microtask queue take precedence when the event loop checks to see if new callbacks are ready and go to the call stack.
This mechanic is described in more detail by Jake Archibald in
Tasks, microtasks, queues and schedules , a wonderful
read .
10. JavaScript engines: how do they work? Asynchronous evolution: from promises to async / await
JavaScript is developing rapidly and we get constant improvements every year. Promises looked like a final, but
with ECMAScript 2017 (ES8) a new syntax appeared: async/await
.
async/await
is just a stylistic improvement that we call syntactic sugar.
async/await
does not change JavaScript at all (remember, the language should be backward compatible with old browsers and should not break existing code). This is just a new way of writing asynchronous code based on premiums. Consider an example. Above we have already saved the prom with the appropriate
then
:
const myPromise = new Promise(function(resolve, reject) { resolve([{ name: "Chris" }]); }); myPromise.then((data) => console.log(data))
Now
, using async/await
we can process asynchronous code so that the code looks synchronous to the reader of our listing . Instead of using
then
we can wrap the promise in a function labeled
async
, and then we will expect (
await
) the result:
const myPromise = new Promise(function(resolve, reject) { resolve([{ name: "Chris" }]); }); async function getData() { const data = await myPromise; console.log(data); } getData();
Looks right, right? It's funny that the async function always returns a promise, and no one can stop it:
async function getData() { const data = await myPromise; return data; } getData().then(data => console.log(data));
What about mistakes? One of the advantages of
async/await
is that this construct can allow us to use
try/catch
. Read the
introduction to error handling in async functions and their testing .
Let's look at the promise again, in which we handle errors with the
catch
handler:
const myPromise = new Promise(function(resolve, reject) { reject('Errored, sorry!'); }); myPromise.catch(err => console.log(err));
With asynchronous functions, we can refactor like this:
async function getData() { try { const data = await myPromise; console.log(data);
However, not all have switched to this style.
try/catch
can complicate your code. In this case, you need to consider something else. See how the error inside the
try
block arises in this code:
async function getData() { try { if (true) { throw Error("Catch me if you can"); } } catch (err) { console.log(err.message); } } getData() .then(() => console.log("I will run no matter what!")) .catch(() => console.log("Catching err"));
What about the two lines that are displayed in the console? Do not forget that
try/catch
is a synchronous construction, and our asynchronous function generates a promise . They go in two different ways, like a train. But they will never meet! ,
throw
,
catch
getData()
. , «Catch me if you can», «I will run no matter what!».
,
throw
then
. , ,
Promise.reject()
:
async function getData() { try { if (true) { return Promise.reject("Catch me if you can"); } } catch (err) { console.log(err.message); } } Now the error will be handled as expected: getData() .then(() => console.log("I will NOT run no matter what!")) .catch(() => console.log("Catching err")); "Catching err"
async/await
JavaScript. .
, JS-
async/await
. . ,
async/await
— .
11. JavaScript-: ? Results
JavaScript — , , . JS-: V8, Google Chrome Node.js; SpiderMonkey, Firefox; JavaScriptCore, Safari.
JavaScript- «» : , , , . , .
JavaScript- , . JavaScript: , - , (, ) .
ECMAScript 2015 . — , . . 2017-
async/await
: , , .