JavaScript Promises & Async
JavaScript is single-threaded — it runs one piece of code at a time on the main thread. Synchronous code blocks execution until it completes. If a synchronous operation takes 500ms to fetch data, the entire UI freezes for that duration. Asynchronous programming solves this by allowing operations to execute in the background and notifying your code when they complete.
JavaScript has evolved through three eras of async patterns: callbacks (Node.js era), Promises (ES6), and async/await (ES2017). Each evolution improves readability and error handling while maintaining the same underlying event-driven model. Understanding all three is essential for reading and writing modern JavaScript.
The key insight: JavaScript does not wait. When you call fetch() or setTimeout(), the runtime starts the operation and immediately continues executing the next line. When the operation finishes, a callback is placed in the task queue and processed by the event loop.
A callback is a function passed as an argument to another function, to be executed when the asynchronous operation completes. This was the original async pattern in JavaScript, used extensively in Node.js core APIs and browser APIs.
| 1 | // Simple callback pattern |
| 2 | function fetchData(callback) { |
| 3 | setTimeout(() => { |
| 4 | const data = { id: 1, name: 'John Doe' }; |
| 5 | callback(null, data); // Node.js convention: error-first |
| 6 | }, 1000); |
| 7 | } |
| 8 | |
| 9 | fetchData((error, data) => { |
| 10 | if (error) { |
| 11 | console.error('Failed:', error); |
| 12 | return; |
| 13 | } |
| 14 | console.log('Data:', data); |
| 15 | }); |
| 16 | |
| 17 | // Error-first callback convention |
| 18 | function readFile(path, callback) { |
| 19 | fs.readFile(path, 'utf-8', (err, content) => { |
| 20 | if (err) { |
| 21 | callback(err, null); |
| 22 | return; |
| 23 | } |
| 24 | callback(null, content); |
| 25 | }); |
| 26 | } |
The callback pattern becomes problematic with nested or sequential async operations. This is known as callback hell — deeply nested, pyramid-shaped code that is difficult to read, reason about, and debug.
| 1 | // Callback hell — deeply nested, hard to follow |
| 2 | function getUserData(userId) { |
| 3 | getUser(userId, (err, user) => { |
| 4 | if (err) { |
| 5 | console.error('Failed to get user:', err); |
| 6 | return; |
| 7 | } |
| 8 | |
| 9 | getPosts(user.id, (err, posts) => { |
| 10 | if (err) { |
| 11 | console.error('Failed to get posts:', err); |
| 12 | return; |
| 13 | } |
| 14 | |
| 15 | getComments(posts[0].id, (err, comments) => { |
| 16 | if (err) { |
| 17 | console.error('Failed to get comments:', err); |
| 18 | return; |
| 19 | } |
| 20 | |
| 21 | getLikes(comments[0].id, (err, likes) => { |
| 22 | if (err) { |
| 23 | console.error('Failed to get likes:', err); |
| 24 | return; |
| 25 | } |
| 26 | |
| 27 | // Five levels deep — with data |
| 28 | renderPage({ user, posts, comments, likes }); |
| 29 | }); |
| 30 | }); |
| 31 | }); |
| 32 | }); |
| 33 | } |
| 34 | |
| 35 | // Problems with callbacks: |
| 36 | // 1. Pyramid of doom — deep nesting |
| 37 | // 2. Error handling repeats at every level |
| 38 | // 3. Mixed concerns — business logic + error handling intertwined |
| 39 | // 4. No standard way to parallelize operations |
| 40 | // 5. Inversion of control — you give control to the callback |
note
A Promise is an object representing the eventual completion (or failure) of an asynchronous operation. It is a placeholder for a value that does not exist yet but will at some point. Promises have three states:
| 1 | // Creating a Promise |
| 2 | const promise = new Promise((resolve, reject) => { |
| 3 | // Executor runs immediately (synchronously) |
| 4 | const success = true; |
| 5 | |
| 6 | setTimeout(() => { |
| 7 | if (success) { |
| 8 | resolve({ id: 1, name: 'Data payload' }); |
| 9 | // Promise transitions: PENDING → FULFILLED |
| 10 | } else { |
| 11 | reject(new Error('Operation failed')); |
| 12 | // Promise transitions: PENDING → REJECTED |
| 13 | } |
| 14 | }, 1000); |
| 15 | }); |
| 16 | |
| 17 | // Consuming a Promise |
| 18 | promise |
| 19 | .then((data) => { |
| 20 | console.log('Resolved with:', data); |
| 21 | return data.name; |
| 22 | }) |
| 23 | .catch((error) => { |
| 24 | console.error('Rejected with:', error.message); |
| 25 | }) |
| 26 | .finally(() => { |
| 27 | console.log('Cleanup — runs regardless of outcome'); |
| 28 | }); |
| 29 | |
| 30 | // Immediately resolved promise |
| 31 | const resolved = Promise.resolve({ cached: true }); |
| 32 | resolved.then(console.log); |
| 33 | |
| 34 | // Immediately rejected promise |
| 35 | const rejected = Promise.reject(new Error('Cache miss')); |
| 36 | rejected.catch(console.error); |
The executor function is called immediately and synchronously by the Promise constructor. The resolve and reject functions are provided by the runtime — you call them to settle the promise. Once settled, a promise is immutable: subsequent calls to resolve or reject are ignored.
warning
Promise chaining is the solution to callback hell. Each .then() returns a new promise, allowing you to flatten nested async operations into a linear chain. Values returned from a .then() handler become the resolution value of the returned promise, enabling data transformation through the chain.
| 1 | // Promise chaining — replacing callback hell |
| 2 | fetchUser(1) |
| 3 | .then((user) => { |
| 4 | console.log('User:', user); |
| 5 | return fetchPosts(user.id); // Return a promise — chain continues |
| 6 | }) |
| 7 | .then((posts) => { |
| 8 | console.log('Posts:', posts); |
| 9 | return fetchComments(posts[0].id); |
| 10 | }) |
| 11 | .then((comments) => { |
| 12 | console.log('Comments:', comments); |
| 13 | return fetchLikes(comments[0].id); |
| 14 | }) |
| 15 | .then((likes) => { |
| 16 | console.log('Likes:', likes); |
| 17 | // Final chain — render everything |
| 18 | renderPage(likes); |
| 19 | }) |
| 20 | .catch((error) => { |
| 21 | // Single error handler for the entire chain |
| 22 | console.error('Any step failed:', error); |
| 23 | showErrorPage(error); |
| 24 | }) |
| 25 | .finally(() => { |
| 26 | hideLoadingSpinner(); |
| 27 | }); |
| 28 | |
| 29 | // Returning values vs promises |
| 30 | Promise.resolve(5) |
| 31 | .then((x) => x * 2) // Returns 10 (wrapped in promise automatically) |
| 32 | .then((x) => x + 1) // Returns 11 |
| 33 | .then((x) => { // Returns a promise (async transformation) |
| 34 | return new Promise((resolve) => { |
| 35 | setTimeout(() => resolve(x * 3), 100); |
| 36 | }); |
| 37 | }) |
| 38 | .then((x) => console.log(x)); // 33 |
| 39 | |
| 40 | // Error recovery in chains |
| 41 | fetchData() |
| 42 | .catch((err) => { |
| 43 | console.warn('First attempt failed, retrying...', err); |
| 44 | return fetchData(); // Return fallback promise |
| 45 | }) |
| 46 | .then((data) => { |
| 47 | // If both fail, this won't run |
| 48 | processData(data); |
| 49 | }); |
Key chaining rules: (1) returning a value from .then() wraps it in Promise.resolve(). (2) returning a promise from .then() flattens it — the chain waits for it to settle. (3) throwing an error inside .then() rejects the returned promise, jumping to the next .catch(). (4) returning from .catch() recovers the chain.
| 1 | // Chaining rules illustrated |
| 2 | Promise.resolve(1) |
| 3 | .then((v) => { |
| 4 | console.log(v); // 1 |
| 5 | return v + 1; // Returns 2 (auto-wrapped in Promise.resolve) |
| 6 | }) |
| 7 | .then((v) => { |
| 8 | console.log(v); // 2 |
| 9 | throw new Error('Intentional error'); |
| 10 | // Throwing rejects the promise — skips to next .catch() |
| 11 | }) |
| 12 | .then((v) => { |
| 13 | // Never reached — skipped by the error |
| 14 | console.log('This will not run'); |
| 15 | }) |
| 16 | .catch((err) => { |
| 17 | console.log(err.message); // "Intentional error" |
| 18 | return 'recovered'; // Recover — next .then() receives this |
| 19 | }) |
| 20 | .then((v) => { |
| 21 | console.log(v); // "recovered" — chain continues after recovery |
| 22 | }); |
pro tip
The Promise class provides static methods for composing multiple promises. These methods handle the common pattern of running several async operations in parallel and waiting for their results. Each method has different semantics for handling success, failure, and timing.
| Method | Settles When | Result | Rejection Behavior |
|---|---|---|---|
| Promise.all | All promises settle | Array of all results (order preserved) | Immediately rejects on first rejection (fail-fast) |
| Promise.allSettled | All promises settle | Array of {status, value/reason} objects | Never rejects — waits for all to complete |
| Promise.race | First promise settles (either outcome) | Value or reason of the first settled promise | Rejects if first settlement is rejection |
| Promise.any | First promise fulfills | Value of first fulfilled promise | Rejects with AggregateError if all reject |
| 1 | // Promise.all — wait for all, fail-fast |
| 2 | const [user, posts, settings] = await Promise.all([ |
| 3 | fetch('/api/user'), |
| 4 | fetch('/api/posts'), |
| 5 | fetch('/api/settings') |
| 6 | ]); |
| 7 | // All three requests run in parallel |
| 8 | // If any fails, all are rejected immediately |
| 9 | |
| 10 | // Promise.allSettled — wait for all, no rejection |
| 11 | const results = await Promise.allSettled([ |
| 12 | fetch('/api/user'), |
| 13 | fetch('/api/posts'), |
| 14 | fetch('/api/settings') |
| 15 | ]); |
| 16 | |
| 17 | for (const result of results) { |
| 18 | if (result.status === 'fulfilled') { |
| 19 | console.log('Success:', result.value); |
| 20 | } else { |
| 21 | console.warn('Failed:', result.reason); |
| 22 | // Handle individual failures without crashing others |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | // Promise.race — first to settle wins |
| 27 | const timeout = new Promise((_, reject) => |
| 28 | setTimeout(() => reject(new Error('Request timed out')), 5000) |
| 29 | ); |
| 30 | |
| 31 | const data = await Promise.race([ |
| 32 | fetch('/api/data'), |
| 33 | timeout |
| 34 | ]); |
| 35 | // If fetch takes longer than 5s, timeout wins |
| 36 | |
| 37 | // Promise.any — first fulfillment wins |
| 38 | const result = await Promise.any([ |
| 39 | fetch('/api/mirror-1'), |
| 40 | fetch('/api/mirror-2'), |
| 41 | fetch('/api/mirror-3') |
| 42 | ]); |
| 43 | // Returns fastest successful response |
| 44 | // Only rejects if ALL requests fail |
Common anti-pattern: using await sequentially when operations are independent. Always use Promise.all for parallel execution — it is significantly faster than awaiting one operation at a time.
| 1 | // BAD — sequential (slow) |
| 2 | const user = await fetchUser(); |
| 3 | const posts = await fetchPosts(); // Starts after user finishes |
| 4 | const comments = await fetchComments(); // Starts after posts finishes |
| 5 | // Total time: sum of all three |
| 6 | |
| 7 | // GOOD — parallel (fast) |
| 8 | const [user, posts, comments] = await Promise.all([ |
| 9 | fetchUser(), |
| 10 | fetchPosts(), // Starts immediately |
| 11 | fetchComments() // Starts immediately |
| 12 | ]); |
| 13 | // Total time: max of all three |
| 14 | |
| 15 | // Promise.all with timeout per request |
| 16 | function withTimeout(promise, ms) { |
| 17 | const timeout = new Promise((_, reject) => |
| 18 | setTimeout(() => reject(new Error('Timed out')), ms) |
| 19 | ); |
| 20 | return Promise.race([promise, timeout]); |
| 21 | } |
| 22 | |
| 23 | const results = await Promise.all([ |
| 24 | withTimeout(fetchUser(), 3000), |
| 25 | withTimeout(fetchPosts(), 3000), |
| 26 | withTimeout(fetchComments(), 3000) |
| 27 | ]); |
best practice
Async/await is syntactic sugar over Promises, introduced in ES2017. An async function always returns a Promise. The await keyword pauses execution of the async function until the awaited Promise settles — without blocking the main thread. This makes asynchronous code read like synchronous code.
| 1 | // async function — always returns a promise |
| 2 | async function fetchUserData(userId) { |
| 3 | const response = await fetch(`/api/users/${userId}`); |
| 4 | const data = await response.json(); |
| 5 | return data; |
| 6 | // Equivalent to: return Promise.resolve(data); |
| 7 | } |
| 8 | |
| 9 | // Consuming an async function |
| 10 | const user = await fetchUserData(42); |
| 11 | console.log(user); |
| 12 | |
| 13 | // Calling without await returns a promise |
| 14 | const promise = fetchUserData(42); // Promise<User> |
| 15 | promise.then(user => console.log(user)); |
| 16 | |
| 17 | // Sequential await (when steps depend on each other) |
| 18 | async function loadUserProfile(userId) { |
| 19 | const user = await fetchUser(userId); |
| 20 | const posts = await fetchPosts(user.id); // Depends on user |
| 21 | const friends = await fetchFriends(user.id); // Depends on user |
| 22 | return { user, posts, friends }; |
| 23 | } |
| 24 | |
| 25 | // Parallel await (when steps are independent) |
| 26 | async function loadDashboard() { |
| 27 | const [user, notifications, analytics] = await Promise.all([ |
| 28 | fetchUser(), |
| 29 | fetchNotifications(), |
| 30 | fetchAnalytics() |
| 31 | ]); |
| 32 | return { user, notifications, analytics }; |
| 33 | } |
Top-level await (ES2022) allows using await outside of async functions in modules. The module waits for the awaited promise before its exports are available to consumers.
| 1 | // Top-level await (ES2022 — modules only) |
| 2 | // data.js |
| 3 | export const config = await fetch('/api/config').then(r => r.json()); |
| 4 | console.log('Config loaded:', config); |
| 5 | // Module waits for this to complete before importing module resolves |
| 6 | |
| 7 | // app.js |
| 8 | import { config } from './data.js'; |
| 9 | // Import waits until config is loaded |
| 10 | console.log('Using config:', config.baseUrl); |
| 11 | |
| 12 | // Await inside async iteration |
| 13 | async function* fetchPages(url, maxPages = 10) { |
| 14 | for (let page = 1; page <= maxPages; page++) { |
| 15 | const response = await fetch(`${url}?page=${page}`); |
| 16 | const data = await response.json(); |
| 17 | yield data; |
| 18 | if (!data.hasMore) break; |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | for await (const page of fetchPages('/api/items')) { |
| 23 | console.log('Page data:', page); |
| 24 | renderItems(page.items); |
| 25 | } |
info
Proper error handling is critical in async code. Unhandled rejections crash Node.js processes and cause silent failures in browsers. With async/await, errors are handled using standard try/catch blocks. With promise chains, use .catch(). Understanding how errors propagate through chains and async functions prevents silently swallowed failures.
| 1 | // try/catch with async/await — clean and familiar |
| 2 | async function loadData() { |
| 3 | try { |
| 4 | const user = await fetchUser(1); |
| 5 | const posts = await fetchPosts(user.id); |
| 6 | return { user, posts }; |
| 7 | } catch (error) { |
| 8 | console.error('Failed to load:', error); |
| 9 | showErrorUI(error.message); |
| 10 | throw error; // Re-throw if caller needs to know |
| 11 | } |
| 12 | } |
| 13 | |
| 14 | // Granular try/catch — handle each operation |
| 15 | async function loadDashboard() { |
| 16 | let user, posts, notifications; |
| 17 | |
| 18 | try { |
| 19 | user = await fetchUser(); |
| 20 | } catch (e) { |
| 21 | console.warn('User fetch failed, using fallback:', e); |
| 22 | user = { id: 0, name: 'Guest' }; |
| 23 | } |
| 24 | |
| 25 | try { |
| 26 | posts = await fetchPosts(); |
| 27 | } catch (e) { |
| 28 | console.warn('Posts failed, showing empty:', e); |
| 29 | posts = []; |
| 30 | } |
| 31 | |
| 32 | return { user, posts }; |
| 33 | } |
| 34 | |
| 35 | // Error propagation — uncaught errors bubble up |
| 36 | async function level1() { |
| 37 | await level2(); |
| 38 | } |
| 39 | |
| 40 | async function level2() { |
| 41 | await level3(); |
| 42 | } |
| 43 | |
| 44 | async function level3() { |
| 45 | throw new Error('Deep error'); |
| 46 | } |
| 47 | |
| 48 | try { |
| 49 | await level1(); |
| 50 | } catch (e) { |
| 51 | console.log('Caught:', e.message); // "Deep error" |
| 52 | } |
| 1 | // Promise chain error handling |
| 2 | fetchUser(1) |
| 3 | .then(user => fetchPosts(user.id)) |
| 4 | .then(posts => { |
| 5 | if (posts.length === 0) { |
| 6 | throw new Error('No posts found'); // Rejects the chain |
| 7 | } |
| 8 | return posts; |
| 9 | }) |
| 10 | .then(posts => renderPosts(posts)) |
| 11 | .catch(error => { |
| 12 | // Catches errors from ANY step above |
| 13 | if (error.message === 'No posts found') { |
| 14 | renderEmptyState(); |
| 15 | } else { |
| 16 | renderErrorPage(error); |
| 17 | } |
| 18 | }); |
| 19 | |
| 20 | // Finally — runs regardless of success or failure |
| 21 | function saveWithCleanup(data) { |
| 22 | showLoading(); |
| 23 | return saveData(data) |
| 24 | .then(result => { |
| 25 | showSuccess(); |
| 26 | return result; |
| 27 | }) |
| 28 | .catch(error => { |
| 29 | showError(error); |
| 30 | throw error; // Re-throw to propagate |
| 31 | }) |
| 32 | .finally(() => { |
| 33 | hideLoading(); // Always runs |
| 34 | }); |
| 35 | } |
| 36 | |
| 37 | // Unhandled rejection detection |
| 38 | process.on('unhandledRejection', (reason, promise) => { |
| 39 | console.error('Unhandled rejection at:', promise, 'reason:', reason); |
| 40 | }); |
| 41 | |
| 42 | window.addEventListener('unhandledrejection', (event) => { |
| 43 | console.error('Unhandled rejection:', event.reason); |
| 44 | event.preventDefault(); // Prevent console warning (optional) |
| 45 | }); |
danger
Understanding the event loop is essential for debugging async code. JavaScript has a microtask queue (for promise callbacks, queueMicrotask, MutationObserver) and a macrotask queue (for setTimeout, setInterval, I/O, UI rendering). Microtasks have priority and are drained before the next macrotask.
| 1 | // Execution order demonstration |
| 2 | console.log('1: Sync start'); |
| 3 | |
| 4 | setTimeout(() => { |
| 5 | console.log('6: Macrotask (setTimeout)'); |
| 6 | }, 0); |
| 7 | |
| 8 | Promise.resolve().then(() => { |
| 9 | console.log('3: Microtask (promise)'); |
| 10 | }); |
| 11 | |
| 12 | Promise.resolve().then(() => { |
| 13 | console.log('4: Microtask (promise)'); |
| 14 | }); |
| 15 | |
| 16 | queueMicrotask(() => { |
| 17 | console.log('5: Microtask (queueMicrotask)'); |
| 18 | }); |
| 19 | |
| 20 | console.log('2: Sync end'); |
| 21 | |
| 22 | // Output: |
| 23 | // 1: Sync start |
| 24 | // 2: Sync end |
| 25 | // 3: Microtask (promise) |
| 26 | // 4: Microtask (promise) |
| 27 | // 5: Microtask (queueMicrotask) |
| 28 | // 6: Macrotask (setTimeout) |
| 29 | |
| 30 | // Microtasks inside microtasks |
| 31 | Promise.resolve().then(() => { |
| 32 | console.log('A: First microtask'); |
| 33 | Promise.resolve().then(() => { |
| 34 | console.log('B: Microtask inside microtask'); |
| 35 | }); |
| 36 | }); |
| 37 | |
| 38 | Promise.resolve().then(() => { |
| 39 | console.log('C: Second microtask'); |
| 40 | }); |
| 41 | |
| 42 | // Output: |
| 43 | // A: First microtask |
| 44 | // C: Second microtask |
| 45 | // B: Microtask inside microtask |
| 46 | // (All microtasks drain before next macrotask) |
This microtask priority has practical implications: promise callbacks run before setTimeout callbacks, even if both are "ready" at the same time. This means promise-based code has higher priority than timer-based code, which is why async/await feels more responsive.
| 1 | // Practical implications of microtask priority |
| 2 | const button = document.getElementById('btn'); |
| 3 | button.addEventListener('click', () => { |
| 4 | console.log('1: Click handler'); |
| 5 | Promise.resolve().then(() => { |
| 6 | console.log('3: Microtask (runs before next event)'); |
| 7 | }); |
| 8 | setTimeout(() => { |
| 9 | console.log('4: Macrotask (runs after render)'); |
| 10 | }, 0); |
| 11 | console.log('2: Sync end'); |
| 12 | }); |
| 13 | |
| 14 | // Click once: |
| 15 | // 1: Click handler |
| 16 | // 2: Sync end |
| 17 | // 3: Microtask (promise) |
| 18 | // 4: Macrotask (setTimeout) |
| 19 | |
| 20 | // Rendering — microtasks can block the UI |
| 21 | function blockMicrotasks() { |
| 22 | let i = 0; |
| 23 | function doMicrotask() { |
| 24 | queueMicrotask(() => { |
| 25 | if (i++ < 1000) { |
| 26 | doMicrotask(); // 1000 chained microtasks |
| 27 | } |
| 28 | }); |
| 29 | } |
| 30 | doMicrotask(); |
| 31 | // UI cannot render until all 1000 microtasks complete |
| 32 | } |
warning
| 1 | // Complete pattern — async with error handling and cleanup |
| 2 | class DataLoader { |
| 3 | constructor(url) { |
| 4 | this.url = url; |
| 5 | this.controller = null; |
| 6 | } |
| 7 | |
| 8 | async load() { |
| 9 | // Cancel previous request |
| 10 | if (this.controller) { |
| 11 | this.controller.abort(); |
| 12 | } |
| 13 | |
| 14 | this.controller = new AbortController(); |
| 15 | const { signal } = this.controller; |
| 16 | |
| 17 | try { |
| 18 | const response = await fetch(this.url, { signal }); |
| 19 | if (!response.ok) { |
| 20 | throw new Error(`HTTP ${response.status}: ${response.statusText}`); |
| 21 | } |
| 22 | const data = await response.json(); |
| 23 | return data; |
| 24 | } catch (error) { |
| 25 | if (error.name === 'AbortError') { |
| 26 | console.log('Request cancelled'); |
| 27 | return null; // Silent return on cancellation |
| 28 | } |
| 29 | throw error; // Re-throw non-abort errors |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | cancel() { |
| 34 | if (this.controller) { |
| 35 | this.controller.abort(); |
| 36 | } |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | // Usage |
| 41 | const loader = new DataLoader('/api/data'); |
| 42 | |
| 43 | async function init() { |
| 44 | try { |
| 45 | const data = await loader.load(); |
| 46 | renderData(data); |
| 47 | } catch (error) { |
| 48 | showError(error); |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | // Cleanup on unmount |
| 53 | function cleanup() { |
| 54 | loader.cancel(); |
| 55 | } |
best practice