Understanding JavaScript Promises and Async/Await: From Callback Hell to Clean Code
Understanding JavaScript Promises and Async/Await: From Callback Hell to Clean Code
I remember writing code that looked like a staircase of doom. I was building a feature that needed to fetch user data, then fetch their posts, then fetch comments for each post, then fetch replies for each comment. By the time I finished, my code was nested five levels deep with callbacks inside callbacks inside callbacks. It was unreadable, unmaintainable, and nearly impossible to debug.
// My callback hell - what I had to write
getUser(userId, (user) => {
getPosts(user.id, (posts) => {
getComments(posts[0].id, (comments) => {
getReplies(comments[0].id, (replies) => {
processReplies(replies, (result) => {
console.log('Finally!', result);
// But what about errors? Where do I handle them?
});
});
});
});
});
That's when I discovered Promises, and later, async/await. These tools didn't just make my code cleaner—they transformed how I think about asynchronous operations in JavaScript. But here's what took me time to understand: Promises aren't just syntactic sugar. They represent a fundamental shift in how we model asynchronous operations, and understanding them deeply unlocks powerful patterns for handling complex async workflows.
In this post, we're going to explore Promises and async/await from the ground up. We'll start with why they exist (to solve callback hell), how they work under the hood, and most importantly, what I learned the hard way about using them effectively and avoiding common pitfalls.
Intended audience: JavaScript developers who want to understand asynchronous
programming deeply—from beginners who've seen .then() but don't understand how
it works, to intermediate developers who want to master Promise patterns and use
async/await effectively.
Note: This post follows a learning-first approach. If you want to understand how JavaScript executes async code (the event loop), check out Understanding the JavaScript Event Loop. This post focuses on the tools—Promises and async/await—while that post explains the mechanism.
Prerequisites:
- Understanding JavaScript Data Types - Promises are objects, understanding references helps
- Basic understanding of functions and callbacks
- Familiarity with JavaScript basics
Table of Contents
- The Problem Promises Solve: Callback Hell
- What is a Promise?
- Understanding Promise States
- Creating Promises
- Consuming Promises: .then(), .catch(), .finally()
- Chaining Promises
- Error Handling with Promises
- Promise Combinators: Promise.all, Promise.allSettled, Promise.race, Promise.any
- Async/Await: Syntactic Sugar for Promises
- Error Handling with Async/Await
- Common Pitfalls I Encountered
- When to Use Promises vs Async/Await
- Best Practices: Writing Effective Async Code
- Key Takeaways
The Problem Promises Solve: Callback Hell
Before Promises, JavaScript used callbacks for asynchronous operations. A callback is simply a function passed to another function to be executed later. This worked fine for simple cases, but created problems as complexity grew.
The Callback Pattern
// Simple callback - works fine
setTimeout(() => {
console.log('Done!');
}, 1000);
// Callback with data
readFile('data.txt', (error, data) => {
if (error) {
console.error('Error:', error);
} else {
console.log('Data:', data);
}
});
The Problem: Callback Hell
When you need to chain multiple asynchronous operations, callbacks become nested deeply:
// Callback hell - hard to read and maintain
getUser(userId, (error, user) => {
if (error) {
console.error('Error getting user:', error);
return;
}
getPosts(user.id, (error, posts) => {
if (error) {
console.error('Error getting posts:', error);
return;
}
getComments(posts[0].id, (error, comments) => {
if (error) {
console.error('Error getting comments:', error);
return;
}
processComments(comments, (error, result) => {
if (error) {
console.error('Error processing:', error);
return;
}
console.log('Success!', result);
});
});
});
});
Problems with this approach:
- Hard to read: The code "grows" to the right, making it difficult to follow
- Error handling is repetitive: Each callback needs its own error handling
- Hard to maintain: Adding or removing steps requires careful indentation changes
- Difficult to debug: Stack traces become confusing with nested callbacks
- Hard to reuse: The logic is tightly coupled to specific callbacks
Why Would They Design It This Way?
You might wonder: why didn't JavaScript start with Promises? The answer is historical—callbacks came first because they're simpler to implement. Early JavaScript didn't need complex async workflows. As JavaScript grew in complexity (especially with Node.js), the need for better async patterns became clear, leading to Promises.
What is a Promise?
A Promise is an object that represents the eventual completion (or failure) of an asynchronous operation and its resulting value. Think of it as a placeholder for a value that will be available in the future.
The Mental Model That Helped Me
I like to think of a Promise as a receipt or ticket:
- You hand over your order (start an async operation)
- You get a receipt (a Promise) that says "your order is being prepared"
- The receipt has three possible states: pending, fulfilled (success), or rejected (failed)
- You can attach handlers to the receipt to specify what to do when it's ready
Promise in Code
// A Promise represents a future value
const userPromise = fetch('/api/user');
// It starts in "pending" state
console.log(userPromise); // Promise { <pending> }
// Later, it will be either "fulfilled" (with data) or "rejected" (with error)
Key insight: A Promise is a container for a value that doesn't exist yet. It's not the value itself—it's a way to interact with that future value.
Understanding Promise States
A Promise can be in one of three states:
1. Pending
The initial state. The operation is in progress, neither fulfilled nor rejected.
const promise = new Promise((resolve, reject) => {
// Promise is pending until resolve() or reject() is called
setTimeout(() => {
resolve('Success!');
}, 1000);
});
console.log(promise); // Promise { <pending> }
2. Fulfilled (Resolved)
The operation completed successfully. The Promise has a value.
const promise = Promise.resolve('Success!');
console.log(promise); // Promise { <fulfilled>: "Success!" }
3. Rejected
The operation failed. The Promise has a reason for failure.
const promise = Promise.reject(new Error('Something went wrong'));
console.log(promise); // Promise { <rejected>: Error: Something went wrong }
Important: States Are Permanent
Once a Promise moves from pending to fulfilled or rejected, it can never change. This is crucial to understand—a Promise represents a single async operation that happens once.
const promise = Promise.resolve('First value');
// You cannot change a resolved Promise
promise = 'Second value'; // This just reassigns the variable, doesn't change the Promise
// The Promise still contains "First value"
Creating Promises
There are several ways to create Promises:
1. Using the Promise Constructor
The most explicit way to create a Promise:
const myPromise = new Promise((resolve, reject) => {
// Do some async work
// Call resolve(value) when successful
// Call reject(error) when failed
});
Real example:
function fetchUserData(userId) {
return new Promise((resolve, reject) => {
// Simulate API call
setTimeout(() => {
if (userId) {
resolve({ id: userId, name: 'John Doe' });
} else {
reject(new Error('User ID is required'));
}
}, 1000);
});
}
Key points:
- The constructor takes a function (called the "executor")
- The executor receives two functions:
resolveandreject - Call
resolve(value)to fulfill the Promise - Call
reject(error)to reject the Promise - The executor runs immediately when the Promise is created
2. Promise.resolve() and Promise.reject()
Shorthand for creating already-resolved or already-rejected Promises:
// Already resolved
const resolvedPromise = Promise.resolve('Success!');
// Already rejected
const rejectedPromise = Promise.reject(new Error('Failed'));
Common use case: Converting a value to a Promise (useful for functions that might return a value or a Promise):
function maybeAsync(value) {
if (typeof value === 'string') {
return Promise.resolve(value); // Wrap in Promise
}
return value; // Already a Promise
}
3. Functions That Return Promises
Many built-in functions and APIs return Promises:
// fetch API
const userPromise = fetch('/api/user');
// File reading (Node.js)
import { readFile } from 'fs/promises';
const filePromise = readFile('data.txt', 'utf-8');
Modern JavaScript APIs use Promises by default, so you'll often be working with Promises returned by APIs rather than creating them yourself.
Consuming Promises: .then(), .catch(), .finally()
Once you have a Promise, you need to handle its result. This is where .then(),
.catch(), and .finally() come in.
.then() - Handling Success
.then() registers callbacks to handle the resolved value:
fetch('/api/user')
.then((response) => {
// This runs when the Promise resolves
console.log('Got response:', response);
return response.json(); // Return a new Promise
})
.then((user) => {
// This runs when response.json() resolves
console.log('User data:', user);
});
Key points:
.then()takes one or two callbacks:onFulfilledand optionallyonRejected.then()returns a new Promise, allowing chaining- If you return a value, it becomes the resolved value of the returned Promise
- If you return a Promise, that Promise's result becomes the result
.catch() - Handling Errors
.catch() is syntactic sugar for .then(null, onRejected). It handles rejected
Promises:
fetch('/api/user')
.then((response) => response.json())
.then((user) => {
console.log('User:', user);
})
.catch((error) => {
// This catches ANY rejection in the chain
console.error('Error:', error);
});
Important: .catch() catches rejections from any previous .then() in
the chain.
.finally() - Cleanup
.finally() runs regardless of whether the Promise fulfilled or rejected:
fetch('/api/user')
.then((response) => response.json())
.then((user) => console.log('User:', user))
.catch((error) => console.error('Error:', error))
.finally(() => {
// This ALWAYS runs
console.log('Request completed');
// Useful for cleanup (hide loading spinner, etc.)
});
Key point: .finally() doesn't receive any arguments (no value or error).
Putting It Together
fetch('/api/user')
.then((response) => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then((user) => {
console.log('Success:', user);
return user; // Pass to next .then()
})
.catch((error) => {
console.error('Error occurred:', error);
// Return a default value
return { name: 'Anonymous' };
})
.finally(() => {
console.log('Request finished');
});
Chaining Promises
One of the most powerful features of Promises is chaining—connecting multiple async operations in sequence.
Sequential Operations
fetch('/api/user')
.then((response) => response.json())
.then((user) => {
// Use the user data to fetch posts
return fetch(`/api/users/${user.id}/posts`);
})
.then((response) => response.json())
.then((posts) => {
// Use posts to fetch comments
return fetch(`/api/posts/${posts[0].id}/comments`);
})
.then((response) => response.json())
.then((comments) => {
console.log('Comments:', comments);
})
.catch((error) => {
// One catch handles all errors in the chain
console.error('Error in chain:', error);
});
Compare this to callback hell:
// Callback version (hard to read)
getUser(userId, (user) => {
getPosts(user.id, (posts) => {
getComments(posts[0].id, (comments) => {
console.log(comments);
});
});
});
// Promise version (clean and readable)
getUser(userId)
.then((user) => getPosts(user.id))
.then((posts) => getComments(posts[0].id))
.then((comments) => console.log(comments));
Why Chaining Works
Each .then() returns a new Promise. This means:
- You can chain multiple
.then()calls - Each step waits for the previous one to complete
- Values flow through the chain
- Errors propagate to the nearest
.catch()
Error Handling with Promises
Error handling in Promises is more elegant than with callbacks, but there are important nuances to understand.
How Errors Propagate
Errors in a Promise chain "bubble up" until they're caught:
fetch('/api/user')
.then((response) => {
throw new Error('Something went wrong');
})
.then((user) => {
// This is SKIPPED because previous .then() threw
console.log('User:', user);
})
.catch((error) => {
// This catches the error from any previous step
console.error('Caught error:', error);
});
Throwing vs Rejecting
In a .then() callback, you can either:
- Throw an error (synchronous error)
- Return a rejected Promise (asynchronous error)
Both are handled the same way:
// Throwing (synchronous)
.then((data) => {
if (!data) {
throw new Error('No data'); // Caught by .catch()
}
return data;
})
// Returning rejected Promise (asynchronous)
.then((data) => {
if (!data) {
return Promise.reject(new Error('No data')); // Also caught by .catch()
}
return data;
})
Catching Specific Errors
You can catch errors at different points in the chain:
fetch('/api/user')
.then((response) => {
if (!response.ok) {
throw new Error('Network error');
}
return response.json();
})
.catch((error) => {
// Handle network/parsing errors
console.error('Fetch error:', error);
return { name: 'Default User' }; // Provide fallback
})
.then((user) => {
// This receives either the user or the fallback
return processUser(user);
})
.catch((error) => {
// Handle processing errors
console.error('Processing error:', error);
});
Key insight: When a .catch() returns a value (or a resolved Promise), the
chain continues. If a .catch() throws or returns a rejected Promise, the error
propagates further.
Promise Combinators: Promise.all, Promise.allSettled, Promise.race, Promise.any
Often, you need to coordinate multiple Promises. JavaScript provides several "combinator" methods for this.
Promise.all() - All or Nothing
Runs multiple Promises in parallel and waits for all to complete. If any fails, the whole thing fails.
const [userResponse, postsResponse, notificationsResponse] = await Promise.all([
fetch('/api/user'),
fetch('/api/posts'),
fetch('/api/notifications'),
]);
const [user, posts, notifications] = await Promise.all([
userResponse.json(),
postsResponse.json(),
notificationsResponse.json(),
]);
console.log('All data loaded:', { user, posts, notifications });
Characteristics:
- Returns an array of results in the same order as input
- Fails fast: If any Promise rejects, the entire
Promise.all()rejects - All Promises run in parallel (faster than sequential)
Use case: Loading multiple independent resources where you need all of them.
Promise.allSettled() - Wait for All, Regardless of Outcome
Waits for all Promises to complete (either fulfilled or rejected) and returns results for all.
async function fetchJson(url) {
const response = await fetch(url);
return response.json();
}
const results = await Promise.allSettled([
fetchJson('/api/user'),
fetchJson('/api/posts'),
fetchJson('/api/notifications'),
]);
// results is an array of:
// { status: 'fulfilled', value: ... } or
// { status: 'rejected', reason: ... }
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Request ${index} succeeded:`, result.value);
} else {
console.error(`Request ${index} failed:`, result.reason);
}
});
Characteristics:
- Never rejects (always resolves with results array)
- Returns results for all Promises, successful or failed
- Useful when you want to handle partial failures
Use case: Loading multiple resources where some can fail without breaking the whole operation.
Promise.race() - First One Wins
Resolves or rejects as soon as the first Promise settles (fulfills or rejects).
async function fetchJson(url) {
const response = await fetch(url);
return response.json();
}
// Race between API call and timeout
const result = await Promise.race([
fetchJson('/api/data'),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 5000),
),
]);
console.log('First to complete:', result);
Characteristics:
- Returns the value/reason of the first settled Promise
- Other Promises continue running (not cancelled)
- Useful for timeouts
Use case: Implementing timeouts, choosing between multiple data sources.
Promise.any() - First Success Wins
Resolves with the value of the first fulfilled Promise. Only rejects if all Promises reject.
async function fetchJson(url) {
const response = await fetch(url);
return response.json();
}
// Try multiple APIs, use the first one that succeeds
const result = await Promise.any([
fetchJson('/api/primary/data'),
fetchJson('/api/fallback/data'),
fetchJson('/api/backup/data'),
]);
console.log('First successful response:', result);
Characteristics:
- Resolves with the first successful result
- Only rejects if all Promises reject (with an AggregateError)
- Ignores rejections until all fail
Use case: Fallback strategies, trying multiple endpoints.
Comparison Table
| Method | Waits For | Fails When | Use Case |
|---|---|---|---|
Promise.all | All fulfill | Any rejects | Need all results |
Promise.allSettled | All settle | Never fails | Handle partial failures |
Promise.race | First to settle | First rejects | Timeouts, fastest response |
Promise.any | First to fulfill | All reject | Fallback strategies |
Async/Await: Syntactic Sugar for Promises
async/await is a syntax for writing Promise-based code that looks synchronous.
It was introduced in ES2017 and has become the preferred way to write async
code.
What is async/await?
asyncmakes a function return a Promiseawaitpauses execution until a Promise resolves- It's syntactic sugar—it doesn't change how JavaScript works, just how you write it
Converting Promise Code to Async/Await
Before (Promises):
function loadUserData(userId) {
return fetch(`/api/users/${userId}`)
.then((response) => {
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
})
.then((user) => {
return fetch(`/api/users/${userId}/posts`);
})
.then((response) => response.json())
.then((posts) => {
return { user: user, posts: posts };
})
.catch((error) => {
console.error('Error:', error);
throw error;
});
}
After (async/await):
async function loadUserData(userId) {
try {
const userResponse = await fetch(`/api/users/${userId}`);
if (!userResponse.ok) {
throw new Error('Failed to fetch user');
}
const user = await userResponse.json();
const postsResponse = await fetch(`/api/users/${userId}/posts`);
const posts = await postsResponse.json();
return { user, posts };
} catch (error) {
console.error('Error:', error);
throw error;
}
}
Notice:
- The code reads top-to-bottom, like synchronous code
- No nested
.then()chains - Errors are handled with familiar
try/catch - Much easier to read and maintain
How async Functions Work
When you declare a function as async, it automatically returns a Promise:
async function getData() {
return 'Hello';
}
// This function returns Promise<string>, not string
const result = getData(); // Promise { <fulfilled>: "Hello" }
// You still need to await it
const value = await getData(); // "Hello"
Key point: Even if you don't use await inside an async function, it
still returns a Promise.
The await Keyword
await does two things:
- Pauses execution of the async function
- Waits for the Promise to resolve
- Returns the resolved value (not the Promise itself)
async function example() {
const promise = fetch('/api/data');
console.log(promise); // Promise { <pending> }
const response = await promise;
console.log(response); // Response object (the actual value)
}
Important: await can only be used inside async functions (or top-level
await in modules).
Error Handling with Async/Await
One of the biggest advantages of async/await is familiar error handling with
try/catch.
Basic Error Handling
async function loadData() {
try {
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
return data;
} catch (error) {
// Catches ANY error in the try block
console.error('Error loading data:', error);
// Return default value or rethrow
return null;
}
}
Multiple Async Operations
async function loadDashboard() {
try {
const userResponse = await fetch('/api/user');
const user = await userResponse.json();
const postsResponse = await fetch('/api/posts');
const posts = await postsResponse.json();
const notificationsResponse = await fetch('/api/notifications');
const notifications = await notificationsResponse.json();
return { user, posts, notifications };
} catch (error) {
// Any of the three fetches can fail, all caught here
console.error('Error loading dashboard:', error);
throw error; // Re-throw to let caller handle it
}
}
Handling Errors in Parallel Operations
When using Promise.all() with async/await:
async function loadDashboard() {
try {
const [userResponse, postsResponse, notificationsResponse] =
await Promise.all([
fetch('/api/user'),
fetch('/api/posts'),
fetch('/api/notifications'),
]);
const user = await userResponse.json();
const posts = await postsResponse.json();
const notifications = await notificationsResponse.json();
return { user, posts, notifications };
} catch (error) {
// If ANY Promise in Promise.all() rejects, we catch it here
console.error('Failed to load dashboard:', error);
return null;
}
}
Async Functions Always Return Promises
Even if you use try/catch, the function still returns a Promise:
async function mightFail() {
try {
const data = await fetch('/api/data');
return data.json();
} catch (error) {
console.error(error);
return null; // Returns Promise<null>, not null directly
}
}
// Caller still needs to handle Promise
mightFail().then((data) => {
if (data) {
console.log('Success:', data);
}
});
Common Pitfalls I Encountered
Here are mistakes I've made and what I learned:
Pitfall 1: Forgetting await
// ❌ Wrong - returns Promise, not value
async function getData() {
const data = fetch('/api/data'); // Missing await!
return data; // Returns Promise<Response>, not data
}
// ✅ Correct
async function getData() {
const response = await fetch('/api/data');
const data = await response.json();
return data; // Returns actual data
}
Pitfall 2: await in Non-Async Functions
// ❌ Wrong - can't use await in regular function
function getData() {
const data = await fetch('/api/data'); // SyntaxError!
}
// ✅ Correct - use async
async function getData() {
const data = await fetch('/api/data');
}
Pitfall 3: Sequential When Parallel Would Be Better
// ❌ Slow - runs sequentially
async function loadData() {
const userResponse = await fetch('/api/user');
const user = await userResponse.json();
const postsResponse = await fetch('/api/posts');
const posts = await postsResponse.json();
const notificationsResponse = await fetch('/api/notifications');
const notifications = await notificationsResponse.json();
// Takes: time(user) + time(posts) + time(notifications)
}
// ✅ Fast - runs in parallel
async function loadData() {
const [userResponse, postsResponse, notificationsResponse] =
await Promise.all([
fetch('/api/user'),
fetch('/api/posts'),
fetch('/api/notifications'),
]);
const user = await userResponse.json();
const posts = await postsResponse.json();
const notifications = await notificationsResponse.json();
// Takes: max(time(user), time(posts), time(notifications))
}
Pitfall 4: Not Handling Errors in Promise.all()
async function fetchJson(url) {
const response = await fetch(url);
return response.json();
}
// ❌ If one fails, whole thing fails with no fallback
async function loadData() {
const [userResponse, postsResponse] = await Promise.all([
fetch('/api/user'),
fetch('/api/posts'),
]);
const [user, posts] = await Promise.all([
userResponse.json(),
postsResponse.json(),
]);
// If /api/posts fails, user data is lost too
}
// ✅ Better - handle partial failures
async function loadData() {
const results = await Promise.allSettled([
fetchJson('/api/user'),
fetchJson('/api/posts'),
]);
const user = results[0].status === 'fulfilled' ? results[0].value : null;
const posts = results[1].status === 'fulfilled' ? results[1].value : [];
return { user, posts };
}
Pitfall 5: Mixing Promises and Async/Await Incorrectly
// ❌ Confusing mix
async function getData() {
return fetch('/api/data')
.then((r) => r.json())
.then((data) => {
return processData(data);
});
// Why use async/await if you're using .then()?
}
// ✅ Better - pick one style and stick with it
async function getData() {
const response = await fetch('/api/data');
const data = await response.json();
return processData(data);
}
Pitfall 6: Creating Promises Unnecessarily
// ❌ Unnecessary Promise wrapper
async function getData() {
return new Promise(async (resolve, reject) => {
const data = await fetch('/api/data');
resolve(data);
});
}
// ✅ Just return the Promise directly
async function getData() {
return fetch('/api/data');
}
When to Use Promises vs Async/Await
Both Promises and async/await are valid. Here's when I use each:
Use async/await when:
- Sequential operations - Code flows top-to-bottom
- Complex error handling - Try/catch is cleaner
- Readability is priority - Especially for beginners
- Multiple awaits - Cleaner than chained .then()
// ✅ Good for async/await
async function processOrder(orderId) {
try {
const order = await getOrder(orderId);
const user = await getUser(order.userId);
const items = await getItems(order.itemIds);
return { order, user, items };
} catch (error) {
handleError(error);
}
}
Use Promises when:
- Parallel operations with combinators - Promise.all(), Promise.race()
- One-liner transformations - Simple .then() chains
- Functional programming style - Method chaining
- Need Promise-specific methods - .finally(), multiple .catch() handlers
// ✅ Good for Promises
const data = fetch('/api/data')
.then((r) => r.json())
.then(transform)
.catch(handleError);
// Or with combinators using helper function
async function fetchJson(url) {
const response = await fetch(url);
return response.json();
}
const results = await Promise.all([fetchJson('/api/1'), fetchJson('/api/2')]);
Best Practice: Be Consistent
Pick one style per function/file and stick with it. Don't mix unnecessarily.
Best Practices: Writing Effective Async Code
Here's what I've learned about writing effective async code:
1. Always Handle Errors
// ✅ Always handle errors
async function loadData() {
try {
const response = await fetch('/api/data');
const data = await response.json();
return data;
} catch (error) {
// Log, show user message, return default, etc.
console.error('Failed to load data:', error);
return null;
}
}
2. Use Promise.all() for Independent Operations
async function fetchJson(url) {
const response = await fetch(url);
return response.json();
}
// ✅ Parallel when possible
async function loadDashboard() {
const [userResponse, postsResponse, notificationsResponse] =
await Promise.all([
fetch('/api/user'),
fetch('/api/posts'),
fetch('/api/notifications'),
]);
const [user, posts, notifications] = await Promise.all([
userResponse.json(),
postsResponse.json(),
notificationsResponse.json(),
]);
return { user, posts, notifications };
}
3. Be Explicit About Return Types
// ✅ Clear that it returns a Promise
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
4. Don't Create Unnecessary Promises
// ❌ Unnecessary wrapper
async function getData() {
return new Promise((resolve) => {
resolve(fetch('/api/data'));
});
}
// ✅ Just return the Promise
async function getData() {
return fetch('/api/data');
}
5. Use Promise.allSettled() for Partial Failures
async function fetchJson(url) {
const response = await fetch(url);
return response.json();
}
// ✅ Handle partial failures gracefully
async function loadMultiple() {
const results = await Promise.allSettled([
fetchJson('/api/1'),
fetchJson('/api/2'),
fetchJson('/api/3'),
]);
return results.filter((r) => r.status === 'fulfilled').map((r) => r.value);
}
6. Add Timeouts for Long-Running Operations
// ✅ Add timeout protection
async function fetchWithTimeout(url, timeout = 5000) {
return Promise.race([
fetch(url),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeout),
),
]);
}
7. Avoid await in Loops When Possible
// ❌ Slow - sequential
async function processItems(items) {
const results = [];
for (const item of items) {
const result = await processItem(item);
results.push(result);
}
return results;
}
// ✅ Fast - parallel
async function processItems(items) {
return Promise.all(items.map((item) => processItem(item)));
}
Key Takeaways
-
Promises solve callback hell by providing a cleaner way to handle async operations with chaining and better error handling.
-
A Promise represents a future value and can be in one of three states: pending, fulfilled, or rejected. Once settled, it never changes.
-
Promise chaining allows you to connect multiple async operations sequentially, with values flowing through
.then()callbacks. -
Error handling in Promises uses
.catch()which catches rejections from anywhere in the chain. Errors propagate until caught. -
Promise combinators (
Promise.all,Promise.allSettled,Promise.race,Promise.any) help coordinate multiple Promises for parallel execution or fallback strategies. -
async/await is syntactic sugar that makes Promise-based code look synchronous.
asyncfunctions always return Promises, andawaitpauses execution until a Promise resolves. -
Error handling with async/await uses familiar
try/catchblocks, making error handling more intuitive. -
Use Promise.all() for parallel operations when you need multiple independent async operations to complete. This is much faster than sequential awaits.
-
Always handle errors—whether using Promises or async/await, never let errors go unhandled.
-
Choose your style consistently—use async/await for sequential, readable code, and Promises for parallel operations with combinators.
Understanding Promises and async/await transformed how I write JavaScript. These tools don't just make code cleaner—they make async operations predictable, composable, and easier to reason about. The next time you encounter callback hell or struggle with async code flow, remember: Promises and async/await are here to help.
Test Your Understanding
Ready to dive deeper? Understanding Promises is the foundation for:
- Understanding the JavaScript Event Loop - How async code executes
- Understanding JavaScript Closures - How closures work with async code
- Understanding JavaScript Data Types - Promises are objects