Understanding Asynchronous Programming
Asynchronous programming is fundamental to Node.js. It allows Node.js to handle multiple operations concurrently without blocking the execution thread, making it perfect for I/O-intensive applications.
Key Concept: Node.js is single-threaded but uses asynchronous operations to handle concurrent tasks efficiently through the event loop.
The Event Loop
The event loop is the heart of Node.js's asynchronous architecture. It allows Node.js to perform non-blocking operations despite JavaScript being single-threaded.
How the Event Loop Works
console.log('Start');
setTimeout(() => {
console.log('Timeout callback');
}, 0);
Promise.resolve().then(() => {
console.log('Promise callback');
});
console.log('End');
/* Output order:
Start
End
Promise callback
Timeout callback
*/
Execution Order Explanation:
- Synchronous code runs first: "Start" and "End"
- Microtasks (Promises) run next: "Promise callback"
- Macrotasks (setTimeout, setInterval) run last: "Timeout callback"
Tip: Promises have higher priority than setTimeout/setInterval in the event loop. They execute in the microtask queue before macrotasks.
Callbacks: The Traditional Approach
Callbacks are functions passed as arguments to be executed later when an operation completes:
const fs = require('fs');
// Reading a file with callback
fs.readFile('data.txt', 'utf8', (error, data) => {
if (error) {
console.error('Error reading file:', error);
return;
}
console.log('File contents:', data);
});
// Multiple operations
function fetchUser(id, callback) {
setTimeout(() => {
const user = { id, name: 'John Doe' };
callback(null, user);
}, 1000);
}
fetchUser(1, (error, user) => {
if (error) {
console.error('Error:', error);
return;
}
console.log('User:', user);
});
Callback Hell (Pyramid of Doom)
Nested callbacks can quickly become difficult to read and maintain:
// BAD: Callback hell example
fs.readFile('user.json', 'utf8', (err, userData) => {
if (err) return console.error(err);
const user = JSON.parse(userData);
fs.readFile(`posts/${user.id}.json`, 'utf8', (err, postsData) => {
if (err) return console.error(err);
const posts = JSON.parse(postsData);
fs.readFile(`comments/${posts[0].id}.json`, 'utf8', (err, commentsData) => {
if (err) return console.error(err);
const comments = JSON.parse(commentsData);
console.log(comments);
// This keeps nesting deeper and deeper...
});
});
});
Problem: Callback hell leads to code that's hard to read, debug, and maintain. Promises and async/await solve this problem.
Promises: A Better Approach
Promises represent the eventual completion (or failure) of an asynchronous operation and its resulting value.
Creating Promises
// Creating a Promise
function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id < 1) {
reject(new Error('Invalid user ID'));
} else {
resolve({ id, name: 'John Doe', email: 'john@example.com' });
}
}, 1000);
});
}
// Using the Promise
fetchUser(1)
.then(user => {
console.log('User:', user);
return user.id;
})
.then(userId => {
console.log('User ID:', userId);
})
.catch(error => {
console.error('Error:', error.message);
})
.finally(() => {
console.log('Operation complete');
});
Promise States
A Promise has three states:
- Pending: Initial state, neither fulfilled nor rejected
- Fulfilled: Operation completed successfully (resolve called)
- Rejected: Operation failed (reject called)
Chaining Promises
// Better than callback hell
function readFilePromise(filename) {
return new Promise((resolve, reject) => {
fs.readFile(filename, 'utf8', (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
// Chained promises
readFilePromise('user.json')
.then(userData => {
const user = JSON.parse(userData);
return readFilePromise(`posts/${user.id}.json`);
})
.then(postsData => {
const posts = JSON.parse(postsData);
return readFilePromise(`comments/${posts[0].id}.json`);
})
.then(commentsData => {
const comments = JSON.parse(commentsData);
console.log('Comments:', comments);
})
.catch(error => {
console.error('Error in chain:', error);
});
Async/Await: The Modern Approach
Async/await is syntactic sugar built on top of Promises, making asynchronous code look and behave like synchronous code.
Basic Async/Await
// Async function always returns a Promise
async function getUser(id) {
// Simulating async operation
const user = { id, name: 'John Doe' };
return user; // Automatically wrapped in Promise.resolve()
}
// Using await
async function displayUser() {
try {
const user = await getUser(1);
console.log('User:', user);
} catch (error) {
console.error('Error:', error);
}
}
displayUser();
Error Handling with Try/Catch
const fs = require('fs').promises; // Promise-based fs API
async function readAndProcessFile(filename) {
try {
// await pauses execution until Promise resolves
const data = await fs.readFile(filename, 'utf8');
const parsed = JSON.parse(data);
console.log('File contents:', parsed);
return parsed;
} catch (error) {
// Catches both fs errors and JSON.parse errors
console.error('Error:', error.message);
throw error; // Re-throw if needed
} finally {
console.log('File operation complete');
}
}
// Call async function
readAndProcessFile('data.json')
.then(result => console.log('Success:', result))
.catch(error => console.error('Failed:', error));
Best Practice: Always use try/catch blocks with async/await to handle errors properly. Unhandled promise rejections can crash your application.
Sequential vs Parallel Execution
// SEQUENTIAL: Operations run one after another (slow)
async function sequential() {
console.time('Sequential');
const user = await fetchUser(1); // Wait 1 second
const posts = await fetchPosts(1); // Wait 1 second
const comments = await fetchComments(1); // Wait 1 second
console.timeEnd('Sequential'); // ~3 seconds
return { user, posts, comments };
}
// PARALLEL: Operations run simultaneously (fast)
async function parallel() {
console.time('Parallel');
// Start all operations at once
const [user, posts, comments] = await Promise.all([
fetchUser(1),
fetchPosts(1),
fetchComments(1)
]);
console.timeEnd('Parallel'); // ~1 second (fastest operation)
return { user, posts, comments };
}
Promise.all() - Wait for All
Execute multiple promises in parallel and wait for all to complete:
const promise1 = Promise.resolve(3);
const promise2 = new Promise(resolve => setTimeout(() => resolve('foo'), 1000));
const promise3 = fetch('https://api.example.com/data');
// Wait for all promises
Promise.all([promise1, promise2, promise3])
.then(results => {
console.log('All results:', results);
// [3, 'foo', Response object]
})
.catch(error => {
// If ANY promise rejects, catch is called
console.error('One promise failed:', error);
});
// Using with async/await
async function fetchAllData() {
try {
const [users, posts, comments] = await Promise.all([
fetch('/api/users').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/comments').then(r => r.json())
]);
return { users, posts, comments };
} catch (error) {
console.error('Failed to fetch all data:', error);
}
}
Warning: Promise.all() fails fast. If any promise rejects, the entire operation fails immediately.
Promise.allSettled() - Wait Regardless
Wait for all promises to complete regardless of success or failure:
const promises = [
Promise.resolve('Success 1'),
Promise.reject('Error 1'),
Promise.resolve('Success 2'),
Promise.reject('Error 2')
];
Promise.allSettled(promises)
.then(results => {
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Promise ${index} succeeded:`, result.value);
} else {
console.log(`Promise ${index} failed:`, result.reason);
}
});
});
/* Output:
Promise 0 succeeded: Success 1
Promise 1 failed: Error 1
Promise 2 succeeded: Success 2
Promise 3 failed: Error 2
*/
Promise.race() - First to Complete
Returns the result of the first promise that completes (success or failure):
const slow = new Promise(resolve => setTimeout(() => resolve('slow'), 3000));
const fast = new Promise(resolve => setTimeout(() => resolve('fast'), 1000));
Promise.race([slow, fast])
.then(result => {
console.log('Winner:', result); // 'fast'
});
// Practical use: Timeout pattern
async function fetchWithTimeout(url, timeout = 5000) {
const fetchPromise = fetch(url);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Request timeout')), timeout);
});
try {
const response = await Promise.race([fetchPromise, timeoutPromise]);
return response;
} catch (error) {
console.error('Fetch failed:', error.message);
throw error;
}
}
Promise.any() - First Successful
Returns the first promise that successfully resolves:
const promises = [
Promise.reject('Error 1'),
new Promise(resolve => setTimeout(() => resolve('Success 2'), 1000)),
new Promise(resolve => setTimeout(() => resolve('Success 3'), 500))
];
Promise.any(promises)
.then(result => {
console.log('First success:', result); // 'Success 3'
})
.catch(error => {
// Only called if ALL promises reject
console.error('All failed:', error);
});
Practice Exercise
- Create a function that returns a Promise which resolves after a delay
- Convert this function to use async/await
- Create three async functions that simulate API calls with different delays
- Use
Promise.all() to run them in parallel and measure the time
- Implement error handling with try/catch
- Use
Promise.race() to implement a timeout for a fetch request
- Create a function that retries a failed async operation 3 times before giving up
Summary
In this lesson, you learned:
- How the event loop enables asynchronous operations in Node.js
- Callbacks and the callback hell problem
- Promises: creating, chaining, and error handling
- Async/await syntax for cleaner asynchronous code
- Error handling with try/catch blocks
- Running parallel operations with
Promise.all()
- Different Promise combinators:
allSettled(), race(), any()
- Sequential vs parallel execution patterns