Why Asynchronous JavaScript?
JavaScript is single-threaded. It runs one thing at a time on one call stack. But the real world is full of operations that take time โ network requests, file reads, timers, user input. If JavaScript blocked on every slow operation, your UI would freeze.
Asynchronous programming solves this. Instead of waiting, JavaScript registers a callback and moves on. When the operation completes, the callback runs.
Synchronous (blocking): Asynchronous (non-blocking):
Start Start
โ โ
โโโ Fetch data (3s wait) โโโ Fetch data โ register callback
โ ...waiting... โโโ Do other work
โ ...waiting... โโโ Do more work
โ ...waiting... โ
โโโ Process data โโโ [callback fires] Process data
โโโ Render UI โโโ Render UI
Done Done
Total: 3s + processing Total: processing only
The Callback Problem
Before Promises, we used callbacks. They work but lead to deeply nested, hard-to-read code:
getUser(userId, function (err, user) {
if (err) return handleError(err);
getOrders(user.id, function (err, orders) {
if (err) return handleError(err);
getOrderDetails(orders[0].id, function (err, details) {
if (err) return handleError(err);
processPayment(details.total, function (err, receipt) {
if (err) return handleError(err);
sendConfirmation(receipt, function (err) {
if (err) return handleError(err);
console.log('Done!');
});
});
});
});
});
This is "callback hell" or the "pyramid of doom." Each level indents further. Error handling is repetitive. Flow is hard to follow.
Promise Anatomy
A Promise is an object representing the eventual result of an async operation. It has three states:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Promise States โ
โ โ
โ โโโโโโโโโโโโ โ
โ โ PENDING โ โโโ initial state โ
โ โโโโโโฌโโโโโโ โ
โ โ โ
โ โโโโโ resolve(value) โโโโโบ FULFILLED โ
โ โ (success) โ
โ โ โ
โ โโโโโ reject(reason) โโโโโบ REJECTED โ
โ (failure) โ
โ โ
โ Once settled (fulfilled or rejected), โ
โ a Promise NEVER changes state again. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Creating a Promise
The Promise constructor takes an executor function with two arguments โ resolve and reject:
const promise = new Promise((resolve, reject) => {
// async operation here
const success = true;
if (success) {
resolve('Operation completed'); // โ FULFILLED
} else {
reject(new Error('Operation failed')); // โ REJECTED
}
});
The executor runs immediately (synchronously). The resolution/rejection is what happens asynchronously.
console.log('1 - before Promise');
const p = new Promise((resolve) => {
console.log('2 - inside executor (synchronous!)');
resolve('done');
});
p.then((val) => console.log('4 - then handler:', val));
console.log('3 - after Promise');
// Output:
// 1 - before Promise
// 2 - inside executor (synchronous!)
// 3 - after Promise
// 4 - then handler: done
Notice: the executor runs immediately (log 2 before 3), but .then() callbacks are always asynchronous (log 4 after 3), even if the Promise is already resolved.
Consuming Promises
promise
.then((value) => {
// handle success
console.log(value);
})
.catch((error) => {
// handle failure
console.error(error);
})
.finally(() => {
// runs regardless of outcome
console.log('cleanup');
});
.then() returns a new Promise, enabling chaining. .catch() is shorthand for .then(undefined, onRejected). .finally() runs after settlement regardless of outcome and passes the value through.
Promise Chaining
Each .then() returns a new Promise, so you can chain them. Whatever you return from a .then() handler becomes the resolved value of the next Promise in the chain.
fetch('/api/users/1')
.then((response) => response.json()) // returns parsed JSON
.then((user) => fetch(`/api/orders/${user.id}`)) // returns fetch Promise
.then((response) => response.json())
.then((orders) => {
console.log('Orders:', orders);
return orders.length;
})
.then((count) => console.log('Count:', count))
.catch((err) => console.error('Failed:', err));
Chain flow:
fetch() โโโบ .then(parse) โโโบ .then(fetch orders) โโโบ .then(parse) โโโบ .then(log)
โ โ โ โ โ
โ โผ โผ โผ โผ
โ Response obj fetch Promise Orders array count
โ
โโโโโ if ANY step fails โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโบ .catch(err)
Return Values in .then()
What you return from .then() determines the next Promise:
// Return a value โ next .then() receives it
Promise.resolve(1)
.then((x) => x + 1) // returns 2
.then((x) => x * 3) // returns 6
.then((x) => console.log(x)); // 6
// Return a Promise โ chain waits for it
Promise.resolve(1)
.then((x) => {
return new Promise((resolve) => {
setTimeout(() => resolve(x + 10), 1000);
});
})
.then((x) => console.log(x)); // 11 (after 1 second)
// Return nothing โ next .then() receives undefined
Promise.resolve(1)
.then((x) => { x + 1; }) // no return statement
.then((x) => console.log(x)); // undefined
Async/Await
async/await is syntactic sugar over Promises. It makes asynchronous code read like synchronous code.
// Promise chain version
function getUser(id) {
return fetch(`/api/users/${id}`)
.then((res) => res.json())
.then((user) => {
return fetch(`/api/orders/${user.id}`)
.then((res) => res.json());
});
}
// async/await version
async function getUser(id) {
const res = await fetch(`/api/users/${id}`);
const user = await res.json();
const ordersRes = await fetch(`/api/orders/${user.id}`);
const orders = await ordersRes.json();
return orders;
}
How async/await Works
asyncmakes a function always return a Promiseawaitpauses execution until the Promise settlesawaitcan only be used inside anasyncfunction (or at the top level of ES modules)
async function demo() {
return 42;
}
// Same as:
function demo() {
return Promise.resolve(42);
}
demo().then((val) => console.log(val)); // 42
Even if you return a non-Promise value, async wraps it in Promise.resolve().
await Pauses, Not Blocks
A critical distinction: await pauses the async function, not the entire thread. Other code keeps running.
async function slowTask() {
console.log('A - start');
await new Promise((r) => setTimeout(r, 1000));
console.log('C - after await');
}
console.log('1');
slowTask();
console.log('2');
// Output:
// 1
// A - start
// 2 โ runs immediately, not blocked
// C - after await โ runs after 1 second
Error Handling
With Promises
Errors propagate down the chain until caught:
fetch('/api/users/1')
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((user) => processUser(user))
.catch((err) => {
// catches errors from ANY .then() above
console.error('Pipeline failed:', err.message);
});
You can catch at different levels:
fetch('/api/data')
.then((res) => res.json())
.then((data) => riskyTransform(data))
.catch((err) => {
console.warn('Transform failed, using fallback');
return fallbackData; // recovered โ chain continues
})
.then((data) => render(data))
.catch((err) => {
console.error('Render failed:', err);
});
With async/await
Use try/catch โ same pattern as synchronous error handling:
async function fetchUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
throw new Error(`HTTP error: ${res.status}`);
}
const user = await res.json();
return user;
} catch (err) {
console.error('Failed to fetch user:', err.message);
throw err; // re-throw if you want callers to handle it too
}
}
Error Handling Patterns Comparison
โโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Pattern โ Use When โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ .catch() at end โ Single error handler for chain โ
โ .catch() mid-chain โ Recovery + continue chain โ
โ try/catch in async โ Standard async error handling โ
โ try/catch per await โ Different handling per step โ
โ .catch() on await โ Inline fallback for one call โ
โ Global unhandledrejection โ Safety net for uncaught โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Inline .catch() Pattern
Sometimes you want to handle one specific await failure without a full try/catch:
async function loadDashboard() {
// If user fetch fails, use guest. Don't abort everything.
const user = await fetchUser().catch(() => ({ name: 'Guest', role: 'viewer' }));
// If preferences fail, use defaults
const prefs = await fetchPreferences(user.id).catch(() => defaultPrefs);
// This one must succeed
const data = await fetchDashboardData(user.id);
return { user, prefs, data };
}
Global Safety Net
Always register an unhandled rejection handler:
// Browser
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled rejection:', event.reason);
event.preventDefault(); // prevent default console error
});
// Node.js
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection at:', promise, 'reason:', reason);
});
Promise Combinators
JavaScript provides four static methods for working with multiple Promises concurrently.
Promise.all โ All Must Succeed
Waits for all Promises to resolve. Rejects immediately if any one rejects.
const [users, products, orders] = await Promise.all([
fetch('/api/users').then((r) => r.json()),
fetch('/api/products').then((r) => r.json()),
fetch('/api/orders').then((r) => r.json()),
]);
// All three requests run in parallel
// Results come back in the same order as the input array
Promise.all timing:
users: |โโโโโโโโโโโโ|
products: |โโโโโโโโโโโโโโโโโโ|
orders: |โโโโโโโโโ|
Result: |โโโโโโโโโโโโโโโโโโ| โ resolves when LAST one finishes
(or rejects when FIRST one fails)
Important: if one fails, you lose ALL results, even from Promises that succeeded.
Promise.allSettled โ Get All Results
Waits for all Promises to settle (resolve or reject). Never short-circuits.
const results = await Promise.allSettled([
fetch('/api/users'),
fetch('/api/broken-endpoint'),
fetch('/api/products'),
]);
// results = [
// { status: 'fulfilled', value: Response },
// { status: 'rejected', reason: Error },
// { status: 'fulfilled', value: Response },
// ]
const succeeded = results.filter((r) => r.status === 'fulfilled');
const failed = results.filter((r) => r.status === 'rejected');
console.log(`${succeeded.length} succeeded, ${failed.length} failed`);
Promise.race โ First to Settle
Returns the result of the first Promise to settle (resolve or reject).
// Timeout pattern
async function fetchWithTimeout(url, timeoutMs = 5000) {
const result = await Promise.race([
fetch(url),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), timeoutMs)
),
]);
return result;
}
try {
const response = await fetchWithTimeout('/api/slow-endpoint', 3000);
const data = await response.json();
} catch (err) {
console.error(err.message); // 'Request timed out' if > 3s
}
Promise.any โ First to Succeed
Returns the result of the first Promise to resolve. Ignores rejections unless ALL reject.
// Try multiple CDN sources, use whichever responds first
async function loadScript(filename) {
const cdns = [
`https://cdn1.example.com/${filename}`,
`https://cdn2.example.com/${filename}`,
`https://cdn3.example.com/${filename}`,
];
try {
const response = await Promise.any(
cdns.map((url) => fetch(url))
);
return response;
} catch (err) {
// AggregateError โ all CDNs failed
console.error('All CDNs failed:', err.errors);
throw err;
}
}
Combinator Comparison
โโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ Method โ Resolves โ Rejects โ Use Case โ
โโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโค
โ Promise.all โ All succeed โ Any fails โ Parallel tasks, โ
โ โ โ โ all required โ
โโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโค
โ Promise.allSettled โ All settle โ Never* โ Batch operations, โ
โ โ โ โ partial failure OK โ
โโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโค
โ Promise.race โ First settlesโ First settlesโ Timeouts, โ
โ โ โ โ fastest response โ
โโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโค
โ Promise.any โ First โ All fail โ Redundant sources, โ
โ โ succeeds โ (Aggregate) โ fallback chains โ
โโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโ
* Promise.allSettled always resolves with an array of result objects
Common Pitfalls
1. Swallowing Errors
The most common mistake โ a Promise rejection with no .catch():
// BAD โ error disappears silently
async function loadData() {
const data = await fetch('/api/data');
return data.json();
}
loadData(); // if this fails, no one catches the rejection
// GOOD โ handle errors
loadData().catch((err) => console.error('Failed:', err));
// or
try {
await loadData();
} catch (err) {
console.error('Failed:', err);
}
2. Sequential When Parallel Is Fine
// BAD โ sequential, takes ~3 seconds total
async function loadAll() {
const users = await fetch('/api/users'); // 1s
const products = await fetch('/api/products'); // 1s
const orders = await fetch('/api/orders'); // 1s
return { users, products, orders };
}
// GOOD โ parallel, takes ~1 second total
async function loadAll() {
const [users, products, orders] = await Promise.all([
fetch('/api/users'),
fetch('/api/products'),
fetch('/api/orders'),
]);
return { users, products, orders };
}
Sequential:
users: |โโโโโโโโ|
products: |โโโโโโโโ|
orders: |โโโโโโโโ|
Total: |โโโโโโโโโโโโโโโโโโโโโโโโโโโโ| ~3s
Parallel:
users: |โโโโโโโโ|
products: |โโโโโโโโ|
orders: |โโโโโโโโ|
Total: |โโโโโโโโ| ~1s
3. Using await in forEach
forEach doesn't wait for async callbacks. Use for...of or Promise.all with .map().
const urls = ['/api/a', '/api/b', '/api/c'];
// BAD โ fires all requests, doesn't wait for any
urls.forEach(async (url) => {
const data = await fetch(url);
console.log(await data.json());
});
console.log('Done'); // runs BEFORE any fetch completes
// GOOD โ sequential processing
for (const url of urls) {
const data = await fetch(url);
console.log(await data.json());
}
console.log('Done'); // runs AFTER all fetches complete
// GOOD โ parallel processing
const results = await Promise.all(
urls.map(async (url) => {
const data = await fetch(url);
return data.json();
})
);
console.log('Done', results);
4. Creating Unnecessary Promises
// BAD โ wrapping an existing Promise in another Promise
function getUser(id) {
return new Promise((resolve, reject) => {
fetch(`/api/users/${id}`)
.then((res) => res.json())
.then((data) => resolve(data))
.catch((err) => reject(err));
});
}
// GOOD โ just return the Promise chain
function getUser(id) {
return fetch(`/api/users/${id}`).then((res) => res.json());
}
// ALSO GOOD โ async functions already return Promises
async function getUser(id) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
5. Not Checking Response.ok
fetch only rejects on network failure, not HTTP error codes:
// BAD โ 404/500 responses will NOT throw
const res = await fetch('/api/missing');
const data = await res.json(); // might parse an error body
// GOOD โ check response status
const res = await fetch('/api/missing');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const data = await res.json();
6. Race Condition with State Updates
// BAD โ if user types fast, responses arrive out of order
searchInput.addEventListener('input', async (e) => {
const results = await search(e.target.value);
renderResults(results); // might render stale results
});
// GOOD โ track request order
let lastRequestId = 0;
searchInput.addEventListener('input', async (e) => {
const requestId = ++lastRequestId;
const results = await search(e.target.value);
// only render if this is still the latest request
if (requestId === lastRequestId) {
renderResults(results);
}
});
Real-World Patterns
Retry with Exponential Backoff
async function fetchWithRetry(url, options = {}) {
const { maxRetries = 3, baseDelay = 1000, maxDelay = 10000 } = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url);
if (!response.ok && response.status >= 500) {
throw new Error(`Server error: ${response.status}`);
}
return response;
} catch (err) {
if (attempt === maxRetries) throw err;
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay);
const jitter = delay * (0.5 + Math.random() * 0.5);
console.warn(
`Attempt ${attempt + 1} failed. Retrying in ${Math.round(jitter)}ms...`
);
await new Promise((r) => setTimeout(r, jitter));
}
}
}
// Usage
const response = await fetchWithRetry('/api/unstable-endpoint', {
maxRetries: 5,
baseDelay: 500,
});
Concurrent Task Queue
Limit concurrent async operations to prevent overwhelming a server:
async function asyncPool(concurrency, items, iteratorFn) {
const results = [];
const executing = new Set();
for (const [index, item] of items.entries()) {
const promise = Promise.resolve().then(() => iteratorFn(item, index));
results.push(promise);
executing.add(promise);
const cleanup = () => executing.delete(promise);
promise.then(cleanup, cleanup);
if (executing.size >= concurrency) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
// Upload 100 files, max 5 at a time
const files = getFilesToUpload(); // 100 files
const results = await asyncPool(5, files, async (file) => {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: formData });
return res.json();
});
Cancelable Async Operations
Using AbortController:
function createCancelableRequest(url) {
const controller = new AbortController();
const promise = fetch(url, { signal: controller.signal })
.then((res) => res.json());
return {
promise,
cancel: () => controller.abort(),
};
}
// Usage
const { promise, cancel } = createCancelableRequest('/api/slow-data');
// Cancel after 3 seconds if not done
const timeout = setTimeout(cancel, 3000);
try {
const data = await promise;
clearTimeout(timeout);
console.log(data);
} catch (err) {
if (err.name === 'AbortError') {
console.log('Request was cancelled');
} else {
throw err;
}
}
Sequential Pipeline
Process items one at a time, feeding each result into the next:
async function pipeline(initialValue, ...fns) {
let result = initialValue;
for (const fn of fns) {
result = await fn(result);
}
return result;
}
const processedUser = await pipeline(
userId,
async (id) => {
const res = await fetch(`/api/users/${id}`);
return res.json();
},
async (user) => {
const res = await fetch(`/api/enrich?email=${user.email}`);
const extra = await res.json();
return { ...user, ...extra };
},
async (user) => {
await fetch('/api/audit', {
method: 'POST',
body: JSON.stringify({ action: 'view', userId: user.id }),
});
return user;
}
);
Polling Pattern
async function poll(fn, { interval = 2000, timeout = 30000, validate }) {
const start = Date.now();
while (Date.now() - start < timeout) {
const result = await fn();
if (validate(result)) {
return result;
}
await new Promise((r) => setTimeout(r, interval));
}
throw new Error('Polling timed out');
}
// Wait for a job to complete
const result = await poll(
() => fetch('/api/jobs/123').then((r) => r.json()),
{
interval: 3000,
timeout: 60000,
validate: (job) => job.status === 'completed',
}
);
Promise-based Event Listener
Wait for a one-time event:
function waitForEvent(element, eventName, timeout = 5000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Timeout waiting for ${eventName}`));
}, timeout);
element.addEventListener(
eventName,
(event) => {
clearTimeout(timer);
resolve(event);
},
{ once: true }
);
});
}
// Usage
const clickEvent = await waitForEvent(button, 'click', 10000);
console.log('Button was clicked at', clickEvent.clientX, clickEvent.clientY);
Promisifying Callback-Based APIs
Convert callback-style functions to Promise-based:
function promisify(fn) {
return function (...args) {
return new Promise((resolve, reject) => {
fn(...args, (err, result) => {
if (err) reject(err);
else resolve(result);
});
});
};
}
// Convert Node.js fs.readFile
import fs from 'fs';
const readFile = promisify(fs.readFile);
const content = await readFile('/path/to/file', 'utf-8');
// Node.js also has util.promisify built in:
import { promisify as nodePromisify } from 'util';
const readFileAsync = nodePromisify(fs.readFile);
async Iteration
for await...of handles async iterables โ streams of Promises:
async function* fetchPages(baseUrl) {
let page = 1;
let hasMore = true;
while (hasMore) {
const res = await fetch(`${baseUrl}?page=${page}`);
const data = await res.json();
yield data.items;
hasMore = data.hasNextPage;
page++;
}
}
// Consume async generator
for await (const items of fetchPages('/api/products')) {
for (const item of items) {
processItem(item);
}
}
Testing Async Code
With Jest/Vitest
// Return the Promise
test('fetches user data', () => {
return fetchUser(1).then((user) => {
expect(user.name).toBe('Alice');
});
});
// async/await (preferred)
test('fetches user data', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
});
// Testing rejections
test('throws on invalid id', async () => {
await expect(fetchUser(-1)).rejects.toThrow('Invalid user ID');
});
// Mocking async functions
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ id: 1, name: 'Alice' }),
});
Faking Timers
test('retries on failure', async () => {
jest.useFakeTimers();
const mockFetch = jest
.fn()
.mockRejectedValueOnce(new Error('fail'))
.mockRejectedValueOnce(new Error('fail'))
.mockResolvedValueOnce({ ok: true });
const promise = fetchWithRetry('/api/test');
// Fast-forward through retry delays
await jest.advanceTimersByTimeAsync(10000);
const result = await promise;
expect(result.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(3);
jest.useRealTimers();
});
Microtask Priority
Promises use the microtask queue, which has higher priority than the macrotask queue (setTimeout, setInterval). This matters for execution order:
console.log('1 - sync');
setTimeout(() => console.log('2 - macrotask'), 0);
Promise.resolve().then(() => console.log('3 - microtask'));
console.log('4 - sync');
// Output:
// 1 - sync
// 4 - sync
// 3 - microtask โ runs BEFORE setTimeout
// 2 - macrotask
For a deeper dive into task scheduling, see the Event Loop article.
Key Takeaways
- Promises represent future values โ pending, fulfilled, or rejected
- Always handle rejections โ unhandled rejections crash Node.js and clutter browser consoles
- async/await is syntactic sugar โ it compiles to Promise chains under the hood
- Use Promise.all for parallel work โ don't await sequentially when operations are independent
- Use Promise.allSettled when partial failure is OK โ get all results regardless of individual failures
- Check response.ok โ fetch doesn't reject on HTTP errors
- Avoid await in forEach โ use for...of or Promise.all with map
- Don't wrap Promises in Promises โ return the chain directly
- Handle race conditions โ track request IDs or use AbortController
- Promises resolve via microtask queue โ they run before setTimeout/setInterval callbacks