JavaScript is single-threaded, meaning it can only do one thing at a time. Asynchronous programming lets you start long-running operations (network requests, timers, file reads) without blocking the rest of your code.
The Event Loop
The event loop is how JavaScript handles asynchronous code. Understanding it is fundamental.
console.log("1 — Start");
setTimeout(() => {
console.log("2 — Timeout callback");
}, 0);
console.log("3 — End");
// Output:
// 1 — Start
// 3 — End
// 2 — Timeout callback
Even with a 0ms delay, the timeout callback runs after the synchronous code finishes. The event loop processes the call stack first, then checks the callback queue.
Callbacks
A callback is a function passed to another function to be called later.
function fetchData(url, callback) {
setTimeout(() => {
const data = { id: 1, name: "Sabaoon" };
callback(null, data);
}, 1000);
}
fetchData("/api/user", (error, data) => {
if (error) {
console.error("Failed:", error);
return;
}
console.log("User:", data.name);
});Callback Hell
Nested callbacks quickly become unreadable:
getUser(userId, (err, user) => {
getOrders(user.id, (err, orders) => {
getOrderDetails(orders[0].id, (err, details) => {
getShipping(details.shippingId, (err, shipping) => {
console.log("Shipping:", shipping);
// deeply nested and hard to maintain
});
});
});
});This is why promises were invented.
Promises
A Promise represents a value that may not be available yet. It has three states: pending, fulfilled, or rejected.
Creating a Promise
function wait(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id <= 0) {
reject(new Error("Invalid user ID"));
} else {
resolve({ id, name: "User " + id });
}
}, 500);
});
}Consuming Promises with .then() and .catch()
fetchUser(1)
.then((user) => {
console.log("User:", user.name);
return user.id;
})
.then((id) => {
console.log("User ID:", id);
})
.catch((error) => {
console.error("Error:", error.message);
})
.finally(() => {
console.log("Request complete");
});Promise Chaining
Chaining flattens the callback hell pattern:
getUser(userId)
.then((user) => getOrders(user.id))
.then((orders) => getOrderDetails(orders[0].id))
.then((details) => getShipping(details.shippingId))
.then((shipping) => console.log("Shipping:", shipping))
.catch((error) => console.error("Failed:", error));Async/Await
async/await is syntactic sugar over promises that makes asynchronous code look synchronous.
async function loadUser(id) {
try {
const user = await fetchUser(id);
console.log("User:", user.name);
return user;
} catch (error) {
console.error("Failed to load user:", error.message);
throw error;
}
}
loadUser(1);Rewriting the Callback Hell Example
async function getShippingInfo(userId) {
try {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const details = await getOrderDetails(orders[0].id);
const shipping = await getShipping(details.shippingId);
console.log("Shipping:", shipping);
return shipping;
} catch (error) {
console.error("Failed:", error.message);
}
}Clean, readable, and easy to debug.
The Fetch API
fetch is the modern way to make HTTP requests in the browser.
GET Request
async function getPost(id) {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts/${id}`
);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const post = await response.json();
console.log(post.title);
return post;
}POST Request
async function createPost(title, body) {
const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, body, userId: 1 }),
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return response.json();
}Always Check response.ok
A common mistake is forgetting that fetch does not throw on HTTP errors like 404 or 500. You must check response.ok manually.
Running Promises in Parallel
Promise.all
Wait for all promises to resolve. Rejects if any one fails.
async function loadDashboard(userId) {
const [user, orders, notifications] = await Promise.all([
fetchUser(userId),
fetchOrders(userId),
fetchNotifications(userId),
]);
return { user, orders, notifications };
}Promise.allSettled
Wait for all promises to complete, regardless of success or failure.
const results = await Promise.allSettled([
fetch("/api/users"),
fetch("/api/posts"),
fetch("/api/broken-endpoint"),
]);
results.forEach((result, i) => {
if (result.status === "fulfilled") {
console.log(`Request ${i}: Success`);
} else {
console.log(`Request ${i}: Failed — ${result.reason}`);
}
});Promise.race
Returns the result of whichever promise settles first.
async function fetchWithTimeout(url, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Request timed out")), ms)
);
const request = fetch(url);
return Promise.race([request, timeout]);
}Error Handling Patterns
Try/Catch with Async/Await
async function safeFetch(url) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return { data: await response.json(), error: null };
} catch (error) {
return { data: null, error: error.message };
}
}
const { data, error } = await safeFetch("/api/user");
if (error) {
console.error("Request failed:", error);
} else {
console.log("Data:", data);
}Practical Exercise
Build a simple API data loader:
async function loadUserProfile(userId) {
console.log("Loading profile...");
try {
const [userRes, postsRes] = await Promise.all([
fetch(`https://jsonplaceholder.typicode.com/users/${userId}`),
fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`),
]);
if (!userRes.ok || !postsRes.ok) {
throw new Error("Failed to load profile data");
}
const user = await userRes.json();
const posts = await postsRes.json();
const profile = {
name: user.name,
email: user.email,
postCount: posts.length,
latestPosts: posts.slice(0, 3).map((p) => p.title),
};
console.log(`${profile.name} (${profile.email})`);
console.log(`${profile.postCount} posts`);
profile.latestPosts.forEach((title) => console.log(` - ${title}`));
return profile;
} catch (error) {
console.error("Failed to load profile:", error.message);
return null;
}
}
loadUserProfile(1);Key Takeaways
- JavaScript uses the event loop to handle async operations on a single thread.
- Promises replace nested callbacks with chainable
.then()calls. async/awaitmakes promise-based code read like synchronous code.- Always check
response.okwhen usingfetch— it does not throw on HTTP errors. - Use
Promise.allfor parallel requests andPromise.allSettledwhen you need results from all regardless of failures.