The JavaScript Event Loop: Explained
Call Stack, Web APIs, Event Queue, Micro-tasks, and Macro-tasks
The event loop is the secret behind JavaScript’s asynchronous programming. JS executes all operations on a single thread, but using a few smart data structures, gives us the illusion of multi-threading. The asynchronous behavior is not part of the JavaScript language itself, rather it is built on top of the core JavaScript language in the browser (or the programming environment) and accessed through the browser APIs.
Browser JavaScript execution flow, as well as in Node.js, is based on an event loop. The event loop concept is very simple. There’s an endless loop, where the JavaScript engine waits for tasks, executes them, and then sleeps, waiting for more tasks.
The general algorithm of the engine:
- While there are tasks - execute them, starting with the oldest task.
- Sleep until a task appears, then go to 1.
That’s a formalization for what we see when browsing a page. The JavaScript engine does nothing most of the time, it only runs if a script/handler/event activates. Examples of tasks:
- When an external script
<script src="...">
loads, the task is to execute it. - When a user moves their mouse, the task is to dispatch
mousemove
event and execute handlers. - When the time is due for a scheduled
setTimeout
, the task is to run its callback. - …and so on.
Tasks are set — the engine handles them — then waits for more tasks (while sleeping and consuming close to zero CPU). Let’s take a look at what happens on the back-end.
Basic Architecture

- Memory Heap — Objects are allocated in a heap which is just a name to denote a large mostly unstructured region of memory.
- Call Stack — This represents the single thread provided for JavaScript code execution. Function calls form a stack of frames. It is responsible for keeping track of all the operations in line to be executed. Whenever a function is finished, it is popped from the stack. It is a LIFO queue (Last In, First Out).
- Browser or Web APIs — They are built into your web browser and are able to expose data from the browser and surrounding computer environment and do useful complex things with it. They are not part of the JavaScript language itself, rather they are built on top of the core JavaScript language, providing you with extra superpowers to use in your JavaScript code.
For example, the Geolocation API provides some simple JavaScript constructs for retrieving location data so you can say, plot your location on a Google Map. In the background, the browser is actually using some complex lower-level code (e.g. C++) to communicate with the device’s GPS hardware (or whatever is available to determine position data), retrieve position data, and return it to the browser environment to use in your code. But again, this complexity is abstracted away from you by the API. - Event or Callback Queue — It is responsible for sending new functions to the track for processing. It follows the queue data structure to maintain the correct sequence in which all operations should be sent for execution.



Whenever an async function is called, it is sent to a browser API. These are APIs built into the browser. Based on the command received from the call stack, the API starts its own single-threaded operation.
An example of this is the setTimeout
method. When a setTimeout
operation is processed in the stack, it is sent to the corresponding API which waits till the specified time to send this operation back in for processing.
Where does it send the operation? The event queue. Hence, we have a cyclic system for running async operations in JavaScript. The language itself is single-threaded, but the browser APIs act as separate threads.
The event loop facilitates this process. It has one simple job — to monitor the call stack and the callback queue. If the call stack is empty, the event loop will take the first event from the queue and will push it to the call stack, which effectively runs. If it is not, then the current function call is processed.
Let’s quickly take a look at an example and see what’s happening when we’re running the following code in a browser:
const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 500);
const baz = () => console.log("Third");
bar();
foo();
baz();Output:First
Third
Second

- We invoke
bar
.bar
returns asetTimeout
function. - The callback we passed to
setTimeout
gets added to the Web API, thesetTimeout
function andbar
get popped off the callstack. - The timer runs, in the meantime
foo
gets invoked and logsFirst
.foo
returns (undefined),baz
gets invoked, and the callback gets added to the queue. baz
logsThird
. The event loop sees the callstack is empty afterbaz
returned, after which the callback gets added to the call stack.- The callback logs
Second
.
The event loop proceeds to execute all the callbacks waiting in the task queue. Inside the task queue, the tasks are broadly classified into two categories, namely micro-tasks and macro-tasks.
Micro-tasks within an event loop:
- A micro-task is said to be a function that is executed after the function or program which created it exits and only if the JavaScript execution stack is empty, but before returning control to the event loop being used by the user agent to drive the script’s execution environment. A Micro-task is also capable of en-queuing other micro-tasks.
- Micro-tasks are often scheduled for things that are required to be completed immediately after the execution of the current script. On completion of one macro-task, the event loop moves on to the micro-task queue. The event loop does not move to the next task outside of the micro-task queue until all the tasks inside the micro-task queue are completed. This implies that the micro-task queue has a higher priority.
- Once all the tasks inside the micro-task queue are finished, only then does the event loop shift back to the macro-task queue. The primary reason for prioritizing the micro-task queue is to improve the user experience. The micro-task queue is processed after callbacks given that any other JavaScript is not under mid-execution. Micro-tasks include mutation observer callbacks as well as promise callbacks.
- In such a case wherein new micro-tasks are being added to the queue, these additional micro-tasks are added at the end of the micro-queue and these are also processed. This is because the event loop will keep on calling micro-tasks until there are no more micro-tasks left in the queue, even if new tasks keep getting added. Another important reason for using micro-tasks is to ensure consistent ordering of tasks as well as simultaneously reducing the risk of delays caused by users.
Syntax: Adding micro-tasks:
queueMicrotask(() => {
// Code to be run inside the micro-task
});
The micro-task function itself takes no parameters and does not return a value.
Examples: process.nextTick, Promises, queueMicrotask, MutationObserver
Macro-tasks within an event loop:
- Macro-task represents some discrete and independent work. These are always the execution of the JavaScript code and micro-task queue is empty. Macro-task queue is often considered the same as the task queue or the event queue. However, the macro-task queue works the same as the task queue. The only small difference between the two is that the task queue is used for synchronous statements whereas the macro-task queue is used for asynchronous statements.
- In JavaScript, no code is allowed to execute until an event has occurred. It is worth mentioning that the execution of a JavaScript code execution is itself a macro-task. The event is queued as a macro-task. When a (macro) task, present in the macro-task queue is being executed, new events may be registered and in turn, created and added to the queue.
- Upon initialization, the JavaScript engine first pulls off the first task in the macro-task queue and executes the callback handler. The JavaScript engine then sends these asynchronous functions to the API module, and the module pushes them to the macro-task queue at the right time. Once inside the macro-task queue, each macro-task is required to wait for the next round of the event loop. In this way, the code is executed.
- All micro-tasks logged are processed in one fell swoop in a single macro-task execution cycle. In comparison, the macro-task queue has a lower priority. Macro-tasks include parsing HTML, generating DOM, executing main thread JavaScript code, and other events such as page loading, input, network events, timer events, etc.
Examples: setTimeout, setInterval, setImmediate, requestAnimationFrame, I/O, UI Rendering
Let’s take a look at a quick example, simply using:
Task1
: a function that's added to the call stack immediately, for example by invoking it instantly in our code.Task2
,Task3
,Task4
: microtasks, for example, a promisethen
callback, or a task added withqueueMicrotask
.Task5
,Task6
: a (macro)task, for example, asetTimeout
orsetImmediate
callback

First, Task1
returned a value and got popped off the call stack. Then, the engine checked for tasks queued in the microtask queue. Once all the tasks were put on the call stack and eventually popped off, the engine checked for tasks on the macro task queue, which got popped onto the call stack, and popped off when they returned a value. Let’s use it with some real code:
1) Using setTimeout and Promise

In this code, we have the macro task setTimeout
and the microtask promise then()
callback. Let's run this code step-by-step, and see what gets logged:
- On the first line, the engine encounters the
console.log()
method. It gets added to the call stack, after which it logs the valueStart!
to the console. The method gets popped off the call stack, and the engine continues. - The engine encounters the
setTimeout
method, which gets popped onto the call stack. ThesetTimeout
method is native to the browser: its callback function (() => console.log('Timeout!')
) will get added to the Web API until the timer is done. Although we provided the value0
for the timer, the call back still gets pushed to the Web API first, after which it gets added to the (macro) task queue:setTimeout
is a macro task!


3. The engine encounters the Promise.resolve()
method. The Promise.resolve()
method gets added to the call stack, after which is resolves with the value Promise!
. Its then
callback function gets added to the microtask queue.
4. The engine encounters the console.log()
method. It gets added to the call stack immediately, after which it logs the value End!
to the console, gets popped off the call stack, and the engine continues.


5. The engine sees the call stack is empty now. Since the call stack is empty, it’s going to check whether there are queued tasks in the microtask queue! And yes there are, the promise then
callback is waiting for its turn! It gets popped onto the call stack, after which it logs the resolved value of the promise: the string Promise!
in this case.
6. The engine sees the call stack as empty, so it’s going to check the microtask queue once again to see if tasks are queued. Nope, the microtask queue is all empty. It’s time to check the (macro)task queue: the setTimeout
callback is still waiting there! The setTimeout
callback gets popped onto the call stack. The callback function returns the console.log
method, which logs the string "Timeout!"
. The setTimeout
callback gets popped off the call stack.


2) Async/Await

- First, the engine encounters a
console.log
. It gets popped onto the call stack, after whichBefore function!
gets logged. - Then, we invoke the async function
myFunc()
, after which the function bodymyFunc
runs. On the very first line within the function body, we call anotherconsole.log
, this time with the stringIn function!
. Theconsole.log
gets added to the call stack, logs the value, and gets popped off. - The function body keeps on being executed, which gets us to the second line. Finally, we see a
await
keyword! 🎉 The first thing that happens is that the value that gets awaited gets executed: the functionone
in this case. It gets popped onto the call stack, and eventually returns a resolved promise. Once the promise has resolved andone
returned a value, the engine encounters theawait
keyword. - When encountering an
await
keyword, theasync
function gets suspended. ✋🏼 The execution of the function body gets paused, and the rest of the async function gets to run in a microtask instead of a regular task! - Now that the async function
myFunc
is suspended as it encountered theawait
keyword, the engine jumps out of the async function and continues executing the code in the execution context in which the async function got called: the global execution context in this case! 🏃🏽♀️ - Finally, there are no more tasks to run in the global execution context! The event loop checks to see if there are any microtasks queued up: and there are! The async
myFunc
function is queued up after resolving the valued ofone
.myFunc
gets popped back onto the call stack, and continues running where it previously left off. - The variable
res
finally gets its value, namely the value of the resolved promise thatone
returned! We invokeconsole.log
with the value ofres
: the stringOne!
in this case.One!
gets logged to the console and gets popped off the call stack! 😊
Finally, all done! Did you notice how async
functions are different compared to a promise then
? The await
keyword suspends the async
function, whereas the Promise body would've kept on being executed if we would've used then
!
Conclusion
We have learned about the JavaScript event loop which is a constantly running process that coordinates the tasks between the call stack and callback queue to achieve concurrency.
Hope that this makes you feel a bit more comfortable with the concept of the event loop. Thank you for reading :)