JavaScript is a single-threaded language, meaning it executes one task at a time on its main execution thread.
However, many operations take time:
If JavaScript blocked execution while waiting for every slow operation, applications would become unresponsive.
To handle these operations efficiently, JavaScript uses asynchronous programming.
Before understanding asynchronous JavaScript, we need to understand synchronous execution.
Synchronous code executes one statement after another.
console.log("Start");
console.log("Middle");
console.log("End");
Output:
Start
Middle
End
Execution:
Start
↓
Middle
↓
End
Each operation finishes before the next operation starts.
Some operations can be scheduled to complete later.
Example:
console.log("Start");
setTimeout(() => {
console.log("Middle");
}, 2000);
console.log("End");
Output:
Start
End
Middle
Notice that:
Middle
appears last even though its code appears before:
End
Why?
Because setTimeout() schedules its callback to run later.
Imagine an API request takes 5 seconds.
If JavaScript blocked everything while waiting:
Send API Request
↓
Wait 5 Seconds
↓
Nothing Else Can Run
↓
Receive Response
↓
Continue
The application would feel frozen.
With asynchronous programming:
Send API Request
↓
Continue Other Work
↓
API Response Arrives
↓
Process Response
This allows applications to remain responsive while waiting for slower operations.
Asynchronous JavaScript does not mean JavaScript suddenly executes all JavaScript code on multiple threads.
Instead, the JavaScript runtime works with its environment.
In a browser:
JavaScript Engine
+
Browser Web APIs
+
Queues
+
Event Loop
Together, these allow asynchronous behavior.
The Event Loop coordinates asynchronous tasks and determines when queued callbacks can return to JavaScript execution.
Important components include:
A simplified model is:
JavaScript Code
↓
Call Stack
↓
Browser Web APIs
↓
Queues
↓
Event Loop
↓
Call Stack
The Call Stack keeps track of functions currently being executed.
Example:
function one() {
two();
}
function two() {
console.log("Hello");
}
one();
Execution:
one()
↓
two()
↓
console.log()
Stack:
┌─────────────┐
│ console.log │
├─────────────┤
│ two() │
├─────────────┤
│ one() │
└─────────────┘
When a function finishes, it is removed from the stack.
Browsers provide features that can handle certain asynchronous operations.
Examples:
setTimeout()
Fetch
DOM Events
Geolocation
Consider:
setTimeout(() => {
console.log("Hello");
}, 2000);
The browser handles the timer.
JavaScript does not sit on the Call Stack waiting for two seconds.
When operations such as timers are ready, their callbacks can be placed into a queue.
This is often called the:
Task Queue
or, in simplified explanations:
Callback Queue
The callback waits there until JavaScript can execute it.
Consider:
console.log("Start");
setTimeout(() => {
console.log("Timer");
}, 2000);
console.log("End");
Output:
Start
End
Timer
Execution starts with:
console.log("Start")
↓
Call Stack
↓
Print "Start"
Then:
setTimeout()
↓
Browser handles timer
JavaScript continues:
console.log("End")
↓
Print "End"
After approximately two seconds:
Timer completes
↓
Callback becomes ready
↓
Task Queue
↓
Event Loop
↓
Call Stack
↓
Print "Timer"
┌─────────────────┐
│ Call Stack │
└────────┬────────┘
│
│
▼
┌─────────────────┐
│ Web APIs │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Task Queue │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Event Loop │
└─────────────────┘
The Event Loop continually checks whether JavaScript can process queued work.
setTimeout() Does Not Guarantee Exact TimingConsider:
setTimeout(() => {
console.log("Hello");
}, 2000);
This means:
Run the callback after at least
approximately 2000ms,
when the Call Stack is available.
It does not guarantee that the callback runs at exactly 2000ms.
setTimeout(..., 0)Even a timeout of 0 does not run immediately.
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
console.log("C");
Output:
A
C
B
Why?
A
↓
Timer Scheduled
↓
C
↓
Current Code Finishes
↓
Timer Callback Can Run
↓
B
A callback is a function passed to another function so that the receiving function can call it.
Example:
function greet(name) {
console.log(
`Hello ${name}`
);
}
function processUser(callback) {
callback("Samir");
}
processUser(greet);
Output:
Hello Samir
Here:
greet
is passed as a callback.
A callback is not automatically asynchronous.
For example:
[1, 2, 3].map(number => {
return number * 2;
});
The function passed to map() is a callback, but it runs synchronously.
Callbacks become asynchronous when used with asynchronous operations such as:
setTimeout()
fetch()
DOM events
Callbacks are often written as anonymous functions.
Example:
setTimeout(() => {
console.log("Hello");
}, 1000);
The arrow function:
() => {
console.log("Hello");
}
is the callback.
function fetchData(callback) {
setTimeout(() => {
callback(
"Data Loaded"
);
}, 2000);
}
Usage:
fetchData(data => {
console.log(data);
});
After approximately two seconds:
Data Loaded
Flow:
fetchData()
↓
Start Timer
↓
Timer Completes
↓
Execute Callback
↓
Receive Data
When multiple asynchronous operations depend on one another, callbacks can become deeply nested.
Example:
getUser(user => {
getOrders(
user.id,
orders => {
getPayment(
orders[0].id,
payment => {
console.log(
payment
);
}
);
}
);
});
Visual:
Callback
└── Callback
└── Callback
└── Callback
This structure is commonly called:
Callback Hell
or:
Pyramid of Doom
Deeply nested callbacks can make code:
Promises provide a cleaner way to represent asynchronous results.
A Promise is an object representing the eventual completion or failure of an asynchronous operation.
Think of it as:
Promise
↓
Result Will Be Available Later
A Promise has three states:
Pending
↓
Either
├── Fulfilled
└── Rejected
The operation has not finished.
The operation completed successfully.
The operation failed.
Use:
new Promise()
Example:
const promise =
new Promise(
(resolve, reject) => {
resolve(
"Success"
);
}
);
The Promise constructor receives a function with:
resolve
and:
reject
resolve()Use resolve() when the operation succeeds.
const promise =
new Promise(
resolve => {
resolve(
"Success"
);
}
);
reject()Use reject() when the operation fails.
const promise =
new Promise(
(resolve, reject) => {
reject(
new Error(
"Something went wrong"
)
);
}
);
A common practice is to reject with an Error object.
Use:
.then()
to handle a fulfilled Promise.
Example:
const promise =
Promise.resolve(
"Success"
);
promise.then(result => {
console.log(result);
});
Output:
Success
const fetchData =
new Promise(
resolve => {
setTimeout(() => {
resolve(
"Data Loaded"
);
}, 2000);
}
);
Usage:
fetchData.then(data => {
console.log(data);
});
After approximately two seconds:
Data Loaded
Use:
.catch()
Example:
const promise =
new Promise(
(resolve, reject) => {
reject(
new Error(
"Something went wrong"
)
);
}
);
Handle the error:
promise
.then(data => {
console.log(data);
})
.catch(error => {
console.error(
error.message
);
});
Output:
Something went wrong
finally()Promises also support:
.finally()
It runs when the Promise settles, whether it succeeds or fails.
Example:
fetchData
.then(data => {
console.log(data);
})
.catch(error => {
console.error(error);
})
.finally(() => {
console.log(
"Finished"
);
});
Useful for things such as:
Stop Loading Spinner
Close Connection
Cleanup
One of the biggest advantages of Promises is chaining.
Example:
Promise.resolve(10)
.then(value => {
return value * 2;
})
.then(value => {
return value + 5;
})
.then(value => {
console.log(value);
});
Output:
25
Flow:
10
↓
× 2
↓
20
↓
+ 5
↓
25
Each .then() returns a new Promise, which allows the next .then() to receive the previous result.
.then()Suppose we have:
getUser()
.then(user => {
return getOrders(
user.id
);
})
.then(orders => {
console.log(orders);
});
Instead of:
Callback
└── Callback
└── Callback
we get:
getUser()
↓
getOrders()
↓
Process Orders
This is much easier to follow.
Promises introduce another important queue:
Microtask Queue
Promise callbacks such as:
.then()
.catch()
.finally()
are scheduled as microtasks.
Timer callbacks such as setTimeout() are scheduled as regular tasks.
Microtasks are processed before the next regular task when the current JavaScript execution finishes.
setTimeout()Consider:
console.log("Start");
setTimeout(() => {
console.log("Timeout");
}, 0);
Promise.resolve()
.then(() => {
console.log("Promise");
});
console.log("End");
Output:
Start
End
Promise
Timeout
Why?
First, synchronous code executes:
Start
End
Then JavaScript processes the microtask:
Promise
Then the timer task:
Timeout
Simplified priority:
Current Synchronous Code
↓
Microtasks
↓
Next Task
A more accurate simplified model is:
┌───────────────────┐
│ Call Stack │
└─────────┬─────────┘
│
▼
┌───────────────────┐
│ Web APIs │
└───────────────────┘
Results
┌────┴────┐
▼ ▼
┌──────────┐ ┌──────────┐
│Microtask │ │Task Queue│
│ Queue │ │ │
└────┬─────┘ └────┬─────┘
│ │
└─────┬──────┘
▼
Event Loop
↓
Call Stack
This becomes important when understanding advanced asynchronous behavior.
async and await provide modern syntax for working with Promises.
They make asynchronous code easier to read.
Promise style:
fetchData()
.then(data => {
console.log(data);
})
.catch(error => {
console.error(error);
});
Async/Await style:
try {
const data =
await fetchData();
console.log(data);
} catch (error) {
console.error(error);
}
Both approaches work with Promises.
async FunctionsAdd the async keyword before a function:
async function test() {
}
An async function always returns a Promise.
Example:
async function greet() {
return "Hello";
}
Calling:
console.log(
greet()
);
returns a Promise.
We can access the result:
greet().then(data => {
console.log(data);
});
Output:
Hello
Conceptually:
async function greet() {
return "Hello";
}
behaves like returning a resolved Promise containing "Hello".
await Keywordawait waits for a Promise to settle before continuing that particular async function.
Example:
function fetchData() {
return new Promise(
resolve => {
setTimeout(() => {
resolve(
"Data Loaded"
);
}, 2000);
}
);
}
Use it with:
async function getData() {
const result =
await fetchData();
console.log(result);
}
getData();
After approximately two seconds:
Data Loaded
await Freeze JavaScript?No.
Consider:
async function getData() {
const result =
await fetchData();
console.log(result);
}
console.log("Start");
getData();
console.log("End");
Output:
Start
End
Data Loaded
await pauses the continuation of getData(), but it does not block the entire JavaScript runtime.
Promise style:
fetchData()
.then(data => {
console.log(data);
})
.catch(error => {
console.error(error);
});
Async/Await:
async function loadData() {
try {
const data =
await fetchData();
console.log(data);
} catch (error) {
console.error(error);
}
}
Async/Await often makes sequential asynchronous logic easier to read.
Use:
try
and:
catch
Example:
async function getData() {
try {
const data =
await fetchData();
console.log(data);
} catch (error) {
console.error(
error
);
}
}
Flow:
try
↓
Run Async Operation
↓
Success?
├── Yes → Continue
└── No → catch
finally with Async/AwaitNormal try...catch...finally also works.
async function getData() {
try {
const data =
await fetchData();
console.log(data);
} catch (error) {
console.error(error);
} finally {
console.log(
"Finished"
);
}
}
Useful for:
Loading = true
↓
Request
↓
Success / Error
↓
Loading = false
A common use of asynchronous JavaScript is making API requests.
async function getUsers() {
try {
const response =
await fetch(
"/api/users"
);
if (!response.ok) {
throw new Error(
"Failed to fetch users"
);
}
const users =
await response.json();
console.log(users);
} catch (error) {
console.error(
error.message
);
}
}
Flow:
getUsers()
↓
fetch()
↓
Wait for Response
↓
response.json()
↓
Wait for JSON Parsing
↓
Users
response.ok?fetch() does not reject simply because the server returns an HTTP error such as:
404
500
So we commonly check:
if (!response.ok) {
throw new Error(
"Request failed"
);
}
Then the error can be handled by:
catch
setTimeout()setTimeout() executes a callback once after a delay.
Syntax:
setTimeout(
callback,
milliseconds
);
Example:
setTimeout(() => {
console.log(
"Hello"
);
}, 3000);
After approximately:
3 Seconds
Output:
Hello
setTimeout() returns an identifier.
const timer =
setTimeout(
() => {
console.log(
"Hello"
);
},
3000
);
Cancel it:
clearTimeout(
timer
);
The callback will not run if it is successfully cancelled before execution.
setInterval()setInterval() repeatedly schedules a callback.
Example:
const interval =
setInterval(
() => {
console.log(
"Running"
);
},
1000
);
Output:
Running
Running
Running
...
approximately every second.
Use:
clearInterval()
Example:
const interval =
setInterval(
() => {
console.log(
"Running"
);
},
1000
);
clearInterval(
interval
);
setTimeout() vs setInterval()setTimeout() |
setInterval() |
|---|---|
| Runs once | Runs repeatedly |
| Delayed execution | Repeating execution |
Cancel with clearTimeout() |
Cancel with clearInterval() |
| Useful for delayed actions | Useful for repeated actions |
JavaScript provides several useful methods for working with multiple Promises.
Common ones include:
Promise.all()
Promise.allSettled()
Promise.race()
Promise.any()
Promise.all()Use Promise.all() when multiple asynchronous operations can run at the same time and you need all of them to succeed.
Example:
const userPromise =
fetch("/api/user");
const productsPromise =
fetch("/api/products");
const results =
await Promise.all([
userPromise,
productsPromise
]);
Conceptually:
Request A ──────┐
├── Wait for Both
Request B ──────┘
This can be faster than waiting for independent operations one by one.
Sequential:
const user =
await getUser();
const products =
await getProducts();
Flow:
getUser()
↓
Wait
↓
getProducts()
↓
Wait
If the second operation does not depend on the first, they can often start together:
const [
user,
products
] = await Promise.all([
getUser(),
getProducts()
]);
Flow:
getUser() ────────┐
├── Results
getProducts() ────┘
This is often more efficient.
Promise.allSettled()Promise.all() rejects when one input Promise rejects.
If we want the outcome of every Promise regardless of failures, we can use:
Promise.allSettled()
Example:
const results =
await Promise.allSettled([
getUser(),
getProducts(),
getOrders()
]);
Each result tells us whether it was:
fulfilled
or:
rejected
Consider:
console.log(
"Loading..."
);
fetchData()
.then(data => {
console.log(data);
})
.catch(error => {
console.error(error);
});
console.log(
"Continue..."
);
Possible output:
Loading...
Continue...
Data Loaded
Why?
Because:
Loading...
↓
Start Async Operation
↓
Continue...
↓
Async Operation Completes
↓
Data Loaded
Suppose we need to load a user from an API.
async function loadUser() {
try {
console.log(
"Loading..."
);
const response =
await fetch(
"/api/user"
);
if (!response.ok) {
throw new Error(
"Failed to load user"
);
}
const user =
await response.json();
console.log(user);
} catch (error) {
console.error(
error.message
);
}
}
Concepts used:
Async Function
↓
Fetch
↓
Promise
↓
Await
↓
Response
↓
JSON
↓
Error Handling
HTML:
<button id="loadButton">
Load User
</button>
<p id="status"></p>
JavaScript:
const button =
document.querySelector(
"#loadButton"
);
const status =
document.querySelector(
"#status"
);
button.addEventListener(
"click",
async () => {
status.textContent =
"Loading...";
try {
const response =
await fetch(
"/api/user"
);
if (!response.ok) {
throw new Error(
"Request failed"
);
}
const user =
await response.json();
status.textContent =
`Hello ${user.name}`;
} catch (error) {
status.textContent =
"Failed to load user";
}
}
);
This demonstrates a common frontend pattern:
User Action
↓
Show Loading State
↓
Start Async Request
↓
Wait
↓
Success / Error
↓
Update UI
awaitConsider:
async function getData() {
const response =
fetch(
"/api/users"
);
console.log(response);
}
response is a Promise, not the completed response.
Correct:
const response =
await fetch(
"/api/users"
);
await response.json()Incorrect:
const data =
response.json();
console.log(data);
response.json() also returns a Promise.
Correct:
const data =
await response.json();
Suppose these requests are independent:
const users =
await getUsers();
const products =
await getProducts();
The second request starts only after the first finishes.
When appropriate, run them together:
const [
users,
products
] = await Promise.all([
getUsers(),
getProducts()
]);
| Synchronous | Asynchronous |
|---|---|
| Runs in sequence | Work may complete later |
| Current operation blocks the next JS statement until it finishes | Waiting operations can be handled without blocking all JS work |
| Simple execution flow | Uses callbacks, Promises and async/await |
| Good for immediate calculations | Essential for network/timer/event operations |
| Approach | Example | Usage |
|---|---|---|
| Callback | setTimeout(fn, 1000) |
Function passed to another operation |
| Promise | .then() |
Represent future result |
| Async/Await | await promise |
Cleaner Promise syntax |
The historical progression is often described as:
Callbacks
↓
Nested Callbacks
↓
Promises
↓
Async/Await
Callbacks still remain important because many JavaScript APIs use them.
| Concept | Purpose |
|---|---|
| Asynchronous JavaScript | Handle operations that complete later |
| Call Stack | Tracks JavaScript execution |
| Web APIs | Browser-provided functionality |
| Event Loop | Coordinates queued work |
| Task Queue | Holds regular tasks such as timer callbacks |
| Microtask Queue | Holds Promise-related callbacks |
| Callback | Function passed to another function |
| Callback Hell | Deeply nested callback structure |
| Promise | Represents eventual success or failure |
resolve() |
Fulfill a Promise |
reject() |
Reject a Promise |
.then() |
Handle fulfillment |
.catch() |
Handle rejection |
.finally() |
Run after settlement |
async |
Makes a function return a Promise |
await |
Wait for a Promise within async flow |
try...catch |
Handle async errors |
setTimeout() |
Schedule one execution |
setInterval() |
Schedule repeated executions |
Promise.all() |
Wait for multiple Promises |
JavaScript executes code using a single main Call Stack, but the runtime can coordinate asynchronous work through browser/runtime APIs, queues, and the Event Loop.
JavaScript
↓
Call Stack
↓
Async Operation
↓
Browser / Runtime API
↓
Queue
↓
Event Loop
↓
Call Stack
The evolution of common asynchronous patterns is:
Callbacks
↓
Promises
↓
Async/Await
Promises represent future results:
Promise
↓
Pending
├── Fulfilled
└── Rejected
And modern JavaScript commonly handles them using:
async function getData() {
try {
const data =
await fetchData();
console.log(data);
} catch (error) {
console.error(error);
}
}
For API-driven applications, a common flow is:
User Action
↓
Start Loading
↓
Fetch Data
↓
await Response
↓
Process Data
↓
Update UI
↓
Handle Errors
Understanding asynchronous JavaScript is essential for modern development because API requests, React data fetching, Next.js server operations, Node.js I/O, timers, event handlers, and many application workflows depend on asynchronous behavior.