Asynchronous programming is at the heart of JavaScript. Whether you're fetching data from an API, reading files, or querying a database, your code needs to handle operations that take time without blocking the rest of the program. Promises and async/await are the modern, clean way to do this — and understanding them deeply will make you a significantly better JavaScript developer.
The Problem: Callback Hell
Before Promises, JavaScript used callbacks. They work fine for simple cases, but nesting them creates deeply indented, hard-to-read code:
// Callback hell — don't do this
getData(function(err, data) {
if (err) return handleError(err);
processData(data, function(err, result) {
if (err) return handleError(err);
saveResult(result, function(err, saved) {
if (err) return handleError(err);
notifyUser(saved, function(err) {
if (err) return handleError(err);
console.log('Done!');
});
});
});
});
Promises were introduced in ES2015 to solve this. Async/await, added in ES2017, is syntactic sugar on top of Promises that makes asynchronous code look synchronous.
Understanding Promises
A Promise is an object representing the eventual completion or failure of an asynchronous operation. It has three states:
- Pending: initial state, neither fulfilled nor rejected
- Fulfilled: the operation completed successfully
- Rejected: the operation failed
Once a promise settles (fulfills or rejects), it cannot change state.
Creating a Promise
const myPromise = new Promise((resolve, reject) => {
// Simulate async work
setTimeout(() => {
const success = Math.random() > 0.5;
if (success) {
resolve('Operation succeeded!');
} else {
reject(new Error('Operation failed'));
}
}, 1000);
});
The function passed to new Promise() is called the executor. It runs immediately and receives two functions: resolve (call when done) and reject (call on failure).
Consuming Promises with .then() and .catch()
myPromise
.then(result => {
console.log('Success:', result);
return result.toUpperCase(); // return value is passed to next .then()
})
.then(upperResult => {
console.log('Transformed:', upperResult);
})
.catch(error => {
console.error('Error:', error.message);
})
.finally(() => {
console.log('This runs regardless of success or failure');
});
Key rules for promise chaining:
- Each
.then()returns a new promise - Whatever you return from a
.then()callback becomes the resolved value of the next promise - If you return a promise from
.then(), the chain waits for it - A single
.catch()at the end handles errors from any step in the chain
Promise Chaining in Practice
// Fetching user data then their posts
fetch('/api/users/1')
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return response.json();
})
.then(user => {
console.log('User:', user.name);
return fetch(`/api/users/${user.id}/posts`);
})
.then(response => response.json())
.then(posts => {
console.log(`Found ${posts.length} posts`);
})
.catch(error => {
console.error('Failed:', error.message);
});
Async/Await
Async/await lets you write promise-based code that looks and reads like synchronous code. Under the hood, it's still promises — just with cleaner syntax.
The async keyword
Adding async before a function makes it return a Promise automatically. Even if you return a plain value, it gets wrapped in Promise.resolve().
async function greet() {
return 'Hello!';
}
// These are equivalent:
greet().then(msg => console.log(msg)); // 'Hello!'
Promise.resolve('Hello!').then(msg => console.log(msg));
The await keyword
await pauses execution of the async function until the promise settles. It can only be used inside an async function (or at the top level in ES modules).
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const user = await response.json();
return user;
}
// Call it
fetchUser(1)
.then(user => console.log(user.name))
.catch(err => console.error(err.message));
// Or use await at the top level (ES modules / Node.js 14.8+)
const user = await fetchUser(1);
console.log(user.name);
The same chain from earlier, rewritten with async/await:
async function loadUserPosts(userId) {
const userResponse = await fetch(`/api/users/${userId}`);
if (!userResponse.ok) throw new Error(`HTTP error: ${userResponse.status}`);
const user = await userResponse.json();
console.log('User:', user.name);
const postsResponse = await fetch(`/api/users/${user.id}/posts`);
const posts = await postsResponse.json();
console.log(`Found ${posts.length} posts`);
return posts;
}
Error Handling with try/catch
async function safeFetch(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
if (error.name === 'TypeError') {
// Network error or invalid URL
console.error('Network error:', error.message);
} else {
// HTTP error
console.error('Request failed:', error.message);
}
throw error; // Re-throw so callers can handle it
}
}
// Using the function
async function main() {
try {
const data = await safeFetch('/api/data');
console.log(data);
} catch (error) {
// Handle at the call site
showErrorToUser(error.message);
}
}
Promise Combinators
When you need to run multiple async operations, JavaScript provides several static methods on the Promise class.
Promise.all()
Runs all promises in parallel. Resolves when all of them succeed, or rejects immediately if any of them fail.
async function loadDashboard(userId) {
// All three requests run in parallel — much faster than sequential
const [user, posts, notifications] = await Promise.all([
fetch(`/api/users/${userId}`).then(r => r.json()),
fetch(`/api/users/${userId}/posts`).then(r => r.json()),
fetch(`/api/users/${userId}/notifications`).then(r => r.json()),
]);
return { user, posts, notifications };
}
// Without Promise.all (sequential — 3x slower)
async function loadDashboardSlow(userId) {
const user = await fetch(`/api/users/${userId}`).then(r => r.json());
const posts = await fetch(`/api/users/${userId}/posts`).then(r => r.json());
const notifications = await fetch(`/api/users/${userId}/notifications`).then(r => r.json());
return { user, posts, notifications };
}
Promise.allSettled()
Like Promise.all(), but waits for all promises to settle (succeed or fail) and never rejects. Returns an array of result objects.
const results = await Promise.allSettled([
fetch('/api/endpoint-1').then(r => r.json()),
fetch('/api/endpoint-2').then(r => r.json()),
fetch('/api/endpoint-that-might-fail').then(r => r.json()),
]);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Request ${index} succeeded:`, result.value);
} else {
console.error(`Request ${index} failed:`, result.reason.message);
}
});
Promise.race()
Resolves or rejects as soon as the first promise settles. Useful for timeouts.
function fetchWithTimeout(url, timeoutMs) {
const fetchPromise = fetch(url).then(r => r.json());
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), timeoutMs)
);
return Promise.race([fetchPromise, timeoutPromise]);
}
try {
const data = await fetchWithTimeout('/api/slow-endpoint', 5000);
console.log(data);
} catch (err) {
console.error(err.message); // 'Request timed out' or fetch error
}
Promise.any()
Resolves with the first fulfilled promise (ignores rejections unless all fail). Useful for trying multiple sources.
// Try multiple CDN mirrors, use the first that responds
const data = await Promise.any([
fetch('https://cdn1.example.com/data.json').then(r => r.json()),
fetch('https://cdn2.example.com/data.json').then(r => r.json()),
fetch('https://cdn3.example.com/data.json').then(r => r.json()),
]);
console.log('Got data from fastest source:', data);
Common Patterns and Pitfalls
Avoid awaiting in loops (unless sequential is intentional)
const userIds = [1, 2, 3, 4, 5];
// ❌ Sequential — each request waits for the previous
for (const id of userIds) {
const user = await fetchUser(id); // waits 1s × 5 = 5s total
console.log(user.name);
}
// ✅ Parallel — all requests fire at once
const users = await Promise.all(userIds.map(id => fetchUser(id))); // ~1s total
users.forEach(user => console.log(user.name));
Don't forget to await
// ❌ Bug: not awaiting — data will be a Promise, not the value
async function getData() {
const data = fetchUser(1); // missing await!
console.log(data); // Promise { <pending> }
return data;
}
// ✅ Correct
async function getData() {
const data = await fetchUser(1);
console.log(data); // { id: 1, name: 'Alice', ... }
return data;
}
Error handling at the right level
// ❌ Swallowing errors silently
async function bad() {
try {
return await riskyOperation();
} catch (err) {
// Silent! Caller doesn't know it failed
}
}
// ✅ Handle what you can, re-throw the rest
async function good() {
try {
return await riskyOperation();
} catch (err) {
logError(err); // log for observability
throw err; // re-throw so caller can react
}
}
Using async IIFE when await is needed at the top level
// CommonJS or older environments without top-level await
(async () => {
try {
const data = await fetchSomething();
console.log(data);
} catch (err) {
console.error(err);
process.exit(1);
}
})();
Promisifying callback-based APIs
const { promisify } = require('util');
const fs = require('fs');
const readFile = promisify(fs.readFile);
// Now you can use it with async/await
async function readConfig() {
const content = await readFile('./config.json', 'utf8');
return JSON.parse(content);
}
// Or write your own promisify wrapper
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function main() {
console.log('Starting...');
await delay(1000);
console.log('1 second later');
}
Async Iteration (for await...of)
For async generators and streams, for await...of lets you iterate over async iterables cleanly.
// Reading a stream line by line
async function processLargeFile(filename) {
const { createReadStream } = require('fs');
const { createInterface } = require('readline');
const stream = createReadStream(filename);
const rl = createInterface({ input: stream });
for await (const line of rl) {
processLine(line);
}
}
// Async generator
async function* generateSequence(start, end) {
for (let i = start; i <= end; i++) {
await delay(100); // simulate async work
yield i;
}
}
for await (const num of generateSequence(1, 5)) {
console.log(num); // 1, 2, 3, 4, 5 (with delays)
}
Quick Reference
// Create
new Promise((resolve, reject) => { ... })
Promise.resolve(value)
Promise.reject(error)
// Consume
promise.then(onFulfilled, onRejected)
promise.catch(onRejected)
promise.finally(onFinally)
// Combinators
Promise.all([p1, p2, p3]) // all must succeed
Promise.allSettled([p1, p2, p3]) // wait for all, never rejects
Promise.race([p1, p2, p3]) // first to settle wins
Promise.any([p1, p2, p3]) // first fulfilled wins
// Async/await
async function fn() { ... } // returns a Promise
const result = await promise; // pause until resolved
try { await fn() } catch (err) {} // error handling
Master promises and async/await and you'll be able to write clean, efficient, and maintainable asynchronous code in any JavaScript context — browser, Node.js, or serverless functions.
Free Developer Tools
If you found this article helpful, check out DevToolkit — 40+ free browser-based developer tools with no signup required.
Popular tools: JSON Formatter · Regex Tester · JWT Decoder · Base64 Encoder
🛒 Get the DevToolkit Starter Kit on Gumroad — source code, deployment guide, and customization templates.
Top comments (0)