Mastering Async JavaScript: Promises, Async/Await, and the Microtask Queue
Async JavaScript has three layers: the syntax (async/await), the abstraction (Promises), and the runtime mechanism (microtask queue). Master all three and async bugs become predictable.

Article focus
3 states
pending → fulfilled → rejected
Key takeaways
- Promises have three states: pending, fulfilled, and rejected - and transition exactly once.
- async/await is syntactic sugar over Promises - under the hood it still uses the microtask queue.
- The microtask queue drains completely before any macrotask (setTimeout) runs - Promises always beat setTimeout.
- Sequential await serializes independent calls: use Promise.all to run them concurrently and cut wait time from (a+b+c) to max(a,b,c).
- Always await inside try/catch - without await the rejection is asynchronous and catch never sees it.
- Promise.all fails fast on any rejection; Promise.allSettled waits for all and never rejects.
- AbortController cancels fetch requests cleanly - essential in React to prevent stale state updates.
The Problem with Callbacks: Callback Hell
Callbacks cause "callback hell" - deeply nested, hard-to-follow code - and make error handling inconsistent. Promises and async/await were designed to fix these problems.
Before Promises, async operations used callbacks: functions passed as arguments to be called when the async work completes. The pattern works for simple cases but breaks down quickly.
The problems: error handling requires passing error objects to every callback (the Node.js (err, result) convention); code execution order is non-linear and hard to follow; each step nests inside the previous one, creating the "pyramid of doom"; and there is no native way to cancel or timeout a callback.
javascript
// Callback hell - 3 levels deep becomes unreadable
getUser(userId, function(err, user) {
if (err) return handleError(err);
getPosts(user.id, function(err, posts) {
if (err) return handleError(err);
getComments(posts[0].id, function(err, comments) {
if (err) return handleError(err);
renderPage(user, posts, comments); // finally!
});
});
});
// Same logic with Promises - flat and readable
getUser(userId)
.then(user => getPosts(user.id))
.then(posts => getComments(posts[0].id))
.then(comments => renderPage(comments))
.catch(handleError); // single error handler for all stepsSee It: Callbacks → Promises → async/await
Same data-fetching logic written in all three styles. Toggle between them to see exactly what each evolution fixed.
Same logic - fetch user, posts, comments, then render - written in all three styles. Toggle to see exactly what each evolution fixed.
// Callbacks - "pyramid of doom"
getUser(userId, function(err, user) {
if (err) return handleError(err);
getPosts(user.id, function(err, posts) {
if (err) return handleError(err);
getComments(posts[0].id, function(err, comments) {
if (err) return handleError(err);
renderPage(user, posts, comments);
// ↑ finally! - 3 levels deep just for 3 calls
});
});
});
// Problems:
// ✗ Error handler duplicated at every level
// ✗ Code reads right-to-left, not top-to-bottom
// ✗ No way to cancel or add a timeout
// ✗ Impossible to compose or reusePromises: States, Handlers, and the Chain
A Promise is an object representing a value that may not be available yet. It has three states: pending (initial), fulfilled (resolved with a value), or rejected (failed with a reason). A Promise can only transition once.
When you create a Promise with new Promise((resolve, reject) => {}), you provide an executor function. Call resolve(value) to fulfill it, or reject(reason) to reject it. Once settled, the state never changes.
.then(onFulfilled, onRejected) registers callbacks. .catch(fn) is shorthand for .then(null, fn). .finally(fn) runs regardless of outcome - useful for cleanup.
Promise chains are the key insight: .then() always returns a new Promise. Returning a value from .then() wraps it in a resolved Promise. Throwing an error (or returning a rejected Promise) propagates down to the next .catch().
Shorthand: Promise.resolve(value) is identical to new Promise(resolve => resolve(value)) - just less verbose. Same for Promise.reject(reason). Use the shorthand when you already have a value and need to wrap it in a Promise for a consistent interface.
javascript
// Creating a Promise
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const fetchUser = (id) => new Promise((resolve, reject) => {
if (!id) {
reject(new Error('ID is required'));
return;
}
// Simulating async work
setTimeout(() => resolve({ id, name: 'Alice' }), 100);
});
// Promise chain
fetchUser(1)
.then(user => {
console.log(user.name); // "Alice"
return user.name.toUpperCase(); // wraps in resolved Promise
})
.then(name => {
console.log(name); // "ALICE"
throw new Error('Oops!'); // propagates to .catch
})
.catch(err => {
console.log(err.message); // "Oops!"
return 'recovered'; // returns resolved Promise - chain continues
})
.then(val => console.log(val)) // "recovered"
.finally(() => console.log('done')); // always runsTry It: Promise State Machine
Click Resolve or Reject to settle the Promise. Watch which callbacks fire - and which stay silent.
A Promise starts pending. Click Resolve or Reject - watch it settle and see which callbacks fire.
new Promise((resolve, reject) => …)
pending
.then(value => …)fires on fulfillment.catch(error => …)fires on rejection.finally(() => …)always firesasync/await: Promises with Synchronous Style
async functions always return a Promise. await pauses execution of the async function until the Promise resolves, then returns the value. The rest of the call stack continues unblocked.
await does not block the thread. Under the hood, the async function registers a continuation in the microtask queue when it hits an await. Control returns to the caller immediately, and the function resumes when the awaited Promise settles.
You can only use await inside an async function (or at the top level in ES modules). Forgetting async on a function containing await is a syntax error.
async/await makes sequential async code read like synchronous code. This dramatically improves readability for complex flows like authentication, multi-step form submission, or data transformation pipelines.
javascript
// async function always returns a Promise
async function getFullProfile(userId) {
const user = await fetchUser(userId); // wait for user
const posts = await fetchPosts(user.id); // wait for posts
const profile = { ...user, posts };
return profile; // equivalent to Promise.resolve(profile)
}
// async/await vs Promise chain - identical behavior
async function withAwait() {
const result = await fetch('/api/data').then(r => r.json());
return result;
}
// Under the hood - what async/await compiles to (simplified)
function withPromise() {
return fetch('/api/data')
.then(r => r.json())
.then(result => result);
}
// Top-level await in ES modules
const data = await fetch('/api/config').then(r => r.json()); // valid in .mjs
// await is just "pause this async function and schedule resume"
async function example() {
console.log('before await');
await Promise.resolve(); // yields to microtask queue
console.log('after await'); // runs as a microtask
}
console.log('sync before example()');
example();
console.log('sync after example()');
// Output: "sync before example()" → "before await" → "sync after example()" → "after await"Try It: Predict the Output Order
Click the console.log outputs in the order they print. Tests your sync → microtask → macrotask mental model.
Question 1 of 3
Click the labels in the order they print to the console:
console.log('A');
Promise.resolve().then(() => console.log('B'));
console.log('C');The Microtask Queue: How Promises Schedule Work
When a Promise settles, its .then() and .catch() callbacks are not called immediately - they are queued as microtasks. The microtask queue drains completely before any macrotask (setTimeout, setInterval) gets a turn.
Every .then(callback) you write registers a microtask. When the Promise resolves or rejects, the engine pushes the callback into the microtask queue - not the call stack directly. After the current synchronous code finishes, the engine processes every pending microtask before touching any macrotask.
This is why Promise.resolve().then(fn) always runs before setTimeout(fn, 0), even though both look equally "async". setTimeout schedules a macrotask. A settled Promise schedules a microtask. Microtasks always win. The execution order rule is: synchronous code → microtasks (all of them, including chained ones) → one macrotask → repeat.
await uses the same mechanism under the hood. When you hit an await, the async function suspends, a microtask continuation is queued, and control returns to the caller immediately. When the Promise settles, the microtask fires and the function resumes - before any setTimeout callback gets a turn.
javascript
// Rule: sync → microtasks → macrotasks - in that order, every time
console.log('1 - sync');
setTimeout(() => console.log('5 - macrotask'), 0);
Promise.resolve()
.then(() => console.log('3 - microtask'))
.then(() => console.log('4 - microtask (chained)'));
console.log('2 - sync');
// Output: 1 → 2 → 3 → 4 → 5
// Chained .then() queues another microtask - still runs before setTimeout.
// async/await follows the exact same rule
async function run() {
console.log('A - sync inside async fn');
await Promise.resolve(); // suspends here → queues microtask
console.log('C - microtask resume');
}
run();
console.log('B - sync (caller continues immediately)');
// Output: A → B → CSequential vs Parallel Await: A Common Performance Trap
Awaiting Promises one by one makes them run sequentially - each call waits for the previous to finish. For independent operations, use Promise.all to run them concurrently and cut total time from (a + b + c) to max(a, b, c).
The trap is invisible. await fetchUser(); followed by await fetchPosts() looks fine - but if those calls are independent, you have serialized two HTTP requests unnecessarily. If each takes 600ms, sequential = 1200ms total. With Promise.all both start immediately and total time is ~600ms. For a dashboard fetching five resources this compounds to a 5× slowdown.
The rule: await sequentially only when the second call depends on the result of the first. If the calls are independent, they belong in Promise.all.
A related pitfall: async callbacks inside Array.forEach. Unlike for...of, forEach does not await async functions - it fires all iterations without waiting for any to finish. For sequential processing use for...of. For parallel processing use Promise.all with .map().
javascript
// SLOW: sequential await - total time = sum of all durations (~1800ms)
async function loadDashboardSlow(userId) {
const user = await fetchUser(userId); // 800ms
const posts = await fetchPosts(userId); // 600ms - waits for user!
const stats = await fetchStats(userId); // 400ms - waits for posts!
return { user, posts, stats };
}
// FAST: concurrent - total time = slowest call (~800ms)
async function loadDashboardFast(userId) {
const [user, posts, stats] = await Promise.all([
fetchUser(userId), // all three start at the same moment
fetchPosts(userId),
fetchStats(userId),
]);
return { user, posts, stats };
}
// Exception: sequential IS correct when calls are dependent
async function loadUserWithPosts(userId) {
const user = await fetchUser(userId); // must run first
const posts = await fetchPosts(user.teamId); // needs user.teamId
return { user, posts };
}
// TRAP: forEach ignores async callbacks
async function buggy(items) {
items.forEach(async (item) => {
await save(item); // all fire at once, forEach doesn't wait!
});
console.log('done'); // BUG - prints before any save() finishes
}
// FIX: for...of for sequential
async function sequential(items) {
for (const item of items) { await save(item); }
console.log('done'); // correct
}
// FIX: Promise.all + map for parallel
async function parallel(items) {
await Promise.all(items.map(item => save(item)));
console.log('done'); // correct, all run concurrently
}Error Handling: try/catch and Unhandled Rejections
Use try/catch inside async functions to handle errors. Always attach .catch() to Promise chains. Unhandled rejections crash Node.js processes and generate console warnings in browsers.
A common mistake: forgetting to await a Promise inside a try/catch. Without await, the Promise rejects asynchronously and the catch block never sees it.
Another trap: swallowing errors silently. An empty catch(e) {} hides bugs. At minimum, log the error or rethrow after handling. A pattern I recommend: handle what you can recover from, rethrow the rest.
javascript
// CORRECT error handling in async functions
async function loadData(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 === 'AbortError') {
console.log('Request cancelled');
return null;
}
// Log and rethrow unexpected errors
console.error('Failed to load data:', error);
throw error;
}
}
// MISTAKE: forgetting await inside try/catch
async function bug() {
try {
fetchData(); // no await - rejection is unhandled!
} catch (e) {
// This catch block NEVER runs for the fetchData error
console.log('This will not catch the fetch error');
}
}
// Global handler for missed rejections (last resort)
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled rejection:', event.reason);
event.preventDefault(); // prevent default console warning in some browsers
});
// Node.js
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
process.exit(1); // crash loudly in production
});Try It: Does catch Fire?
For each try/catch pattern, predict whether the catch block runs. The await detail makes all the difference.
For each snippet: does the catch block actually run?
async function test() {
try {
await Promise.reject(new Error('oops'));
} catch (e) {
console.log('caught!'); // fires?
}
}async function test() {
try {
Promise.reject(new Error('oops')); // no await!
} catch (e) {
console.log('caught!'); // fires?
}
}async function test() {
try {
return fetchData(); // returns Promise, no await
} catch (e) {
console.log('caught!'); // fires?
}
}async function test() {
try {
return await fetchData(); // await + return
} catch (e) {
console.log('caught!'); // fires?
}
}Promise Combinators: all, race, allSettled, any
JavaScript provides four Promise combinators for running multiple async operations: Promise.all (fail-fast, all or nothing), Promise.race (first to settle wins), Promise.allSettled (wait for all, never rejects), and Promise.any (first to fulfill).
Promise.all is the most used. It runs all Promises concurrently and resolves when all resolve, or rejects immediately if any reject. Use it when you need all results and a single failure means the whole operation failed.
Promise.allSettled is safer - it always waits for every Promise and gives you an array of {status, value} or {status, reason} objects. Use it when each result is independent and you want to handle successes and failures individually.
javascript
// Promise.all - concurrent, fail-fast
async function loadDashboard(userId) {
const [user, posts, notifications] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchNotifications(userId),
]);
return { user, posts, notifications };
}
// Promise.allSettled - wait for all, get individual outcomes
async function loadWidgets(widgetIds) {
const results = await Promise.allSettled(
widgetIds.map(id => fetchWidget(id))
);
const loaded = results
.filter(r => r.status === 'fulfilled')
.map(r => r.value);
const failed = results
.filter(r => r.status === 'rejected')
.map(r => r.reason);
return { loaded, failed };
}
// Promise.race - first to settle (resolve or reject)
async function fetchWithTimeout(url, timeoutMs = 5000) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), timeoutMs)
);
return Promise.race([fetch(url), timeout]);
}
// Promise.any - first to FULFILL (ignores rejections, fails only if all reject)
async function fetchFromFastestMirror(urls) {
try {
return await Promise.any(urls.map(url => fetch(url)));
} catch (aggError) {
// AggregateError - all mirrors failed
throw new Error('All mirrors failed');
}
}Try It: Combinator Live Simulation
Pick a combinator, make one op fail, then run. Watch exactly when and how each combinator settles.
Pick a combinator, optionally make one operation fail, then run to see exactly when and how it settles.
Resolves when ALL fulfill. Rejects immediately if any one rejects.
AbortController: Cancelling Fetch and Async Operations
AbortController provides a signal you can pass to fetch() and other async APIs. Calling abort() on the controller cancels the operation and rejects the Promise with an AbortError.
Without AbortController, you cannot cancel a fetch request. The browser still downloads the response even if you no longer care about it - wasting bandwidth and potentially processing stale data.
AbortController is essential in React: cancel in-flight requests when a component unmounts or when the user navigates away. This prevents "state update on unmounted component" errors and stale data races.
javascript
// Basic AbortController usage
async function fetchCancellable(url) {
const controller = new AbortController();
// Cancel after 5 seconds
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('Fetch was aborted');
return null;
}
throw error;
}
}
// React: cancel on unmount or when search changes
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
const controller = new AbortController();
async function search() {
try {
const res = await fetch(`/api/search?q=${query}`, {
signal: controller.signal
});
const data = await res.json();
setResults(data); // safe - component still mounted
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error);
}
// AbortError means component unmounted or query changed - ignore
}
}
search();
return () => controller.abort(); // cancel on cleanup
}, [query]); // re-runs when query changes, cancels previous request
return <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>;
}Async Iteration: for await...of
for await...of iterates async iterables like streams, paginated APIs, or any object implementing the async iterator protocol. Each iteration awaits the next value automatically.
The Streams API, Node.js readable streams, and the ReadableStream from fetch().body all implement the async iterable protocol. for await...of provides a clean syntax to consume them.
This is particularly useful for paginated APIs where each page fetch depends on the previous response's cursor or next-page URL.
javascript
// Paginated API with async generator
async function* fetchAllPages(baseUrl) {
let cursor = null;
while (true) {
const url = cursor ? `${baseUrl}?cursor=${cursor}` : baseUrl;
const res = await fetch(url);
const data = await res.json();
yield data.items; // yield current page
if (!data.nextCursor) break;
cursor = data.nextCursor;
}
}
// Consumer using for await...of
async function loadAll() {
const allItems = [];
for await (const page of fetchAllPages('/api/items')) {
allItems.push(...page);
console.log(`Loaded ${allItems.length} items so far...`);
}
return allItems;
}
// Streaming response with fetch
async function processStream(url) {
const response = await fetch(url);
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(decoder.decode(value)); // process each chunk
}
}Real-World Patterns: Retry Logic and Deduplication
Production async code needs retry logic for transient failures and request deduplication to prevent race conditions when multiple components fetch the same resource simultaneously.
A robust retry function uses exponential backoff - each retry waits twice as long as the previous. This prevents overwhelming a struggling server with immediate retries.
Request deduplication stores in-flight Promises and returns the same Promise to concurrent callers. This is exactly what React Query and SWR do internally.
javascript
// Retry with exponential backoff
async function fetchWithRetry(url, options = {}, retries = 3, backoff = 300) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await fetch(url, options);
if (!response.ok && attempt < retries) {
throw new Error(`HTTP ${response.status}`);
}
return response;
} catch (error) {
if (attempt === retries) throw error;
await new Promise(resolve =>
setTimeout(resolve, backoff * Math.pow(2, attempt)) // 300ms, 600ms, 1200ms
);
console.log(`Retry ${attempt + 1}/${retries}...`);
}
}
}
// Request deduplication - return same Promise for concurrent callers
const pendingRequests = new Map();
async function deduplicatedFetch(url) {
if (pendingRequests.has(url)) {
return pendingRequests.get(url); // return existing in-flight Promise
}
const promise = fetch(url)
.then(r => r.json())
.finally(() => pendingRequests.delete(url)); // cleanup when done
pendingRequests.set(url, promise);
return promise;
}
// Both calls return the same Promise - only one HTTP request is made
const p1 = deduplicatedFetch('/api/user');
const p2 = deduplicatedFetch('/api/user');
console.log(p1 === p2); // trueTest Your Knowledge
1.What does this code print?
console.log('A');
Promise.resolve().then(() => console.log('B'));
setTimeout(() => console.log('C'), 0);
console.log('D');2.What is the bug in this code?
async function load() {
try {
fetchData(); // no await
} catch (e) {
handleError(e);
}
}3.Which combinator waits for ALL Promises and NEVER rejects?
4.Which approach is faster for 3 independent API calls?
// A - sequential const a = await fetchA(); const b = await fetchB(); const c = await fetchC(); // B - concurrent const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
5.What does controller.abort() do to an in-flight fetch()?
6.A Promise in "pending" state can transition to:
Your turn
- >Did this help you ship something?
- >Which part clicked the most for you?
- >Applying this at work? Share your experience.
Recommended blogs
Continue reading

How I Use Claude Code to Ship Features 10× Faster
A real-world workflow guide for using Claude Code CLI to build features, debug bugs, refactor legacy code, and run custom automations - with actual examples from building this portfolio.

Frontend Architecture: The Mental Model That Keeps Complex Apps Maintainable
Five interconnected pillars - components, state, contracts, testing, tooling - that keep a frontend codebase healthy as the team and features grow.
Photo by ThisIsEngineering on Pexels
Read article
Discussion
Leave a comment
Thoughts, questions, corrections - all welcome.