Introduction
Asynchronous programming is essential for modern JavaScript development. It allows your code to perform time-consuming operations like fetching data from servers, reading files, or waiting for user input without freezing the entire application. In this lesson, you'll master Promises, async/await, and learn how to handle asynchronous operations elegantly.
Learning Objectives
By the end of this lesson, you will be able to:
- Understand synchronous vs asynchronous code execution
- Work with callbacks and avoid callback hell
- Master Promises and promise chaining
- Use async/await for cleaner asynchronous code
- Handle errors in asynchronous operations
- Understand the event loop and task queue
Prerequisites
- Completion of Lessons 01-03
- Strong understanding of functions and callbacks
- Familiarity with array methods
Estimated Time
4 hours (including practice exercises)
Synchronous vs Asynchronous
Understanding the difference between synchronous and asynchronous code is fundamental:
Synchronous Code
// Synchronous - executes line by line
console.log('First');
console.log('Second');
console.log('Third');
// Output:
// First
// Second
// Third
Asynchronous Code
// Asynchronous - doesn't block execution
console.log('First');
setTimeout(() => {
console.log('Second (after 1 second)');
}, 1000);
console.log('Third');
// Output:
// First
// Third
// Second (after 1 second)
Callbacks
Callbacks are functions passed as arguments to be executed later:
// Simple callback example
function fetchUser(id, callback) {
setTimeout(() => {
const user = { id: id, name: 'Alice' };
callback(user);
}, 1000);
}
fetchUser(1, (user) => {
console.log('User:', user);
});
// Callback with error handling
function fetchData(url, onSuccess, onError) {
setTimeout(() => {
const success = Math.random() > 0.5;
if (success) {
onSuccess({ data: 'Some data' });
} else {
onError(new Error('Failed to fetch'));
}
}, 1000);
}
fetchData(
'/api/data',
(data) => console.log('Success:', data),
(error) => console.error('Error:', error)
);
Callback Hell
Nested callbacks can become difficult to read and maintain:
// Callback hell - hard to read and maintain
fetchUser(1, (user) => {
fetchPosts(user.id, (posts) => {
fetchComments(posts[0].id, (comments) => {
fetchLikes(comments[0].id, (likes) => {
console.log('Finally got likes:', likes);
});
});
});
});
Promises
A Promise represents a value that may be available now, in the future, or never. It has three states: pending, fulfilled, or rejected.
Creating Promises
// Basic Promise creation
const myPromise = new Promise((resolve, reject) => {
// Simulate async operation
setTimeout(() => {
const success = true;
if (success) {
resolve('Operation successful!');
} else {
reject('Operation failed!');
}
}, 1000);
});
// Using the Promise
myPromise
.then(result => {
console.log(result); // "Operation successful!"
})
.catch(error => {
console.error(error);
});
// Practical example: Fetch user data
function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) {
resolve({ id: id, name: 'Alice', email: '[email protected]' });
} else {
reject(new Error('Invalid user ID'));
}
}, 1000);
});
}
fetchUser(1)
.then(user => console.log('User:', user))
.catch(error => console.error('Error:', error));
Promise Chaining
// Chain multiple async operations
function fetchUser(id) {
return new Promise(resolve => {
setTimeout(() => {
resolve({ id: id, name: 'Alice' });
}, 500);
});
}
function fetchPosts(userId) {
return new Promise(resolve => {
setTimeout(() => {
resolve([
{ id: 1, title: 'Post 1', userId: userId },
{ id: 2, title: 'Post 2', userId: userId }
]);
}, 500);
});
}
function fetchComments(postId) {
return new Promise(resolve => {
setTimeout(() => {
resolve([
{ id: 1, text: 'Great post!', postId: postId },
{ id: 2, text: 'Thanks for sharing', postId: postId }
]);
}, 500);
});
}
// Clean promise chain (no callback hell!)
fetchUser(1)
.then(user => {
console.log('User:', user);
return fetchPosts(user.id);
})
.then(posts => {
console.log('Posts:', posts);
return fetchComments(posts[0].id);
})
.then(comments => {
console.log('Comments:', comments);
})
.catch(error => {
console.error('Error:', error);
});
Async/Await
Async/await is syntactic sugar over Promises, making asynchronous code look and behave more like synchronous code:
Basic Async/Await
// Using async/await
async function getUserData() {
try {
const user = await fetchUser(1);
console.log('User:', user);
const posts = await fetchPosts(user.id);
console.log('Posts:', posts);
const comments = await fetchComments(posts[0].id);
console.log('Comments:', comments);
return comments;
} catch (error) {
console.error('Error:', error);
}
}
// Call async function
getUserData();
// Async function always returns a Promise
getUserData().then(comments => {
console.log('All done!', comments);
});
Error Handling with Async/Await
// Proper error handling
async function fetchDataSafely() {
try {
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Failed to fetch data:', error);
throw error; // Re-throw if needed
}
}
// Multiple try-catch blocks
async function processData() {
let user;
try {
user = await fetchUser(1);
} catch (error) {
console.error('Failed to fetch user:', error);
return; // Exit early
}
try {
const posts = await fetchPosts(user.id);
console.log('Posts:', posts);
} catch (error) {
console.error('Failed to fetch posts:', error);
// Continue execution
}
console.log('Function completed');
}
Promise Methods
Promise.all()
Run multiple promises concurrently and wait for all to complete:
// Run promises in parallel
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())
]);
console.log('Users:', users);
console.log('Posts:', posts);
console.log('Comments:', comments);
} catch (error) {
console.error('One of the requests failed:', error);
}
}
// If any promise rejects, Promise.all() rejects immediately
Promise.all([
Promise.resolve(1),
Promise.reject('Error!'),
Promise.resolve(3)
])
.then(results => console.log(results))
.catch(error => console.error(error)); // "Error!"
Promise.race()
Returns the first promise that settles (resolves or rejects):
// Timeout pattern with Promise.race()
function timeout(ms) {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error('Timeout')), ms);
});
}
async function fetchWithTimeout(url, ms) {
try {
const response = await Promise.race([
fetch(url),
timeout(ms)
]);
return await response.json();
} catch (error) {
console.error('Request failed or timed out:', error);
}
}
// Use it
fetchWithTimeout('/api/data', 5000); // 5 second timeout
Promise.allSettled()
Wait for all promises to settle, regardless of success or failure:
// Get results from all promises, even if some fail
async function fetchMultipleResources() {
const results = await Promise.allSettled([
fetch('/api/users').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/invalid').then(r => r.json()) // This will fail
]);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Request ${index} succeeded:`, result.value);
} else {
console.log(`Request ${index} failed:`, result.reason);
}
});
}
// Output:
// Request 0 succeeded: [users data]
// Request 1 succeeded: [posts data]
// Request 2 failed: Error: 404 Not Found
Promise.any()
Returns the first promise that fulfills:
// Try multiple servers, use first successful response
async function fetchFromMultipleServers() {
try {
const data = await Promise.any([
fetch('https://api1.example.com/data'),
fetch('https://api2.example.com/data'),
fetch('https://api3.example.com/data')
]);
return await data.json();
} catch (error) {
console.error('All requests failed:', error);
}
}
The Event Loop
Understanding how JavaScript handles asynchronous operations:
// Event loop demonstration
console.log('1: Synchronous');
setTimeout(() => {
console.log('2: Timeout (macrotask)');
}, 0);
Promise.resolve().then(() => {
console.log('3: Promise (microtask)');
});
console.log('4: Synchronous');
// Output order:
// 1: Synchronous
// 4: Synchronous
// 3: Promise (microtask)
// 2: Timeout (macrotask)
// Microtasks (Promises) execute before macrotasks (setTimeout)
2. Execute all microtasks (Promises)
3. Execute one macrotask (setTimeout, setInterval)
4. Repeat
Practical Example: Data Fetching Service
// Complete data fetching service with caching
class DataService {
constructor() {
this.cache = new Map();
}
async fetchWithCache(url, ttl = 60000) {
// Check cache
const cached = this.cache.get(url);
if (cached && Date.now() - cached.timestamp < ttl) {
console.log('Returning cached data');
return cached.data;
}
// Fetch fresh data
try {
console.log('Fetching fresh data');
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
// Update cache
this.cache.set(url, {
data: data,
timestamp: Date.now()
});
return data;
} catch (error) {
console.error('Fetch failed:', error);
// Return stale cache if available
if (cached) {
console.log('Returning stale cache');
return cached.data;
}
throw error;
}
}
async fetchMultiple(urls) {
const promises = urls.map(url => this.fetchWithCache(url));
return await Promise.allSettled(promises);
}
clearCache() {
this.cache.clear();
}
}
// Usage
const service = new DataService();
async function loadDashboard() {
try {
const [users, posts, stats] = await Promise.all([
service.fetchWithCache('/api/users'),
service.fetchWithCache('/api/posts'),
service.fetchWithCache('/api/stats')
]);
console.log('Dashboard loaded:', { users, posts, stats });
} catch (error) {
console.error('Failed to load dashboard:', error);
}
}
loadDashboard();
Key Takeaways
- Asynchronous code doesn't block execution
- Promises represent eventual completion of async operations
- Async/await provides cleaner syntax than promise chains
- Always use try/catch with async/await for error handling
- Promise.all() runs multiple promises concurrently
- Promise.allSettled() waits for all promises regardless of outcome
- Microtasks (Promises) execute before macrotasks (setTimeout)
Practice Exercises
Exercise 1: Promise Creation
Create a function that simulates a dice roll with a delay:
// Create a function rollDice() that:
// 1. Returns a Promise
// 2. Waits 1 second
// 3. Resolves with a random number 1-6
// 4. Rejects if the number is 1 (unlucky!)
function rollDice() {
// Your code here
}
// Test it
rollDice()
.then(result => console.log('Rolled:', result))
.catch(error => console.log('Unlucky!', error));
Exercise 2: Async/Await Conversion
Convert this promise chain to async/await:
// Convert to async/await
function getFullUserData(userId) {
return fetchUser(userId)
.then(user => {
return fetchPosts(user.id)
.then(posts => {
return { user, posts };
});
})
.catch(error => {
console.error('Error:', error);
});
}
// Your async/await version:
async function getFullUserData(userId) {
// Your code here
}
Exercise 3: Parallel Requests
Fetch data from multiple APIs concurrently:
// Fetch user, posts, and comments in parallel
// Calculate total time taken
// Handle errors gracefully
async function fetchAllUserData(userId) {
// Your code here
// Hint: Use Promise.all() and console.time()
}
Exercise 4: Retry Logic
Implement a function that retries failed requests:
// Create a retry function that:
// 1. Tries to execute an async function
// 2. Retries up to maxRetries times if it fails
// 3. Waits delay ms between retries
// 4. Throws error if all retries fail
async function retry(fn, maxRetries = 3, delay = 1000) {
// Your code here
}
// Test it
retry(() => fetch('/api/unreliable'), 3, 1000)
.then(response => console.log('Success!'))
.catch(error => console.log('All retries failed'));
Common Pitfalls
1. Forgetting to await
// Wrong - missing await
async function getData() {
const data = fetchUser(1); // Returns Promise, not data!
console.log(data.name); // undefined
}
// Correct
async function getData() {
const data = await fetchUser(1);
console.log(data.name); // Works!
}
2. Not handling errors
// Wrong - unhandled promise rejection
async function riskyOperation() {
const data = await fetch('/api/data'); // Might fail!
return data.json();
}
// Correct
async function riskyOperation() {
try {
const data = await fetch('/api/data');
return await data.json();
} catch (error) {
console.error('Operation failed:', error);
throw error;
}
}
3. Sequential instead of parallel
// Slow - sequential (4 seconds total)
async function fetchSequential() {
const users = await fetchUsers(); // 2 seconds
const posts = await fetchPosts(); // 2 seconds
return { users, posts };
}
// Fast - parallel (2 seconds total)
async function fetchParallel() {
const [users, posts] = await Promise.all([
fetchUsers(), // 2 seconds
fetchPosts() // 2 seconds (runs simultaneously)
]);
return { users, posts };
}
Additional Resources
What's Next?
In Lesson 05, you'll learn how to work with external APIs using the Fetch API. You'll make HTTP requests, handle JSON data, work with query parameters and headers, and build complete API integrations for real-world applications.