If, like me, you struggled to understand the concept of the JavaScript runtime, this article may help shed some light on this particular topic. Put simply, the JavaScript runtime is the environment that executes your JavaScript code. Although JavaScript can now be run on the server side (Node.js/Deno), today we will focus on the client side - running JavaScript in the browser.
The browser does not have the ability to understand JavaScript files. Instead, they must be parsed into executable commands. In order to do this, the browser uses a JavaScript engine, for instance Chrome uses the Google V8 engine. The specific engine depends on which browser you’re using. Here's a list of browser specific engines.
Components of the JS runtime
In the browser world, the JavaScript runtime environment is made up of the following:
Call Stack (part of the JS engine)
The call stack makes up part of the JS engine. As the name suggests, it’s a stack data structure which keeps track of which functions are running. This is emblematic of the single threaded nature of JavaScript as, being a stack, the call stack can only handle one function at a time. The call stack follows a last-in first-out principle. In order to facilitate asynchronous code, callback and microtasks queues are implemented (see below).
Memory Heap & Memory Stack (part of the JS engine)
Another piece of the JS engine is the memory heap/stack which is responsible for allocating space for any data in your JavaScript file. Depending on the type of data, the JS engine will allocate space in either the memory heap or the memory stack. The memory stack is reserved for static data, such as primitive values or references to objects/functions - data which the JavaScript engine knows the size of at compile time. On the other hand, the memory heap is where the objects and functions themselves are stored. At compile time, the JavaScript engine does not know the size of these pieces of data and will not find out until run time so the memory space allocation is more dynamic.
Web APIs (supplied by the browser)
Web APIs are separate from the JS engine, and are provided by the browser. To quote Will Sentance, they are “facade functions” which look like JS functions and which can be interacted with in your code, but they are not native to the JavaScript language. One example is the DOM API which allows you to interact with the HTML of the web page. Other examples include setTimeout()
, fetch()
, local storage interface and event listeners.
Callback Queue (supplied by the browser)
The callback queue takes care of executing callback functions in the correct order by pushing them to the call stack when their time comes. It operates on a first-in first-out basis. An example could be timer related functionality such as setTimeout()
or setInterval()
.
Microtask Queue (supplied by the browser)
Similarly, the microtask queue also deals with sending callbacks to the call stack. What’s the difference? The microtask queue handles callback functions coming from promises, queueMicrotask()
invocations and mutation observers. The microtask queue takes priority over the callback queue, so the callback queue will have to wait for the microtask queue to be empty before it can send anything to the call stack.
Event Loop (supplied by the browser)
The event loop is the middle man between the callback/microtask queues and the call stack. It keeps track of what is in the callback/microtask queue and sends the relevant function to the call stack. As mentioned above, it prioritises the microtask queue and will only send callback queue functionality to the call stack once the microtask queue is completely empty.
Now let's put it into practice...
setTimeout(() => {
console.log('ahoj')
}, 0)
console.log('hola')
// OUTPUT -->
// hola
// ahoj
In the snippet above, the console.log('hola')
will be executed first because the setTimeout()
function will be sent to the callback queue as it's utilising the timer from the Browser API. Even though the timer is set for 0 seconds, it waits for the call stack to be empty before triggering.
setTimeout(() => {
console.log('ahoj')
}, 0)
Promise.resolve('hi').then(val => console.log(val))
console.log('hola')
// OUTPUT -->
// hola
// hi
// ahoj
In this second example, as with the above, the console.log('hola')
runs first. The promise comes second as this is sent to the microtask queue which takes precedence over the callback queue. Finally, the setTimeout()
callback runs after the microtask is clear and the callback queue functions are pushed to the call stack by the event loop.
In the case of fetch()
and setTimeout()
you might get a different order to the one you were expecting. This is due to the fact that the microtask queue may be empty if it's still waiting for the response from the XHR request, meaning that a setTimeout()
with a timer set to 0ms could potentially run first.
fetch('https://dog.ceo/api/breeds/image/random')
.then(res => res.json())
.then(data => console.log(data))
setTimeout(() => {
console.log('ahoj')
}, 0)
// OUTPUT -->
// ahoj
// { Response Object }
Here's a great video summary of the topic
Top comments (6)
worth reading and good explanation
thanks so much :)
Nice explanation!
Thank you :)
Great article 🔥
Thanks!