Skip to main content

Chapter 5: JavaScript Async Programming - Complete Guide to Promises and Async/Await

Asynchronous programming is a fundamental concept in JavaScript that allows your code to handle operations that take time to complete without blocking the execution of other code. Understanding async programming is crucial for building responsive web applications, handling API calls, and managing user interactions.

Why Async Programming Matters in JavaScript

Asynchronous programming in JavaScript is essential because it:

  • Prevents Blocking: Keeps the UI responsive during long-running operations
  • Handles I/O Operations: Manages file reads, network requests, and database queries
  • Enables Concurrency: Allows multiple operations to run simultaneously
  • Improves User Experience: Provides smooth, non-blocking interactions
  • Supports Modern Web APIs: Works with fetch, timers, and event handlers

Learning Objectives

Through this chapter, you will master:

  • Callback functions and callback hell
  • Promise creation and chaining
  • Async/await syntax and error handling
  • Event loop and execution model
  • Error handling in async operations
  • Modern async patterns and best practices

Callbacks

Basic Callback Pattern

// Simple callback function
function greet(name, callback) {
console.log(`Hello, ${name}!`);
callback();
}

function sayGoodbye() {
console.log("Goodbye!");
}

greet("Alice", sayGoodbye);
// Output:
// Hello, Alice!
// Goodbye!

// Inline callback
greet("Bob", function() {
console.log("See you later!");
});

// Arrow function callback
greet("Charlie", () => {
console.log("Have a great day!");
});

Asynchronous Callbacks

// Simulating asynchronous operation with setTimeout
function fetchData(callback) {
console.log("Fetching data...");

setTimeout(() => {
const data = { id: 1, name: "John Doe", email: "[email protected]" };
console.log("Data fetched successfully");
callback(null, data); // First parameter is error, second is data
}, 2000);
}

function handleData(error, data) {
if (error) {
console.error("Error:", error);
} else {
console.log("Received data:", data);
}
}

fetchData(handleData);
console.log("This runs immediately, before data is fetched");

Callback Hell Problem

// Callback hell - nested callbacks become hard to read and maintain
function getUserData(userId, callback) {
setTimeout(() => {
const user = { id: userId, name: "John" };
callback(null, user);
}, 1000);
}

function getUserPosts(userId, callback) {
setTimeout(() => {
const posts = [{ id: 1, title: "Post 1" }, { id: 2, title: "Post 2" }];
callback(null, posts);
}, 1000);
}

function getPostComments(postId, callback) {
setTimeout(() => {
const comments = [{ id: 1, text: "Great post!" }];
callback(null, comments);
}, 1000);
}

// Callback hell example
getUserData(1, (err, user) => {
if (err) {
console.error("Error getting user:", err);
return;
}

console.log("User:", user);

getUserPosts(user.id, (err, posts) => {
if (err) {
console.error("Error getting posts:", err);
return;
}

console.log("Posts:", posts);

getPostComments(posts[0].id, (err, comments) => {
if (err) {
console.error("Error getting comments:", err);
return;
}

console.log("Comments:", comments);
// This nesting can continue indefinitely...
});
});
});

Promises

Creating Promises

// Basic promise creation
const myPromise = new Promise((resolve, reject) => {
// Simulate async operation
setTimeout(() => {
const success = Math.random() > 0.5;

if (success) {
resolve("Operation completed successfully!");
} else {
reject(new Error("Operation failed!"));
}
}, 1000);
});

// Using the promise
myPromise
.then(result => {
console.log("Success:", result);
})
.catch(error => {
console.error("Error:", error.message);
});

// Promise with different states
const pendingPromise = new Promise((resolve, reject) => {
// This promise will remain pending
console.log("Promise created, but not resolved or rejected");
});

console.log("Promise state:", pendingPromise); // Promise { <pending> }

Promise Methods

// Promise.resolve() - creates resolved promise
const resolvedPromise = Promise.resolve("Immediate success");
resolvedPromise.then(result => console.log(result)); // Immediate success

// Promise.reject() - creates rejected promise
const rejectedPromise = Promise.reject(new Error("Immediate failure"));
rejectedPromise.catch(error => console.error(error.message)); // Immediate failure

// Promise.all() - wait for all promises to resolve
const promise1 = Promise.resolve("First");
const promise2 = Promise.resolve("Second");
const promise3 = Promise.resolve("Third");

Promise.all([promise1, promise2, promise3])
.then(results => {
console.log("All resolved:", results); // ['First', 'Second', 'Third']
})
.catch(error => {
console.error("One failed:", error);
});

// Promise.allSettled() - wait for all promises to settle (resolve or reject)
const mixedPromises = [
Promise.resolve("Success"),
Promise.reject(new Error("Failure")),
Promise.resolve("Another success")
];

Promise.allSettled(mixedPromises)
.then(results => {
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Promise ${index}:`, result.value);
} else {
console.log(`Promise ${index} failed:`, result.reason.message);
}
});
});

// Promise.race() - resolve with first settled promise
const fastPromise = new Promise(resolve => setTimeout(() => resolve("Fast"), 500));
const slowPromise = new Promise(resolve => setTimeout(() => resolve("Slow"), 2000));

Promise.race([fastPromise, slowPromise])
.then(result => {
console.log("Winner:", result); // Winner: Fast
});

Promise Chaining

// Converting callback-based functions to promise-based
function fetchUserData(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const user = { id: userId, name: "John Doe", email: "[email protected]" };
resolve(user);
}, 1000);
});
}

function fetchUserPosts(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const posts = [
{ id: 1, title: "First Post", userId: userId },
{ id: 2, title: "Second Post", userId: userId }
];
resolve(posts);
}, 1000);
});
}

function fetchPostComments(postId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const comments = [
{ id: 1, text: "Great post!", postId: postId },
{ id: 2, text: "Thanks for sharing!", postId: postId }
];
resolve(comments);
}, 1000);
});
}

// Promise chaining - much cleaner than callbacks
fetchUserData(1)
.then(user => {
console.log("User:", user);
return fetchUserPosts(user.id);
})
.then(posts => {
console.log("Posts:", posts);
return fetchPostComments(posts[0].id);
})
.then(comments => {
console.log("Comments:", comments);
})
.catch(error => {
console.error("Error in chain:", error);
});

// Chaining with data transformation
fetchUserData(1)
.then(user => {
console.log("Original user:", user);
// Transform data
return {
...user,
displayName: user.name.toUpperCase(),
isActive: true
};
})
.then(transformedUser => {
console.log("Transformed user:", transformedUser);
})
.catch(error => {
console.error("Error:", error);
});

Async/Await

Basic Async/Await Syntax

// Converting promise-based code to async/await
async function fetchUserDataAsync(userId) {
try {
const user = await fetchUserData(userId);
console.log("User:", user);

const posts = await fetchUserPosts(user.id);
console.log("Posts:", posts);

const comments = await fetchPostComments(posts[0].id);
console.log("Comments:", comments);

return { user, posts, comments };
} catch (error) {
console.error("Error:", error);
throw error; // Re-throw if needed
}
}

// Using the async function
fetchUserDataAsync(1)
.then(result => {
console.log("Complete result:", result);
})
.catch(error => {
console.error("Final error:", error);
});

// Async function with error handling
async function processUserData(userId) {
try {
const user = await fetchUserData(userId);

if (!user) {
throw new Error("User not found");
}

const posts = await fetchUserPosts(user.id);
const comments = await fetchPostComments(posts[0]?.id);

return {
user: {
...user,
postCount: posts.length,
commentCount: comments.length
},
posts,
comments
};
} catch (error) {
console.error("Error processing user data:", error.message);
return null;
}
}

Parallel Execution with Async/Await

// Sequential execution (slower)
async function fetchDataSequential() {
const start = Date.now();

const user = await fetchUserData(1);
const posts = await fetchUserPosts(1);
const comments = await fetchPostComments(1);

const end = Date.now();
console.log(`Sequential execution took: ${end - start}ms`);

return { user, posts, comments };
}

// Parallel execution (faster)
async function fetchDataParallel() {
const start = Date.now();

// Start all promises simultaneously
const [user, posts, comments] = await Promise.all([
fetchUserData(1),
fetchUserPosts(1),
fetchPostComments(1)
]);

const end = Date.now();
console.log(`Parallel execution took: ${end - start}ms`);

return { user, posts, comments };
}

// Mixed approach - some parallel, some sequential
async function fetchDataMixed() {
const start = Date.now();

// Fetch user first
const user = await fetchUserData(1);

// Then fetch posts and comments in parallel
const [posts, comments] = await Promise.all([
fetchUserPosts(user.id),
fetchPostComments(1) // Assuming we have a post ID
]);

const end = Date.now();
console.log(`Mixed execution took: ${end - start}ms`);

return { user, posts, comments };
}

Error Handling in Async/Await

// Multiple error handling approaches
async function robustDataFetching(userId) {
// Approach 1: Try-catch with specific error handling
try {
const user = await fetchUserData(userId);

if (!user) {
throw new Error(`User with ID ${userId} not found`);
}

return user;
} catch (error) {
if (error.message.includes('not found')) {
console.error("User not found:", error.message);
return null;
} else {
console.error("Unexpected error:", error.message);
throw error;
}
}
}

// Approach 2: Using Promise.allSettled for partial failures
async function fetchMultipleUsers(userIds) {
const promises = userIds.map(id => fetchUserData(id));
const results = await Promise.allSettled(promises);

const successful = [];
const failed = [];

results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successful.push(result.value);
} else {
failed.push({ userId: userIds[index], error: result.reason });
}
});

return { successful, failed };
}

// Approach 3: Retry mechanism
async function fetchWithRetry(fetchFunction, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fetchFunction();
} catch (error) {
console.log(`Attempt ${attempt} failed:`, error.message);

if (attempt === maxRetries) {
throw new Error(`Failed after ${maxRetries} attempts: ${error.message}`);
}

// Wait before retrying (exponential backoff)
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}
}
}

// Using the retry mechanism
async function reliableDataFetch() {
try {
const user = await fetchWithRetry(() => fetchUserData(1));
console.log("Successfully fetched user:", user);
} catch (error) {
console.error("Failed to fetch user after retries:", error.message);
}
}

Event Loop and Execution Model

Understanding the Event Loop

// Demonstrating the event loop
console.log("1. Synchronous code starts");

setTimeout(() => {
console.log("4. setTimeout callback (macrotask)");
}, 0);

Promise.resolve().then(() => {
console.log("3. Promise callback (microtask)");
});

console.log("2. Synchronous code ends");

// Output order:
// 1. Synchronous code starts
// 2. Synchronous code ends
// 3. Promise callback (microtask)
// 4. setTimeout callback (macrotask)

// More complex example
console.log("Start");

setTimeout(() => console.log("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
// Timeout 2

Microtasks vs Macrotasks

// Microtasks (higher priority)
Promise.resolve().then(() => console.log("Microtask 1"));
queueMicrotask(() => console.log("Microtask 2"));

// Macrotasks (lower priority)
setTimeout(() => console.log("Macrotask 1"), 0);
setInterval(() => console.log("Macrotask 2"), 1000);

// The event loop processes all microtasks before any macrotasks
console.log("Synchronous");

// Output:
// Synchronous
// Microtask 1
// Microtask 2
// Macrotask 1
// (Macrotask 2 will repeat every second)

Modern Async Patterns

Async Iterators and Generators

// Async generator function
async function* asyncDataGenerator() {
for (let i = 1; i <= 3; i++) {
// Simulate async operation
await new Promise(resolve => setTimeout(resolve, 1000));
yield { id: i, data: `Data ${i}` };
}
}

// Using async iterator
async function processAsyncData() {
for await (const item of asyncDataGenerator()) {
console.log("Received:", item);
}
}

processAsyncData();

// Async generator with error handling
async function* robustAsyncGenerator() {
try {
for (let i = 1; i <= 5; i++) {
if (i === 3) {
throw new Error("Simulated error at item 3");
}

await new Promise(resolve => setTimeout(resolve, 500));
yield { id: i, data: `Data ${i}` };
}
} catch (error) {
console.error("Generator error:", error.message);
yield { error: error.message };
}
}

// Processing with error handling
async function processWithErrors() {
for await (const item of robustAsyncGenerator()) {
if (item.error) {
console.error("Processing error:", item.error);
break;
}
console.log("Processed:", item);
}
}

AbortController for Cancellation

// Using AbortController to cancel async operations
async function cancellableFetch(url, signal) {
try {
const response = await fetch(url, { signal });
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request was cancelled');
return null;
}
throw error;
}
}

// Example usage
const controller = new AbortController();
const signal = controller.signal;

// Start the request
const fetchPromise = cancellableFetch('https://api.example.com/data', signal);

// Cancel after 2 seconds
setTimeout(() => {
controller.abort();
console.log('Request cancelled');
}, 2000);

fetchPromise
.then(data => {
if (data) {
console.log('Data received:', data);
}
})
.catch(error => {
console.error('Error:', error.message);
});

Best Practices

1. Always Handle Errors

// Good: Proper error handling
async function safeAsyncOperation() {
try {
const result = await riskyAsyncOperation();
return result;
} catch (error) {
console.error('Operation failed:', error.message);
return null; // or throw a more specific error
}
}

// Avoid: Unhandled promise rejections
// riskyAsyncOperation().then(result => console.log(result));

2. Use Promise.all for Parallel Operations

// Good: Parallel execution
async function fetchAllData() {
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments()
]);
return { users, posts, comments };
}

// Avoid: Sequential execution when parallel is possible
async function fetchAllDataSlow() {
const users = await fetchUsers();
const posts = await fetchPosts();
const comments = await fetchComments();
return { users, posts, comments };
}

3. Use Async/Await Over Promise Chains

// Good: Async/await (more readable)
async function processData() {
try {
const user = await fetchUser();
const posts = await fetchUserPosts(user.id);
const comments = await fetchPostComments(posts[0].id);
return { user, posts, comments };
} catch (error) {
console.error('Error:', error);
throw error;
}
}

// Avoid: Long promise chains
function processDataWithChains() {
return fetchUser()
.then(user => fetchUserPosts(user.id))
.then(posts => fetchPostComments(posts[0].id))
.then(comments => ({ user, posts, comments }))
.catch(error => {
console.error('Error:', error);
throw error;
});
}

Summary

Asynchronous programming is essential for modern JavaScript development:

  • Callbacks: Basic async pattern, but can lead to callback hell
  • Promises: Cleaner async handling with .then() and .catch()
  • Async/Await: Most readable syntax for async operations
  • Event Loop: Understanding execution order and priority
  • Error Handling: Proper error management in async code
  • Best Practices: Use parallel execution, handle errors, prefer async/await

Mastering these concepts enables you to build responsive, efficient JavaScript applications that handle real-world async scenarios effectively.


This tutorial is part of the JavaScript Mastery series by syscook.dev