Understanding Asynchronous JavaScript


JavaScript is a single-threaded, non-blocking, asynchronous language primarily designed to run in web browsers. While its synchronous nature makes it easier to reason about control flow, modern web applications often require asynchronous behavior for tasks such as fetching data from APIs, handling user inputs, or delaying execution. This is where the event loop, microtasks, and macrotasks come into play.
Let’s break down how asynchronous JavaScript works under the hood and clarify the mechanics behind its concurrency model.
The Single-Threaded Nature of JavaScript
JavaScript runs in a single thread, meaning it can only execute one piece of code at a time. Unlike multithreaded languages like Java or C++, where tasks can run in parallel, JavaScript relies on an event loop and a task queue system to handle asynchronous operations without blocking the main thread.
This design choice avoids issues like race conditions and deadlocks, but requires a smart way to manage long-running or delayed tasks.
Synchronous vs Asynchronous Code
Synchronous Code:
console.log('A');
console.log('B');
console.log('C');
Output:
A
B
C
Each line executes one after the other.
Asynchronous Code:
console.log('A');
setTimeout(() => console.log('B'), 1000);
console.log('C');
Output:
A
C
B
Here, setTimeout
schedules the callback to run after at least 1000 ms, but doesn’t block execution. This is where the event loop comes into action.
What is the Event Loop?
The event loop is the mechanism that manages the execution of synchronous and asynchronous code in JavaScript. It constantly checks if the call stack is empty, and if so, it pushes the next task from the queue (macro or micro) onto the call stack for execution.
The Event Loop Lifecycle:
Execute all synchronous code in the call stack.
Empty the microtask queue (e.g.,
Promise
callbacks).Pick a macrotask (e.g.,
setTimeout
,setInterval
,I/O events
) and move it to the stack.Repeat.
Call Stack
The call stack is a data structure that stores the functions to be executed. When a function is called, it’s pushed onto the stack. Once it's done executing, it’s popped off.
Example:
function foo() {
bar();
}
function bar() {
console.log('Hello');
}
foo();
Stack:
foo() → bar() → console.log('Hello')
Task Queues: Microtasks vs Macrotasks
JavaScript has two types of task queues:
1. Macrotasks Queue (a.k.a. Task Queue)
These include:
setTimeout
setInterval
setImmediate
(Node.js)I/O callbacks
UI rendering tasks
2. Microtasks Queue
These include:
Promise.then
/Promise.catch
/Promise.finally
queueMicrotask
MutationObserver
Key Difference:
Microtasks have higher priority. They are executed immediately after the current call stack is empty, before any macrotasks.
Precedence: Microtasks vs Macrotasks
Let’s illustrate with an example:
console.log('Start');
setTimeout(() => {
console.log('Macrotask');
}, 0);
Promise.resolve().then(() => {
console.log('Microtask');
});
console.log('End');
Output:
Start
End
Microtask
Macrotask
What happened?
console.log('Start')
runs →Start
is printed.setTimeout
is scheduled (macrotask).Promise.then
is scheduled (microtask).console.log('End')
runs →End
is printed.The call stack is empty, so event loop executes microtasks:
Microtask
is printed.
Then it picks up the macrotask from the queue:
Macrotask
is printed.
Real-World Scenario
Consider this example mixing micro and macrotasks:
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('promise1');
}).then(() => {
console.log('promise2');
});
console.log('script end');
Expected Output:
script start
script end
promise1
promise2
setTimeout
Explanation:
console.log('script start')
andconsole.log('script end')
execute first.setTimeout(..., 0)
is scheduled as a macrotask.Promises (
promise1
andpromise2
) are microtasks.After synchronous code ends, the event loop runs all microtasks (
promise1
thenpromise2
).Finally, the macrotask from
setTimeout
is executed.
Nested Microtasks
Microtasks can queue more microtasks:
Promise.resolve()
.then(() => {
console.log('Microtask 1');
return Promise.resolve();
})
.then(() => {
console.log('Microtask 2');
});
Each .then()
schedules a new microtask. Even within a microtask, new microtasks are added and executed in the same turn of the loop before any macrotask is processed.
Browser Rendering and Task Timing
Microtasks are executed before the browser can render the DOM. This is crucial for performance-sensitive applications.
Excessive or infinite microtasks can block rendering:
function blockRender() {
Promise.resolve().then(blockRender);
}
blockRender();
This creates a starvation scenario: the browser never gets to render anything.
Use microtasks for quick operations, not infinite or heavy computations.
Node.js and Event Loop
Node.js has a more detailed event loop model with phases:
Timers (setTimeout, setInterval)
Pending callbacks
Idle, prepare
Poll
Check (
setImmediate
)Close callbacks
Microtasks (process.nextTick
, Promises) are executed between each phase.
Order of priority:
process.nextTick
Promises (
.then
)Timer callbacks
setImmediate
Example:
setImmediate(() => console.log('Immediate'));
setTimeout(() => console.log('Timeout'), 0);
process.nextTick(() => console.log('NextTick'));
Promise.resolve().then(() => console.log('Promise'));
Output (usually):
NextTick
Promise
Timeout
Immediate
Best Practices for Async JavaScript
Use Promises and
async/await
for readable asynchronous code.Avoid blocking the main thread with long synchronous operations.
Keep microtasks short to avoid delaying UI updates or other tasks.
Prefer
queueMicrotask()
for tasks you want to run immediately after the current operation.
Conclusion
Understanding how JavaScript handles asynchronous tasks via the event loop, microtasks, and macrotasks is critical for writing efficient, non-blocking applications.
Here’s a summary:
Synchronous code runs to completion before asynchronous code.
Microtasks (Promises,
queueMicrotask
) run right after the call stack clears.Macrotasks (setTimeout, setInterval) run after all microtasks are processed.
The event loop orchestrates the order of execution.
Mastering these concepts enables you to write code that is not only performant but also behaves predictably across different scenarios.
Subscribe to my newsletter
Read articles from Yasar Arafath directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
