Sync code runs line by line, and each line must finish completely before the next one starts. If one line takes a long time, everything else waits. This is called blocking code.
console.log("First");
console.log("Second");
console.log("Third");
// Output: First, Second, Third — always in this exact orderAsync code is non-blocking. Instead of waiting for a slow operation to finish, JavaScript moves on to the next line and comes back to the async operation later when it’s done.
console.log("First");
setTimeout(function () {
console.log("Second"); // this is async — runs later
}, 2000);
console.log("Third");
// Output: First, Third, Second
// "Third" prints before "Second" even though "Second" appears first in the codeThis is essential for things like fetching data from an API — you don’t want the entire page to freeze while waiting for a server response.
JavaScript by itself is just a language — it needs an engine to actually run it. In Chrome (and Node.js), that engine is called the V8 Engine. The full picture of how JavaScript runs is called the Runtime Environment, and it’s made of 4 main parts working together.
The diagram below shows the full flow visually.
This is where all sync code runs. Every time a function is called, it gets pushed onto the stack. When it finishes, it gets popped off.
It works as LIFO (Last In, First Out) — the last function that entered is the first one to leave.
function getSum(a, b) {
return a + b;
}
console.log("one");
getSum(5, 10); // pushed to stack, runs, then popped off
console.log("three");If the Call Stack is busy running sync code, nothing else can run — this is what “blocking” means.
Any async code does NOT run in the Call Stack. Instead, it gets sent immediately to the Web APIs environment, which lives outside of JavaScript itself — it’s provided by the browser.
Web APIs handle the waiting so the Call Stack stays free to keep running other code.
Things that go to Web APIs:
AJAX
DOM Events
setTimeout
setInterval
Promise
fetch
Once an async operation finishes in Web APIs, it doesn’t go straight back to the Call Stack. It first moves to the Callback Queue, waiting in line.
The Callback Queue is split into two priority levels:
MicroTask Queue (High Priority — runs FIRST) — FIFO
AJAX
Promise
fetch
MacroTask Queue (Low Priority — runs SECOND) — FIFO
setTimeout
setInterval
DOM Events
Both queues follow FIFO (First In, First Out) — the first one that arrived is the first one to run.
The Event Loop always empties the entire MicroTask Queue before even looking at the MacroTask Queue.
The Event Loop is the piece that ties everything together. Think of it as a security guard standing between the Callback Queue and the Call Stack — constantly watching and deciding what goes in next.
Its job is simple but critical:
- Watch the Call Stack — is it empty?
- If yes, check the MicroTask Queue first — is there anything waiting?
- Push the first MicroTask into the Call Stack and let it run
- Repeat until the MicroTask Queue is completely empty
- Only then, look at the MacroTask Queue and do the same
The Event Loop never pushes anything into the Call Stack while it’s still busy. It only moves things in when the stack is completely clear.
Your Code Runs
|
|---> Sync Code ---------> Call Stack (runs immediately)
|
|---> Async Code --------> Web APIs (waits here)
|
| (when finished)
v
Callback Queue
/ \
MicroTask MacroTask
(Promise/AJAX/fetch) (setTimeout/DOM Events)
runs FIRST runs SECOND
\ /
\ /
v v
Event Loop watches
|
v
Call Stack (empty?)
|
v
Runs the next task
console.log("one"); // sync
setTimeout(() => {
console.log("two"); // async - MacroTask
}, 0);
Promise.resolve().then(() => {
console.log("three"); // async - MicroTask
});
console.log("four"); // syncWork out the answer yourself before looking below.
Answer and explanation:
one → sync, runs immediately in the Call Stack
four → sync, runs immediately after "one"
three → MicroTask (Promise), runs before MacroTask
two → MacroTask (setTimeout), runs last even though delay is 0ms
Even though setTimeout has a delay of 0, it still goes through Web APIs and MacroTask Queue. A Promise always wins over setTimeout because MicroTasks always run before MacroTasks — no exceptions.
JavaScript runs on a single thread — meaning it can only do one thing at a time, line by line. It has one Call Stack, and only one piece of code can be running in it at any given moment.
This is the question that confuses a lot of people: if JavaScript is single-threaded, how can it handle async operations without freezing?
The answer is: JavaScript itself is single-threaded, but the browser is not.
When JavaScript hits an async operation (like setTimeout or fetch), it hands it off to the browser’s Web APIs — which run on separate threads provided by the browser, not by JavaScript. JavaScript then keeps running normally on its single thread. When the browser finishes the async operation, it puts the result in the Callback Queue, and the Event Loop brings it back into JavaScript when the Call Stack is free.
So JavaScript never actually does two things at once — it just delegates the waiting to the browser, stays free, and picks up the result later.
Multi-Thread → multiple things happening at the exact same time
Single-Thread → one thing at a time, BUT delegates waiting to the browser
This is why JavaScript can feel like it’s doing multiple things at once — it’s not, the browser is handling the waiting part while JavaScript keeps moving.
This is one of the most important topics for interviews. JavaScript has gone through 3 generations of solving the same problem: “how do I use the result of an async operation?”
A callback is a function passed as a parameter to another function, to be called when that function finishes its work.
function calculate(num1, num2, callback) {
callback(num1, num2);
}
function add(x, y) {
console.log(x + y);
}
calculate(5, 3, add);When you need to chain multiple async operations — where each one depends on the result of the previous one — callbacks get nested inside each other deeper and deeper, creating what’s known as Callback Hell.
The image below shows what this looks like in real code — it forms a pyramid shape that gets wider and deeper with every level, making it nearly impossible to read, debug, or maintain.
getUser(1, function (user) {
getOrders(user.id, function (orders) {
getOrderDetails(orders[0].id, function (details) {
getPaymentInfo(details.paymentId, function (payment) {
// deeper and deeper...
});
});
});
});This is the “Pyramid of Doom” — every callback adds another level of indentation. This is the problem that Promises were created to solve.
A Promise is a container for async code. Instead of nesting callbacks, you chain .then() calls in a flat, readable way.
A Promise has 2 possible statuses: 1- Pending — still running, result not ready yet
2- Settled — finished, either: A) Resolved (Success) — operation succeeded B) Rejected (Failed) — operation failed
function checkResult(degree) {
return new Promise(function (resolve, reject) {
if (degree >= 50) {
resolve("Success");
} else {
reject("Failed");
}
});
}
checkResult(80)
.then(function (message) {
console.log(message);
})
.catch(function (error) {
console.log(error);
});Even though Promises are much better than callbacks, chaining many .then() calls can still become long and hard to follow, especially for complex operations.
fetch(url)
.then(function (res) { return res.json(); })
.then(function (data) { return processData(data); })
.then(function (result) { return saveResult(result); })
.then(function (saved) { console.log(saved); })
.catch(function (err) { console.log(err); });This is the “then chain” problem — it’s better than callback hell, but it’s still a chain of callbacks in disguise. This is the problem that async/await was created to solve.
async/await is the modern and most recommended way to handle async code. It makes async code look and read like normal sync code — no nesting, no chains.
Rules:
awaitcan only be used inside anasyncfunctionawaitpauses execution inside the function until the Promise resolves- The function always returns a Promise
async function getUserData() {
let response = await fetch("https://api.example.com/user/1");
let data = await response.json();
console.log(data);
}
getUserData();Since async operations can fail, always wrap async/await code in try...catch to handle errors cleanly.
async function getUserData() {
try {
let response = await fetch("https://api.example.com/user/1");
let data = await response.json();
console.log(data);
} catch (error) {
console.log("Something went wrong:", error);
}
}
getUserData();Instead of only reacting to errors JavaScript creates on its own, you can create and throw your own custom error, which is especially useful for validating input before running any logic.
function checkAge(age) {
if (age < 18) {
throw new Error("Age must be 18 or older");
}
return "Access granted";
}
try {
console.log(checkAge(15));
} catch (error) {
console.log(error.message);
}throw immediately stops the function from running any further, exactly like return. This pairs perfectly with try...catch, the catch block receives your custom Error object, and error.message holds the exact text you wrote when creating it.
fetch is the modern built-in way to make API requests in JavaScript. It returns a Promise, so it works perfectly with async/await.
When fetch gets a response back from the server, the response body is not immediately available as usable data — it’s a raw stream. Calling .json() reads that stream and converts it into a JavaScript object or array. Since this is also async, it needs its own await.
let response = await fetch("https://api.example.com/data");
let data = await response.json(); // converts the raw response to usable JS objectasync function sendData() {
try {
let response = await fetch("https://api.example.com/products", {
method: "POST", // GET, POST, PUT, PATCH, DELETE
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer your-token-here"
},
body: JSON.stringify({ // data you want to send (POST/PUT/PATCH only)
name: "Cat Food",
price: 50,
category: "cats"
})
});
let data = await response.json();
console.log(data);
} catch (error) {
console.log("Error:", error);
}
}
sendData();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" href="css/bootstrap.min.css"/>
<link rel="stylesheet" href="css/style.css"/>
<title>Fetch Session</title>
</head>
<body>
<section>
<div class="container">
<div class="row"></div>
</div>
</section>
<script src="js/bootstrap.bundle.min.js"></script>
<script src="js/main.js"></script>
</body>
</html>async function getPizza() {
try {
var data = await fetch(
"https://forkify-api.herokuapp.com/api/search?q=pizza",
{
method: "GET",
}
);
var finalResponse = await data.json();
displayPizza(finalResponse.recipes);
} catch (error) {
console.log(error);
}
}
getPizza();
function displayPizza(array) {
let cards = ``;
for (let i = 0; i < array.length; i++) {
cards += `<div class="col-4">
<div class="card">
<img src=${array[i].image_url} class="img-fluid" alt="" />
<p>${array[i].publisher}</p>
<p>${array[i].title}</p>
</div>
</div>`;
}
document.querySelector(".row").innerHTML = cards;
}getPizza()is anasyncfunction, so it can useawaitinsideawait fetch(...)sends the GET request and waits for the server to respond before moving to the next lineawait data.json()waits for the response body to be fully read and converted to a JavaScript objectfinalResponse.recipesis the array of pizza recipes from the API- That array is passed to
displayPizza()which loops through it and builds HTML cards using template literals - If anything goes wrong at any point (network error, bad response, etc.), the
catchblock handles it cleanly
| Callback | Promise | Async/Await | |
|---|---|---|---|
| Introduced | Always existed | ES6 (2015) | ES8 (2017) |
| Readability | Hard (pyramid shape) | Medium (then chains) | Best (reads like sync code) |
| Error handling | Scattered | .catch() | try…catch |
| Recommended? | No (legacy) | Sometimes | Yes, this is the standard |
End of Session 05

