JavaScript: A Synchronous, Single-Threaded Language
JavaScript operates as a synchronous, single-threaded language, meaning it can handle only one task at a time using a single call stack.
The call stack, a crucial component of the JavaScript engine, is responsible for executing all JavaScript code sequentially.
Its primary function is to process and execute tasks as they arrive.
It does not wait for any operation to complete—it executes everything immediately. Hence, the saying: "Time, tide, and JavaScript wait for none."
Handling Delays in JavaScript
But hold on, what would it be if we needed to wait for something, or what would be if any program or script needed to be run after 5 seconds? Can we do that? no, we can do that because whatever comes inside the Call stack executed immediately, the Call stack doesn't have a timer to wait for 5 seconds.
If we need to run a piece of code after a certain delay we need super power of timers, to access those super powers we need WebAPI's.
Let's see some WebAPI's:
1. Timer - setTimeout(), setInterval()
2. DOM APIs - document.getElementById("test")
3. fetch()
4. Local storage
5. Console
6. Location
All these WebAPIs are present in the global object - 'window', so we can call the WebAPIs either by the window.setTimeout() or just by setTimeout().
Let's understand how Javascript handles the async operation
- JavaScript starts executing synchronous code and pushes function calls onto the call stack.
- If an asynchronous operation (like a
setTimeout
,fetch
, or event listener) is encountered, it is sent to the Web APIs (provided by the browser or Node.js). - Once the async operation completes, the callback function is placed in the Callback queue/macro tasks.
- The event loop checks whether the call stack is empty before moving the next task from the task queue to the call stack.
Consider the following JavaScript snippet:
console.log("Start");
setTimeout(() => console.log("Executed me after 5 seconds"), 5000);
console.log("End");
Expected Output:
Start
End
Executed me after 5 seconds
Why?
-
console.log("Start")
runs first (synchronous code). - When javascript sees the
setTimeout()
, it will call the timer using the WebAPI and register/store the callback function so it's scheduled for later. (Since JS will wait for nothing it will go to the next line). -
console.log("End")
runs next. - Now comes into the picture Event loop and macro tasks to handle, once the timer expires, the callback function is pushed into the macro tasks.
- The event loop will push this callback function once the Call stack is free
- And now
console.log("Executed me after 5 seconds")
is executed
Understanding the Event Loop
The event loop is the mechanism that allows JavaScript to handle asynchronous operations efficiently. It continuously monitors the call stack and task queue, ensuring that tasks are executed in the right order.
The event loop acts as a gatekeeper, it will continuously check if any task is present in the task queue and it pushes it into the Callstack once it is free.
But hold on, Why do we need the Macro tasks/Callback Queue?
A callback function can be pushed directly onto the call stack once the timer expires or an event is emitted. Let's understand this concept with the following JavaScript snippet:
console.log("Start");
document.getElementById("submit-btn").addEventListner("click", function cb() {
console.log("callback");
})
console.log("End");
Step 1: Initial Execution (Synchronous Code):
-
console.log("Start")
→ This runs immediately and prints "Start" to the console. -
document.getElementById("submit-btn").addEventListener(...)
→ This registers an event listener for the "click" event on the submit button. However, the callback function - (cb) is not executed immediately. It is stored in the browser's event listener registry. -
console.log("End")
→ This runs immediately and prints "End" to the console. - At this point, the execution is done, and the Call Stack is empty.
Step 2: Click Event (Asynchronous Code):
- When the user clicks the submit button, the event listener (cb function) should execute.
- However, it does not go directly to the Call Stack. Instead, it follows these steps:
- Event is detected by the browser: The browser sees that a click event occurred on the button.
- Callback function (cb) is placed in the Macrotask Queue (Callback Queue): JavaScript does not push the function directly to the Call Stack because the Call Stack might already be executing something.
- Event Loop steps in: The Event Loop continuously checks whether the Call Stack is empty. If the Call Stack is empty, it moves the task from the Macrotask Queue to the Call Stack.
- Function execution (cb()): Once on the Call Stack, cb executes, printing "callback".
In simple words, it ensures JavaScript does not block the main thread and processes tasks sequentially.
We have seen setTimeout
and event listener
, let see about fetch
Fetch basically makes an API call with API servers, this fetch() function returns a promise, we need to attach a callback to the promise to trigger once it is resolved.
Let see with an example:
console.log("Start");
setTimeout(() => console.log("Executed me after 5 seconds"), 5000);
fetch("https://api.netflix.com").then(()=> {console.log("Success Response of Netflix API");});
//Let's assume we have 1000 lines of code to execute which take 10 seconds to execute
console.log("End");
Expected Output:
Start
End
Success Response of Netflix API
Executed me after 5 seconds
-
console.log("Start")
runs first (synchronous code). - When javascript sees the
setTimeout()
, it will call the timer using the WebAPI and register the callback function inside WebAPI's environment. - When javascript sees the
fetch()
, it will make the network call using the fetch() WebAPI and register the callback function inside WebAPI's environment. - Execute all other 1000 lines of code which take 10 seconds to execute.
- Timer and API calls are made by WebAPI is also completed while the above 1000 line is executed and the
Timer callback function
is pushed toMacro Tasks
andfetch callback function
is pushed toMicro Tasks
-
console.log("End")
runs next. - Now Call stack is free.
- Now Event loop will push the
fetch callback function
to the Call stack since the Micro Tasks Queue has priority over the Macro Tasks Queue - And now
console.log("Success Response of Netflix API")
is executed - Once the Micro Task Queue is empty, the Event loop will push the task from the Macro Tasks Queue.
- And now
console.log("Executed me after 5 seconds")
is executed
Microtasks vs. Macrotasks
Asynchronous operations are categorized into two types: microtasks and macro tasks.
Microtasks:
Microtasks are high-priority asynchronous operations that execute immediately after the current script completes, before any macro task.
Examples:
- Promises (
.then()
,.catch()
,.finally()
) -
MutationObserver
(used to detect DOM changes) queueMicrotask()
Macrotasks:
Macrotasks execute after microtasks and include operations related to the browser or Node.js environment.
Examples:
-
setTimeout
,setInterval
-
setImmediate
(Node.js) Event listener
Execution Order:
- Execute synchronous code (pushed to the call stack).
- Process all microtasks.
- Execute the next macrotask from the queue.
- Repeat the process.
Understanding Task Starvation:
Now, imagine this: if micro tasks keep popping up without allowing other tasks a chance to run, what happens next? Well, in this scenario, the Callback Queue won’t get an opportunity to execute its tasks. This situation is what we call the starvation of tasks in the Callback Queue.
Key Takeaways
- JavaScript runs code synchronously, but async operations are handled using the event loop.
- Microtasks execute before macrotasks, making them high-priority.
- Always be aware of execution order when working with asynchronous code.
- Understanding this helps in optimizing performance and avoiding unexpected behaviors.
You can write efficient and bug-free asynchronous JavaScript code by mastering these concepts. Happy coding!
Top comments (1)
Quality Post. Good explanation on how the event loop works!