|$ curl https://forge-ai.dev/api/markdown?path=docs/js/promises
$cat docs/javascript-promises-&-async.md
updated This week·45 min read·published

JavaScript Promises & Async

JavaScriptPromisesAsyncAdvancedAdvanced
Introduction

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.

Callbacks

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.

callbacks.js
JavaScript
1// Simple callback pattern
2function 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
9fetchData((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
18function 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.

callback-hell.js
JavaScript
1// Callback hell — deeply nested, hard to follow
2function 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

The error-first callback pattern (err, result) was standard in Node.js. If the operation succeeded, err is null and result contains the data. If it failed, err contains the error and result is null. Promises and async/await were created to solve the problems inherent in this pattern.
Promise Basics

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:

Promise States
PENDING — Initial state, neither fulfilled nor rejected The async operation has not completed yet Promise is in the "settled" state ↓ ↓FULFILLED REJECTED Operation succeeded Operation failed .then() handlers fire .catch() handlers fire resolve() was called reject() was calledOnce settled (fulfilled or rejected), a promise is IMMUTABLE.It cannot change state again.
promise-basics.js
JavaScript
1// Creating a Promise
2const 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
18promise
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
31const resolved = Promise.resolve({ cached: true });
32resolved.then(console.log);
33
34// Immediately rejected promise
35const rejected = Promise.reject(new Error('Cache miss'));
36rejected.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

If you create a promise and never call resolve or reject, the promise remains in the pending state forever. This is called a "hanging promise." It will never trigger .then(), .catch(), or .finally(). Always ensure every code path in the executor calls either resolve or reject.
Promise Chaining

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.

promise-chaining.js
JavaScript
1// Promise chaining — replacing callback hell
2fetchUser(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
30Promise.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
41fetchData()
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.

chaining-rules.js
JavaScript
1// Chaining rules illustrated
2Promise.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

Always return or throw in promise handlers. If you forget to return, the next .then() receives undefined, which is a common source of bugs. Use an ESLint rule like promise/always-return to catch this automatically.
Static Methods

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.

MethodSettles WhenResultRejection Behavior
Promise.allAll promises settleArray of all results (order preserved)Immediately rejects on first rejection (fail-fast)
Promise.allSettledAll promises settleArray of {status, value/reason} objectsNever rejects — waits for all to complete
Promise.raceFirst promise settles (either outcome)Value or reason of the first settled promiseRejects if first settlement is rejection
Promise.anyFirst promise fulfillsValue of first fulfilled promiseRejects with AggregateError if all reject
promise-static.js
JavaScript
1// Promise.all — wait for all, fail-fast
2const [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
11const results = await Promise.allSettled([
12 fetch('/api/user'),
13 fetch('/api/posts'),
14 fetch('/api/settings')
15]);
16
17for (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
27const timeout = new Promise((_, reject) =>
28 setTimeout(() => reject(new Error('Request timed out')), 5000)
29);
30
31const 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
38const 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.

parallel-vs-sequential.js
JavaScript
1// BAD — sequential (slow)
2const user = await fetchUser();
3const posts = await fetchPosts(); // Starts after user finishes
4const comments = await fetchComments(); // Starts after posts finishes
5// Total time: sum of all three
6
7// GOOD — parallel (fast)
8const [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
16function 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
23const results = await Promise.all([
24 withTimeout(fetchUser(), 3000),
25 withTimeout(fetchPosts(), 3000),
26 withTimeout(fetchComments(), 3000)
27]);

best practice

Use Promise.allSettled when you need results from all operations even if some fail — for example, loading multiple sections on a dashboard where each section handles its own error state. Use Promise.all when a single failure means the entire operation is meaningless, like loading dependent data.
Async / Await

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.

async-await.js
JavaScript
1// async function — always returns a promise
2async 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
10const user = await fetchUserData(42);
11console.log(user);
12
13// Calling without await returns a promise
14const promise = fetchUserData(42); // Promise<User>
15promise.then(user => console.log(user));
16
17// Sequential await (when steps depend on each other)
18async 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)
26async 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.

top-level-await.js
JavaScript
1// Top-level await (ES2022 — modules only)
2// data.js
3export const config = await fetch('/api/config').then(r => r.json());
4console.log('Config loaded:', config);
5// Module waits for this to complete before importing module resolves
6
7// app.js
8import { config } from './data.js';
9// Import waits until config is loaded
10console.log('Using config:', config.baseUrl);
11
12// Await inside async iteration
13async 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
22for await (const page of fetchPages('/api/items')) {
23 console.log('Page data:', page);
24 renderItems(page.items);
25}

info

Top-level await blocks the evaluation of the importing module. Use it for initialization code that must complete before the module can be used — like loading configuration, establishing database connections, or initializing SDKs. Avoid it for non-essential tasks where a loading state is acceptable.
preview
Error Handling

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.

async-error-handling.js
JavaScript
1// try/catch with async/await — clean and familiar
2async 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
15async 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
36async function level1() {
37 await level2();
38}
39
40async function level2() {
41 await level3();
42}
43
44async function level3() {
45 throw new Error('Deep error');
46}
47
48try {
49 await level1();
50} catch (e) {
51 console.log('Caught:', e.message); // "Deep error"
52}
promise-error-handling.js
JavaScript
1// Promise chain error handling
2fetchUser(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
21function 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
38process.on('unhandledRejection', (reason, promise) => {
39 console.error('Unhandled rejection at:', promise, 'reason:', reason);
40});
41
42window.addEventListener('unhandledrejection', (event) => {
43 console.error('Unhandled rejection:', event.reason);
44 event.preventDefault(); // Prevent console warning (optional)
45});

danger

Always handle promise rejections. Unhandled rejections in Node.js (since v15) crash the process. In browsers, they cause memory leaks and silent failures. Use global handlers sparingly — prefer explicit try/catch or .catch() on every promise path. If using ESLint, enable the no-floating-promises rule.
Microtasks & The Event Loop

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.

Event Loop Execution Order
1. Execute synchronous code (call stack)2. Drain ALL microtasks (promise callbacks, queueMicrotask) - If microtasks enqueue more microtasks, drain those too3. Execute ONE macrotask (setTimeout callback, I/O)4. Render UI (if needed)5. Go to step 2 (drain microtasks again)
event-loop.js
JavaScript
1// Execution order demonstration
2console.log('1: Sync start');
3
4setTimeout(() => {
5 console.log('6: Macrotask (setTimeout)');
6}, 0);
7
8Promise.resolve().then(() => {
9 console.log('3: Microtask (promise)');
10});
11
12Promise.resolve().then(() => {
13 console.log('4: Microtask (promise)');
14});
15
16queueMicrotask(() => {
17 console.log('5: Microtask (queueMicrotask)');
18});
19
20console.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
31Promise.resolve().then(() => {
32 console.log('A: First microtask');
33 Promise.resolve().then(() => {
34 console.log('B: Microtask inside microtask');
35 });
36});
37
38Promise.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.

microtask-priority.js
JavaScript
1// Practical implications of microtask priority
2const button = document.getElementById('btn');
3button.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
21function 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

Chaining many microtasks can starve the event loop and prevent UI rendering. The browser cannot paint frames until the microtask queue is empty. For long-running computations, consider using setTimeout (macrotask) or requestAnimationFrame to yield to the render cycle.
Best Practices
Prefer async/await over raw .then() chains for readability — it reads like synchronous code and handles errors with try/catch
Always handle rejections — every promise chain must end with .catch() and every async function call must be in a try/catch or awaited in a context that handles the error
Use Promise.all for parallel independent operations — never await one operation at a time when they don't depend on each other
Avoid promise nesting — use flat chains with return instead of new Promise inside .then(). If you need a new promise, return it
Use AbortController with fetch to cancel requests when the component unmounts or the user navigates away
Be aware of the microtask queue — promise callbacks run before setTimeout, which affects both performance and correctness
Use Promise.any for race conditions where you need the first success (e.g., multiple API mirrors)
Use Promise.allSettled when you need all results regardless of individual failures
Pass only the function reference to setTimeout/requestAnimationFrame — avoid anonymous wrappers that prevent cleanup
Handle async errors at the boundary — catch errors in top-level event handlers and route handlers, not deep in utility functions
async-pattern.js
JavaScript
1// Complete pattern — async with error handling and cleanup
2class 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
41const loader = new DataLoader('/api/data');
42
43async 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
53function cleanup() {
54 loader.cancel();
55}

best practice

The AbortController API is the standard way to cancel fetch requests. Always pair fetch() with an abort signal in components that mount/unmount. This prevents state updates on unmounted components and reduces server load from abandoned requests.
$Blueprint — Engineering Documentation·Section ID: JS-04·Revision: 1.0