Synchronous vs Asynchronous
Synchronous code runs line by line, blocking execution. Asynchronous code allows other operations to run while waiting for long-running tasks.
// Synchronous - blocks execution
console.log("1");
console.log("2");
console.log("3");
// Output: 1, 2, 3 (in order)
// Asynchronous - doesn't block
console.log("1");
setTimeout(() => {
console.log("2"); // Runs after delay
}, 1000);
console.log("3");
// Output: 1, 3, 2 (3 before 2!)
// Why async matters
// Without async, a slow network request would freeze the UI!
console.log("Fetching data...");
// Imagine this took 5 seconds synchronously
// The entire page would be unresponsive!
fetch("https://api.example.com/data")
.then(response => console.log("Data received!"));
console.log("Page is still interactive!");
JavaScript is single-threaded but uses an event loop to handle async operations. Callbacks are placed in a queue and executed when the main thread is free.
Callbacks
A callback is a function passed to another function to be executed later, typically when an async operation completes.
// setTimeout with callback
setTimeout(() => {
console.log("Executed after 2 seconds");
}, 2000);
// setInterval with callback
let count = 0;
const intervalId = setInterval(() => {
count++;
console.log(`Count: ${count}`);
if (count === 5) {
clearInterval(intervalId); // Stop the interval
}
}, 1000);
// Simulating async operation
function fetchUserData(userId, callback) {
console.log(`Fetching user ${userId}...`);
setTimeout(() => {
// Simulated data
const user = { id: userId, name: "John Doe" };
callback(null, user); // Error-first callback pattern
}, 1500);
}
fetchUserData(123, (error, user) => {
if (error) {
console.log("Error:", error);
return;
}
console.log("User:", user);
});
// Callback Hell (pyramid of doom)
// This is why we moved to Promises!
getUserData(userId, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getOrderDetails(orders[0].id, (err, details) => {
if (err) return handleError(err);
processPayment(details, (err, result) => {
if (err) return handleError(err);
console.log("Payment processed!");
// This is getting hard to read...
});
});
});
});
Promises
A Promise represents the eventual completion or failure of an async operation. It provides a cleaner alternative to callbacks.
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
resolve("Operation successful!");
} else {
reject(new Error("Operation failed!"));
}
}, 1000);
});
// Consuming a Promise
myPromise
.then(result => console.log(result))
.catch(error => console.log(error.message));
// Promise states: pending → fulfilled OR rejected
// Practical example: fetch data
function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) {
resolve({ id, name: `User ${id}` });
} else {
reject(new Error("Invalid user ID"));
}
}, 1000);
});
}
fetchUser(1)
.then(user => {
console.log("Got user:", user);
return user.id; // Return value for next .then()
})
.then(userId => {
console.log("User ID:", userId);
})
.catch(error => {
console.log("Error:", error.message);
})
.finally(() => {
console.log("Done!"); // Always runs
});
// Chaining Promises (solves callback hell)
getUserData(userId)
.then(user => getOrders(user.id))
.then(orders => getOrderDetails(orders[0].id))
.then(details => processPayment(details))
.then(result => console.log("Payment processed!"))
.catch(error => console.log("Error:", error.message));
Promise Static Methods
// Promise.resolve() - immediately resolved
const resolved = Promise.resolve("Already done!");
resolved.then(value => console.log(value));
// Promise.reject() - immediately rejected
const rejected = Promise.reject(new Error("Already failed!"));
rejected.catch(error => console.log(error.message));
// Promise.all() - wait for ALL promises (fails if any fails)
const promises = [
fetch("/api/users"),
fetch("/api/posts"),
fetch("/api/comments")
];
Promise.all(promises)
.then(responses => {
console.log("All loaded!", responses);
})
.catch(error => {
console.log("One failed:", error);
});
// Promise.allSettled() - wait for all, never rejects
Promise.allSettled(promises)
.then(results => {
results.forEach(result => {
if (result.status === "fulfilled") {
console.log("Success:", result.value);
} else {
console.log("Failed:", result.reason);
}
});
});
// Promise.race() - first to settle wins
const race = Promise.race([
new Promise(resolve => setTimeout(() => resolve("Fast"), 100)),
new Promise(resolve => setTimeout(() => resolve("Slow"), 500))
]);
race.then(winner => console.log(winner)); // "Fast"
// Promise.any() - first to RESOLVE wins (ignores rejections)
const any = Promise.any([
Promise.reject("Error 1"),
Promise.resolve("Success"),
Promise.reject("Error 2")
]);
any.then(value => console.log(value)); // "Success"
Async/Await
Async/await is syntactic sugar over Promises, making async code look and behave like synchronous code.
// async function always returns a Promise
async function greet() {
return "Hello!";
}
greet().then(msg => console.log(msg)); // "Hello!"
// await pauses execution until Promise resolves
async function fetchUserData(id) {
console.log("Fetching...");
// await pauses here until resolved
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
console.log("Done!");
return user;
}
// Using the async function
fetchUserData(1)
.then(user => console.log(user));
// Or with another async function
async function main() {
const user = await fetchUserData(1);
console.log(user);
}
// Error handling with try/catch
async function loadData() {
try {
const response = await fetch("/api/data");
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.log("Error loading data:", error.message);
return null;
}
}
// Sequential vs Parallel execution
async function sequential() {
// These run one after another - SLOW
const user = await fetchUser(); // 1 second
const posts = await fetchPosts(); // 1 second
const comments = await fetchComments(); // 1 second
// Total: 3 seconds
}
async function parallel() {
// These run at the same time - FAST
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments()
]);
// Total: ~1 second (longest request)
}
// Async arrow functions
const getData = async () => {
const response = await fetch("/api");
return response.json();
};
// Async in array methods - be careful!
const ids = [1, 2, 3];
// This won't work as expected!
ids.forEach(async (id) => {
const user = await fetchUser(id);
console.log(user); // Order not guaranteed!
});
// Use for...of for sequential
async function loadUsersSequential() {
for (const id of ids) {
const user = await fetchUser(id);
console.log(user);
}
}
// Or Promise.all for parallel
async function loadUsersParallel() {
const users = await Promise.all(
ids.map(id => fetchUser(id))
);
console.log(users);
}
Real-World Examples
// 1. Fetch API with async/await
async function getUsers() {
try {
const response = await fetch("https://jsonplaceholder.typicode.com/users");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const users = await response.json();
return users;
} catch (error) {
console.error("Failed to fetch users:", error);
throw error;
}
}
// 2. POST request with fetch
async function createUser(userData) {
const response = await fetch("/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(userData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message);
}
return response.json();
}
// 3. Retry logic
async function fetchWithRetry(url, retries = 3, delay = 1000) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url);
return response.json();
} catch (error) {
if (i === retries - 1) throw error;
console.log(`Retry ${i + 1}/${retries}...`);
await new Promise(r => setTimeout(r, delay));
}
}
}
// 4. Timeout wrapper
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Timeout")), ms);
});
return Promise.race([promise, timeout]);
}
// Usage
try {
const data = await withTimeout(fetch("/api/slow"), 5000);
} catch (error) {
if (error.message === "Timeout") {
console.log("Request timed out");
}
}
// 5. Loading state management
async function loadDataWithStatus(setStatus, setData) {
setStatus("loading");
try {
const data = await fetch("/api/data").then(r => r.json());
setData(data);
setStatus("success");
} catch (error) {
setStatus("error");
}
}
// 6. Debounced search
function debounce(fn, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
const searchUsers = debounce(async (query) => {
if (!query) return;
const users = await fetch(`/api/search?q=${query}`).then(r => r.json());
displayResults(users);
}, 300);
Common Async Patterns
// Pattern 1: Async IIFE
(async () => {
const data = await fetchData();
console.log(data);
})();
// Pattern 2: Top-level await (ES2022, modules only)
// In a module file:
const config = await loadConfig();
export { config };
// Pattern 3: Convert callback to Promise
function readFilePromise(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, 'utf8', (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
// Pattern 4: Async queue/semaphore
class AsyncQueue {
constructor(concurrency = 1) {
this.concurrency = concurrency;
this.running = 0;
this.queue = [];
}
async add(fn) {
if (this.running >= this.concurrency) {
await new Promise(resolve => this.queue.push(resolve));
}
this.running++;
try {
return await fn();
} finally {
this.running--;
if (this.queue.length > 0) {
this.queue.shift()();
}
}
}
}
const queue = new AsyncQueue(2); // Max 2 concurrent
// Pattern 5: Async iterator
async function* asyncGenerator() {
yield await fetchPage(1);
yield await fetchPage(2);
yield await fetchPage(3);
}
for await (const page of asyncGenerator()) {
console.log(page);
}
// Pattern 6: Cancellable fetch with AbortController
const controller = new AbortController();
async function fetchWithCancel(url) {
try {
const response = await fetch(url, {
signal: controller.signal
});
return response.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request was cancelled');
}
throw error;
}
}
// Cancel the request
controller.abort();
- Forgetting to
awaita Promise - Not handling Promise rejections
- Using
awaitin non-async functions - Sequential awaits when parallel is possible
- Not cancelling requests when components unmount
The Event Loop Deep Dive
Understanding the event loop is crucial for mastering async JavaScript. It's the mechanism that enables non-blocking I/O operations.
// The event loop processes tasks in this order:
// 1. Execute synchronous code (call stack)
// 2. Execute all microtasks (Promise callbacks, queueMicrotask)
// 3. Execute one macrotask (setTimeout, setInterval, I/O)
// 4. Repeat
console.log("1. Sync");
setTimeout(() => console.log("2. Macrotask (setTimeout)"), 0);
Promise.resolve().then(() => console.log("3. Microtask (Promise)"));
queueMicrotask(() => console.log("4. Microtask (queueMicrotask)"));
console.log("5. Sync");
// Output order:
// 1. Sync
// 5. Sync
// 3. Microtask (Promise)
// 4. Microtask (queueMicrotask)
// 2. Macrotask (setTimeout)
// Complex example showing task queue behavior
console.log("Start");
setTimeout(() => {
console.log("Timeout 1");
Promise.resolve().then(() => console.log("Promise inside Timeout 1"));
}, 0);
setTimeout(() => {
console.log("Timeout 2");
}, 0);
Promise.resolve()
.then(() => {
console.log("Promise 1");
return Promise.resolve();
})
.then(() => console.log("Promise 2"));
console.log("End");
// Output:
// Start
// End
// Promise 1
// Promise 2
// Timeout 1
// Promise inside Timeout 1
// Timeout 2
// Microtasks can starve the macrotask queue!
function recursiveMicrotask() {
Promise.resolve().then(() => {
console.log("Microtask");
recursiveMicrotask(); // DON'T do this! Infinite microtasks
});
}
// requestAnimationFrame - special timing
// Runs before the next repaint, great for animations
function animate() {
// Update animation state
element.style.left = position + "px";
if (position < 300) {
position++;
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
// Yielding to the event loop
async function processLargeArray(items) {
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
// Yield every 100 items to allow UI updates
if (i % 100 === 0) {
await new Promise(r => setTimeout(r, 0));
}
}
}
Microtasks vs Macrotasks
JavaScript has two task queues with different priorities. Understanding the difference prevents subtle timing bugs.
// MICROTASKS (higher priority, run immediately after sync code):
// - Promise.then/catch/finally
// - queueMicrotask()
// - MutationObserver
// MACROTASKS (lower priority, run one at a time):
// - setTimeout / setInterval
// - setImmediate (Node.js)
// - I/O operations
// - UI rendering
// - requestAnimationFrame
// Practical difference
document.body.innerHTML = "Step 1";
// Using microtask - user won't see "Step 1"
Promise.resolve().then(() => {
document.body.innerHTML = "Step 2"; // Runs before render
});
// Using macrotask - user might see "Step 1" briefly
setTimeout(() => {
document.body.innerHTML = "Step 3"; // Runs after render
}, 0);
// queueMicrotask for explicit microtask scheduling
queueMicrotask(() => {
// Useful when you need microtask timing without Promises
console.log("This is a microtask");
});
// MutationObserver uses microtasks
const observer = new MutationObserver((mutations) => {
console.log("DOM changed - this is a microtask callback");
});
// Node.js specific: process.nextTick vs setImmediate
// process.nextTick is even higher priority than microtasks!
process.nextTick(() => console.log("nextTick")); // First
Promise.resolve().then(() => console.log("Promise")); // Second
setImmediate(() => console.log("setImmediate")); // Last
// Scheduling for after DOM update
function afterDOMUpdate(callback) {
// Using double-requestAnimationFrame for reliable post-paint timing
requestAnimationFrame(() => {
requestAnimationFrame(callback);
});
}
Advanced Promise Methods
ES2020+ introduced powerful Promise methods for handling multiple async operations.
// Promise.all - Wait for ALL to resolve (fails fast on rejection)
const urls = ["/api/users", "/api/posts", "/api/comments"];
try {
const [users, posts, comments] = await Promise.all(
urls.map(url => fetch(url).then(r => r.json()))
);
console.log({ users, posts, comments });
} catch (error) {
console.error("One request failed:", error);
}
// Promise.allSettled - Wait for ALL to complete (never rejects)
const results = await Promise.allSettled([
fetch("/api/users"),
fetch("/api/posts"),
fetch("/api/fail") // This might fail
]);
results.forEach((result, index) => {
if (result.status === "fulfilled") {
console.log(`Request ${index} succeeded:`, result.value);
} else {
console.log(`Request ${index} failed:`, result.reason);
}
});
// Filter successful results
const successfulData = results
.filter(r => r.status === "fulfilled")
.map(r => r.value);
// Promise.race - First to settle (resolve OR reject) wins
const timeout = (ms) => new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), ms)
);
try {
const data = await Promise.race([
fetch("/api/slow-endpoint").then(r => r.json()),
timeout(5000)
]);
console.log("Got data:", data);
} catch (error) {
console.error("Request timed out");
}
// Promise.any - First to RESOLVE wins (ES2021)
const mirrors = [
"https://mirror1.example.com/data",
"https://mirror2.example.com/data",
"https://mirror3.example.com/data"
];
try {
const fastestResponse = await Promise.any(
mirrors.map(url => fetch(url))
);
console.log("Got response from fastest mirror");
} catch (error) {
// AggregateError - all promises rejected
console.error("All mirrors failed:", error.errors);
}
// Promise.withResolvers (ES2024)
// Creates a promise with exposed resolve/reject functions
const { promise, resolve, reject } = Promise.withResolvers();
// Useful for converting callback APIs
function loadImage(src) {
const { promise, resolve, reject } = Promise.withResolvers();
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load: ${src}`));
img.src = src;
return promise;
}
// Combining patterns
async function fetchWithRetry(url, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await Promise.race([
fetch(url),
timeout(5000)
]);
} catch (error) {
if (i === retries - 1) throw error;
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}
}
}
Async Iterators and Generators
Process async data streams elegantly with async iteration, perfect for paginated APIs and real-time data.
// Async generator function
async function* fetchPages(baseUrl) {
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await fetch(`${baseUrl}?page=${page}`);
const data = await response.json();
yield data.items;
hasMore = data.hasNextPage;
page++;
}
}
// Using for-await-of
async function getAllItems() {
const allItems = [];
for await (const items of fetchPages("/api/products")) {
allItems.push(...items);
console.log(`Fetched ${items.length} items`);
}
return allItems;
}
// Async iterable object
const asyncIterable = {
async *[Symbol.asyncIterator]() {
for (let i = 1; i <= 5; i++) {
await new Promise(r => setTimeout(r, 1000));
yield i;
}
}
};
for await (const num of asyncIterable) {
console.log(num); // 1, 2, 3, 4, 5 (one per second)
}
// Real-time data stream
async function* streamMessages(websocketUrl) {
const ws = new WebSocket(websocketUrl);
const messages = [];
let resolve;
ws.onmessage = (event) => {
messages.push(JSON.parse(event.data));
resolve?.();
};
try {
while (true) {
if (messages.length === 0) {
await new Promise(r => resolve = r);
}
yield messages.shift();
}
} finally {
ws.close();
}
}
// Using the stream
for await (const message of streamMessages("ws://example.com/chat")) {
console.log("New message:", message);
if (message.type === "end") break;
}
// Converting callback streams to async iterators
function nodeStreamToAsyncIterator(readableStream) {
return {
async *[Symbol.asyncIterator]() {
for await (const chunk of readableStream) {
yield chunk;
}
}
};
}
// Parallel async iteration with pool limit
async function* parallelMap(items, fn, concurrency = 3) {
const executing = new Set();
for (const item of items) {
const promise = fn(item).then(result => {
executing.delete(promise);
return result;
});
executing.add(promise);
if (executing.size >= concurrency) {
yield await Promise.race(executing);
}
}
while (executing.size > 0) {
yield await Promise.race(executing);
}
}
Async Patterns Cheat Sheet
- Sequential:
for (const item of items) await process(item) - Parallel all:
await Promise.all(items.map(process)) - Parallel limited: Use a concurrency pool pattern
- First wins:
await Promise.race([...]) - All results:
await Promise.allSettled([...])
Summary
Callbacks
Functions passed to be called later; can lead to "callback hell"
Promises
Represent eventual completion; chain with .then()/.catch()
async/await
Cleaner syntax; use try/catch for errors
Promise.all
Run multiple promises in parallel
Error Handling
Always .catch() or try/catch async code
AbortController
Cancel fetch requests when needed