Async JavaScript, Finally Understandable.
Callbacks → Promises → async/await.
You know the event loop now. The question is: how do you actually write async code that
doesn't betray you? This is the definitive guide to asynchronous JavaScript patterns —
from the pyramid of doom to Promise.allSettled,
AbortController, and
production-grade retry logic.
01 · Recap & Why Async Is Hard
In Part 1, you learned that JavaScript runs on a single thread with a call stack, a microtask queue, and a macrotask queue. Every async mystery collapsed into one rule: sync code runs first, then all microtasks drain, then one macrotask. If you haven't read Part 1, pause here and do it — everything ahead builds on that mental model.
But understanding the engine is different from writing good async code. You can know exactly how promises resolve and still write code that leaks memory, swallows errors, serializes requests that should run in parallel, or freezes under load. That's what this article fixes.
The Real Cost of Bad Async Code
Before we look at syntax, let's look at the damage. Every backend team has stories like these:
Serialized Requests
Five independent API calls awaited one after another. Latency stacks to 2.5× the parallel version.
Swallowed Errors
An await inside .then() that throws — the error vanishes, the request hangs, and the caller times out.
Unhandled Rejections
Node.js crashes on unhandledRejection by default since v15. One missing .catch() = a dead server.
Zombie Promises
Requests fire, timeouts hit the client, but the server keeps processing. Wasted CPU, wasted money.
Thundering Herd
10,000 requests hit the DB simultaneously because nobody limited concurrency. DB falls over.
Race Conditions
Two writes race to the same key, one wins, the other silently overwrites. Bug reports months later.
Every one of those has a clean, idiomatic solution. By the end of this article, you'll know all of them.
02 · The Callback Era & Its Demise
Before promises, JavaScript handled async with callbacks: you passed a function to another function, and that function would "call you back" when the work was done. It's simple, it's honest, and — as anyone who shipped code in 2012 can tell you — it collapses under its own weight at scale.
The Pyramid of Doom
Here's the canonical example every backend developer eventually writes: fetch a user, then their orders, then the details of the first order, then the shipping status, then notify someone.
This is callback hell. It's not a joke — it's the shape of code that has been responsible for thousands of production bugs. Every level adds:
- Another place where
errmust be checked. - Another closure that keeps parent variables alive forever (memory cost).
- Another line of indentation that makes code review physically painful.
- Another chance to forget a
returnand accidentally continue execution after an error.
The Four Fatal Problems with Callbacks
| Problem | What It Looks Like | Why It Hurts |
|---|---|---|
| Inversion of Control | You hand your function to a library and hope it calls it once, correctly, at the right time. | If the library calls it twice, or never, or with wrong arguments — you can't do anything about it. |
| Error Handling Chaos | Every callback takes (err, result). Forget one check and errors disappear. |
Bugs that take weeks to find because the error trail is invisible. |
| No Composition | You can't easily run 5 callbacks in parallel and collect all results. | Every "run in parallel" pattern is reinvented, incorrectly. |
| Stack Trace Hell | Errors show up in setTimeout with no reference to where you called it from. |
Debugging becomes archaeology. |
Backend reality check: Node.js's oldest core APIs — fs.readFile,
crypto.pbkdf2, dns.lookup — still use callbacks. You'll see them in
every legacy codebase. Knowing how to promisify them cleanly is a survival skill.
When Callbacks Are Still Okay
Callbacks aren't evil. They're just the wrong default. Two cases where they remain appropriate:
✅ Callbacks still shine
- Event emitters (
socket.on('data', cb)) — the callback fires 0-to-N times. - Streams (
stream.write(data, cb)) — the callback fires once per write. - Low-level APIs where you need maximum performance (no promise allocation).
- Interop with C++ addons that predate promises.
❌ Callbacks are wrong when
- You're doing a sequence of steps that depend on each other.
- You need to compose parallel work.
- Error flow matters (which is: always).
- You're writing new code in 2026 and the API supports promises.
Promisification — Turning Callbacks into Promises
Node.js ships util.promisify to convert any (err, result) callback
API into a promise-returning one. This is the bridge between the old world and the new:
const fs = require('node:fs');
const util = require('node:util');
// Old callback style
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) return console.error(err);
console.log(data);
});
// Promisified — clean, await-able, catch-able
const readFileAsync = util.promisify(fs.readFile);
async function loadConfig() {
try {
const data = await readFileAsync('config.json', 'utf8');
return JSON.parse(data);
} catch (err) {
console.error('Failed to load config:', err.message);
throw err;
}
}
Modern shortcut: Node.js 20+ offers fs/promises and
node:fs/promises which expose promise-returning versions natively. You almost
never need to promisify anymore — just import the /promises namespace.
03 · Promises — The Contract
A promise is a placeholder for a value that doesn't exist yet. That's the whole idea. Instead of passing a callback to a library and hoping it does the right thing, the library returns an object. That object is the contract: it will eventually resolve or reject, exactly once, and you'll be notified.
The Three States (and Why They're Absolute)
| State | Meaning | Transitions To |
|---|---|---|
| pending | Neither fulfilled nor rejected. Initial state. | fulfilled OR rejected (never both) |
| fulfilled | Succeeded with a value. | Settled — cannot change |
| rejected | Failed with a reason (usually an Error). | Settled — cannot change |
Once a promise settles, it never changes. Calling resolve() then
reject() on the same promise — the reject is silently ignored. This is a
feature. It eliminates the "called twice" chaos of callbacks.
Creating Promises — The Manual Way (and When Not To)
// ✅ Legitimate use: wrapping a callback-based API
function wait(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
// ✅ Legitimate use: low-level stream / event coordination
function readStream(stream) {
return new Promise((resolve, reject) => {
let data = '';
stream.on('data', (chunk) => data += chunk);
stream.on('end', () => resolve(data));
stream.on('error', reject);
});
}
// ❌ ANTI-PATTERN: wrapping an already-promise function
function fetchUserBad(id) {
return new Promise((resolve, reject) => {
fetch(`/users/${id}`) // this already returns a promise!
.then((res) => res.json())
.then(resolve)
.catch(reject);
});
}
// ✅ CORRECT: just return the chain
async function fetchUserGood(id) {
const res = await fetch(`/users/${id}`);
return res.json();
}
The #1 promise anti-pattern: wrapping an existing promise API in
new Promise(...). It's called the explicit promise construction
anti-pattern. It adds noise, swallows errors, and creates a second promise you don't
need. If a function already returns a promise, just return it.
The Three Methods That Matter: then / catch / finally
Every promise gives you three methods. Each one returns a new promise — this is the key to chaining:
fetch('/api/orders')
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((orders) => {
console.log('Orders:', orders.length);
return orders;
})
.catch((err) => {
// Catches errors from ANY of the .then() callbacks above
console.error('Failed:', err.message);
throw err; // re-throw to propagate
})
.finally(() => {
// Runs whether success or failure — perfect for cleanup
console.log('Request finished (success or failure)');
});
Notice how .catch() reaches back up the chain. It catches errors from any preceding
.then(). This is the "chain-wide" error handling that callbacks never gave you.
Interactive: Watch Promise States Transition
04 · Promise Chaining & Flattening the Pyramid
The single biggest win of promises over callbacks: you can chain instead of nest. Same logic. Same data flow. But flat instead of pyramidal. Here's the callback-hell example from earlier, rewritten as a promise chain:
getUser(id)
.then((user) => getOrders(user.id))
.then((orders) => getOrderDetail(orders[0]))
.then((detail) => getShippingStatus(detail))
.then((status) => notify(user.email, status))
.catch((err) => console.error('Chain failed:', err));
Five lines, one error handler, zero pyramid. Every step returns a promise, and the next
.then() receives the resolved value. This is the essence of promise chaining.
The Rules of Chaining
| You Return From .then() | Next .then() Receives |
|---|---|
| A plain value | That value, wrapped in a resolved promise |
| A promise | Whatever that promise resolves to (auto-unwrapped) |
| Nothing (undefined) | undefined |
| A thrown error | Skipped — goes straight to the next .catch() |
| A rejected promise | Skipped — goes straight to the next .catch() |
The auto-unwrap is the magic. You don't need to think about whether you
returned a value or a promise — .then() flattens it automatically. This is why
chaining "just works" and nesting never did.
Common Chaining Mistakes
❌ Mistake 1 — Forgetting to return
.then((user) => {
getOrders(user.id); // returns undefined!
})
.then((orders) => {
// orders is undefined 😱
})
✅ Fix — Return the promise
.then((user) => {
return getOrders(user.id);
})
.then((orders) => {
// orders is correct ✓
})
❌ Mistake 2 — Nesting .then inside .then
.then((user) => {
getOrders(user.id).then((orders) => {
// back to pyramid shape 😖
})
})
✅ Fix — Keep the chain flat
.then((user) => getOrders(user.id))
.then((orders) => {
// flat and beautiful ✓
})
Using a Value Before It "Exists"
A subtle but powerful trick: because closures capture references, you can use a value inside a
.then() that was defined before it resolved.
let currentUser; // declared outside the chain
getUser(id)
.then((user) => {
currentUser = user; // captured by closure
return getOrders(user.id);
})
.then((orders) => {
// currentUser is available here — same closure
return notify(currentUser.email, orders.length);
})
.catch(console.error);
Caution: This works but breaks the "data flows through the chain" principle. Prefer reshaping the value as it flows:
.then(user => getOrders(user.id).then(orders => ({ user, orders })))
Now every subsequent .then() receives the full context as a clean object.
05 · Promise Combinators — all / race / any / allSettled
This is where promises decisively beat callbacks. Four built-in methods let you orchestrate multiple promises with one expression. Master these and you'll write async code at a level that most "senior" JavaScript developers haven't reached.
The Four Combinators at a Glance
| Method | Resolves When… | Rejects When… | Use Case |
|---|---|---|---|
Promise.all([...]) |
All succeed | Any fails (fail-fast) | Parallel independent calls — fail entire operation if any fails |
Promise.allSettled([...]) |
All settle (success or fail) | Never | Batch operations where partial failure is acceptable |
Promise.race([...]) |
First one settles (success OR failure) | First one rejects | Timeouts — race a request against a timer |
Promise.any([...]) |
First one succeeds | All fail (AggregateError) | Redundant sources — try multiple, take first success |
Promise.all — The Workhorse
If you only ever remember one combinator, remember this one. It's the correct answer to "I need to run N independent async operations and wait for all of them."
async function buildUserDashboard(userId) {
// 🔴 NAIVE (serial): 4 sequential round trips
// const user = await getUser(userId); // 200ms
// const orders = await getOrders(userId); // 300ms
// const balance = await getBalance(userId); // 150ms
// const prefs = await getPrefs(userId); // 100ms
// → Total: ~750ms
// ✅ CORRECT (parallel): 1 round-trip wall time
const [user, orders, balance, prefs] = await Promise.all([
getUser(userId), // 200ms ┐
getOrders(userId), // 300ms │ all running
getBalance(userId), // 150ms │ in parallel
getPrefs(userId) // 100ms ┘
]);
// → Total: ~300ms (the slowest one)
return { user, orders, balance, prefs };
}
Fail-fast behavior: Promise.all rejects as soon as the first
promise rejects. The others keep running in the background (they're already started) but their
results are discarded. If you need partial successes, use allSettled.
Promise.allSettled — When Partial Failure Is Normal
Imagine you're aggregating data from 5 different microservices. If one is down, you still want
to show the other 4. That's allSettled.
async function aggregateFeeds(sources) {
const results = await Promise.allSettled(
sources.map((s) => fetchFeed(s))
);
const successes = [];
const failures = [];
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
successes.push({ source: sources[i], data: result.value });
} else {
failures.push({ source: sources[i], error: result.reason });
}
});
// Return partial success — the caller decides what to do
return {
feeds: successes,
failures,
totalSources: sources.length,
successRate: successes.length / sources.length
};
}
Each result in the array is either { status: 'fulfilled', value: … } or
{ status: 'rejected', reason: … }. You get every outcome, regardless of failures.
Promise.race — Timeouts and Fallbacks
race resolves or rejects with the first promise to settle. Its #1 use is
implementing timeouts:
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
)
]);
}
// Usage
try {
const data = await withTimeout(fetch('/api/slow'), 3000);
} catch (err) {
// Will catch either a real error OR the timeout
console.error(err.message);
}
Promise.race doesn't cancel the loser. If the timeout fires first, the underlying fetch keeps running. For real cancellation, see the AbortController section below.
Promise.any — Redundancy and First Success
any is the mirror of race: it waits for the first successful
promise, ignoring failures until all fail. Perfect when you have multiple ways to get the same
data:
const mirrors = [
'https://cdn-us.example.com/asset.json',
'https://cdn-eu.example.com/asset.json',
'https://cdn-ap.example.com/asset.json'
];
try {
// Returns the first mirror that responds successfully
const res = await Promise.any(mirrors.map((url) => fetch(url)));
const data = await res.json();
} catch (err) {
// AggregateError — ALL mirrors failed
// err.errors contains each individual failure
console.error('All mirrors down:', err.errors);
}
Interactive: Same Inputs, Four Different Outcomes
Each button runs the same scenario — three promises where one fails — through a different combinator. Watch how the outcome changes depending on which one you pick.
06 · async/await — The Pinnacle
In 2017, ES2017 introduced async/await. It's not new functionality —
it's syntactic sugar over promises. Every async function returns a
promise. Every await is a .then() in disguise. But the ergonomics are
so much better that they've become the default way to write async code.
Rewriting the Chain
// Promise chain version
function getOrderSummary(userId) {
return getUser(userId)
.then((user) => getOrders(user.id))
.then((orders) => getOrderDetail(orders[0]))
.then((detail) => ({ detail, count: detail.items.length }));
}
// Same logic, async/await version — reads top-to-bottom
async function getOrderSummary(userId) {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const detail = await getOrderDetail(orders[0]);
return { detail, count: detail.items.length };
}
Notice: no more nesting, no more .then() chains, and — most importantly — you can
use try/catch exactly like synchronous code. This is the single
biggest productivity boost async/await brings.
Three Rules That Prevent 90% of async/await Bugs
Rule 1: async functions always return a promise — even if you
return a plain value.
Rule 2: await pauses the current function only. It
does not block the event loop.
Rule 3: An awaited rejection becomes a thrown exception inside
the function — you must handle it, or it becomes an unhandled rejection.
Don't Serialize What Can Run in Parallel
This is the #1 performance bug in async/await code. Awaiting things in a loop feels natural, but it's often wrong:
❌ Serial — wait, wait, wait
const results = [];
for (const id of ids) {
results.push(await fetchUser(id));
}
// 10 users × 200ms = 2000ms 😴
✅ Parallel — all at once
const results = await Promise.all(
ids.map((id) => fetchUser(id))
);
// 10 users × 200ms = 200ms 🚀
Mental checklist before every loop with await:
1. Do these iterations depend on each other? → serial is correct.
2. Are there many of them? → consider a concurrency limit (see section 10).
3. Are they independent and few? → Promise.all is almost always the right answer.
async/await Doesn't Make Sync Code Async
This catches people. Marking a function async doesn't magically make its
synchronous parts concurrent. If your function does heavy CPU work, it still blocks:
async function processHugeFile(data) {
// 💥 This blocks the event loop for the entire duration.
// async does NOT make this parallel.
for (let i = 0; i < data.length; i++) {
data[i] = expensiveTransform(data[i]);
}
return data;
}
// ✅ Fix: yield to the event loop periodically
async function processHugeFileYielding(data) {
for (let i = 0; i < data.length; i++) {
data[i] = expensiveTransform(data[i]);
if (i % 1000 === 0) {
await new Promise((r) => setImmediate(r));
}
}
return data;
}
For genuinely CPU-heavy work, use worker_threads (Node.js) or
Web Workers (browser). async/await does not turn single-threaded work
into parallel work.
07 · Error Flow in Async Code
Async error handling is where most codebases quietly rot. Errors vanish. Requests hang. Servers crash at 3 AM. This section is about making error flow as boring as it should be.
The Four Places Errors Can Come From
| Source | Example | How It Reaches You |
|---|---|---|
| Sync throw inside async | throw new Error('bad') |
Rejects the returned promise |
| Rejected await | await fetch('/api') network fails |
Thrown at the await site |
| Rejected promise not awaited | Fire-and-forget doAsync() |
Unhandled rejection (crashes Node.js by default) |
| Error inside .then() | p.then(() => { throw x }) |
Rejects the chained promise |
try/catch/finally in Async Functions
async function transferFunds(from, to, amount) {
let txnId;
try {
txnId = await beginTransaction();
await debit(txnId, from, amount);
await credit(txnId, to, amount);
await commit(txnId);
return { ok: true, txnId };
} catch (err) {
console.error('Transfer failed:', err.message);
if (txnId) {
await rollback(txnId).catch((e) =>
console.error('Rollback failed:', e.message));
}
throw err; // propagate to caller
} finally {
await releaseConnection();
}
}
Notice three things: (1) try wraps the whole async sequence, (2) the inner rollback
has its own catch to avoid masking the original error, (3) finally is
awaited — a subtle but crucial detail that ensures cleanup completes.
Critical gotcha: finally with a return or
throw inside it will override the original resolution. Avoid
return inside finally. Ever.
Unhandled Rejections — The Silent Killer
Since Node.js 15, an unhandled promise rejection crashes the process by default. This is a feature — it forces you to handle errors. But it means you must know how to catch them.
// 💀 This crashes Node.js — nothing catches it
async function fireAndForget() {
throw new Error('boom');
}
fireAndForget(); // rejected promise, no handler
// ✅ Fix 1: await it
await fireAndForget();
// ✅ Fix 2: attach a catch
fireAndForget().catch((e) => console.error(e));
// ✅ Fix 3: use void to make intent explicit (still unsafe)
void fireAndForget(); // ← doesn't fix, just documents intent
// ✅ Fix 4: global safety net (last resort, log and exit)
process.on('unhandledRejection', (reason) => {
console.error('UNHANDLED REJECTION:', reason);
process.exit(1); // let Kubernetes restart you cleanly
});
The Async Error Boundary Pattern
In production, you want one place that decides "log this, return 500, don't leak internals". A wrapper function gives you exactly that:
// Wraps an async route handler so thrown/rejected errors flow to Express
function asyncHandler(fn) {
return (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
}
// Usage
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) throw new NotFoundError();
res.json(user);
}));
// Global error handler — the single place that decides response shape
app.use((err, req, res, next) => {
req.log.error({ err }, 'Request failed');
const status = err.statusCode || 500;
res.status(status).json({
error: err.expose ? err.message : 'Internal server error'
});
});
Every async handler should be wrapped. Without it, a rejected promise
inside an Express route becomes an unhandled rejection, crashes your process, and returns
nothing to the client. The asyncHandler wrapper is 3 lines that prevent a whole
class of outage.
Custom Error Classes for Clean Semantics
class AppError extends Error {
constructor(message, statusCode = 500, expose = false) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.expose = expose;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, true);
}
}
class ValidationError extends AppError {
constructor(details) {
super('Validation failed', 400, true);
this.details = details;
}
}
class UpstreamError extends AppError {
constructor(service, cause) {
super(`Upstream service failed: ${service}`, 502, false);
this.cause = cause;
}
}
// In controllers, throwing is enough — the global handler shapes the response
async function getUser(id) {
const user = await db.find(id);
if (!user) throw new NotFoundError('User');
return user;
}
08 · Cancellation with AbortController
Promises are not cancellable by design. This was a deliberate choice, but it leaves a gap: what if a user navigates away? What if a request exceeds its deadline? What if you're shutting down the server?
The modern answer is AbortController. It's a standard web API
adopted by Node.js 15+, and it works with fetch, streams, and any API that accepts
an AbortSignal.
AbortController in 30 Seconds
const controller = new AbortController();
const signal = controller.signal;
// Pass the signal to any cancellable API
fetch('/api/large-report', { signal })
.then((res) => res.json())
.catch((err) => {
if (err.name === 'AbortError') {
console.log('Request was cancelled');
} else {
throw err;
}
});
// Cancel whenever you want
setTimeout(() => controller.abort('User navigated away'), 2000);
The Three Killer Use Cases
Timeouts
Don't just race against a timer — actually cancel the underlying request so the server can stop working.
Graceful Shutdown
On SIGTERM, abort all in-flight requests so the process can exit cleanly within Kubernetes's grace period.
User Cancellation
React components unmount, API clients disconnect — abort the work that's no longer needed.
Combining Timeout + Abort — The Right Way
// Node.js 18+ / modern browsers: AbortSignal.timeout() does this for you
const res = await fetch(url, {
signal: AbortSignal.timeout(5000) // auto-cancel after 5s
});
// For more control — combine timeout + external abort
function fetchWithCancel(url, { timeoutMs = 5000, externalSignal } = {}) {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const signal = externalSignal
? AbortSignal.any([externalSignal, timeoutSignal])
: timeoutSignal;
return fetch(url, { signal });
}
Modern niceties:
AbortSignal.timeout(ms) — auto-aborts after a duration.
AbortSignal.any([...signals]) — aborts when any input signal aborts.
Both available in Node.js 20+ and modern browsers. These replace 95% of hand-rolled timeout code.
Making Your Own Functions Cancellable
When you're writing the layer that wraps a DB or an HTTP client, honor the signal. It's a 5-line change that turns your library into a good citizen:
function queryCancellable(sql, params, { signal } = {}) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
return reject(new DOMException('Aborted', 'AbortError'));
}
const query = db.query(sql, params, (err, rows) => {
signal?.removeEventListener('abort', onAbort);
err ? reject(err) : resolve(rows);
});
function onAbort() {
query.cancel(); // driver-specific cancel
reject(new DOMException('Aborted', 'AbortError'));
}
signal?.addEventListener('abort', onAbort);
});
}
09 · Retry with Exponential Backoff
Networks are flaky. Redis occasionally times out. The upstream API rate-limits you. The correct response is not "fail immediately" — it's "retry with a smart policy". The industry-standard policy is exponential backoff with jitter.
The Full Retry Utility
function retry(fn, {
retries = 3,
baseMs = 200,
maxMs = 10_000,
jitter = true,
onRetry,
shouldRetry = () => true,
signal
} = {}) {
return new Promise((resolve, reject) => {
let attempt = 0;
async function run() {
if (signal?.aborted) {
return reject(new DOMException('Aborted', 'AbortError'));
}
try {
const result = await fn(attempt);
return resolve(result);
} catch (err) {
if (attempt >= retries || !shouldRetry(err, attempt)) {
return reject(err);
}
const delay = Math.min(baseMs * 2 ** attempt, maxMs);
const finalDelay = jitter
? Math.random() * delay * 0.5 + delay * 0.5
: delay;
onRetry?.({ attempt: attempt + 1, error: err, delayMs: finalDelay });
await new Promise((r) => setTimeout(r, finalDelay));
attempt++;
return run();
}
}
run();
});
}
Using the Retry Utility
// Retry on 5xx or network errors only — never retry 4xx
function shouldRetryHttp(err) {
if (err.name === 'AbortError') return false;
if (err.status >= 500) return true;
if (err.status === 429) return true; // rate-limited: retry
if (err.code === 'ECONNRESET') return true;
return false;
}
const data = await retry(
() => fetch('/api/expensive-op').then((r) => {
if (!r.ok) throw Object.assign(new Error('HTTP error'), { status: r.status });
return r.json();
}),
{
retries: 4,
baseMs: 300,
shouldRetry: shouldRetryHttp,
onRetry: ({ attempt, delayMs, error }) =>
logger.warn({ attempt, delayMs, err: error.message }, 'Retrying')
}
);
Never blindly retry:
• Non-idempotent operations (POST that charges a card) — retrying could double-charge.
• 4xx errors (400, 401, 403, 404) — the request was wrong, retrying won't fix it.
• Business logic errors (validation failures, insufficient balance) — retrying is meaningless.
Only retry on: network errors, timeouts, 5xx, 429 (respecting Retry-After).
Interactive: Watch Backoff Grow
10 · Concurrency Limiting (p-limit style)
Promise.all is beautiful until you pass it 10,000 items. Then you hit the DB with
10,000 simultaneous connections, the connection pool exhausts, requests queue up at the socket
level, and your p99 latency explodes. The fix is a concurrency limiter.
The Implementation — 25 Lines, Production Ready
function createLimiter(concurrency) {
const queue = [];
let activeCount = 0;
function next() {
if (activeCount >= concurrency || queue.length === 0) return;
activeCount++;
const { fn, resolve, reject } = queue.shift();
Promise.resolve()
.then(fn)
.then(resolve, reject)
.finally(() => {
activeCount--;
next();
});
}
return function limit(fn) {
return new Promise((resolve, reject) => {
queue.push({ fn, resolve, reject });
next();
});
};
}
Using the Limiter — Before and After
❌ Without limit — thundering herd
// 5000 concurrent DB queries 😵
const users = await Promise.all(
ids.map((id) => db.findUser(id))
);
// DB connection pool: exhausted
// p99 latency: 🔥🔥🔥
✅ With limit — smooth flow
const limit = createLimiter(20);
const users = await Promise.all(
ids.map((id) => limit(() => db.findUser(id)))
);
// Max 20 in flight at any moment
// Predictable latency, stable DB
Interactive: Concurrency in Action
Choosing the right concurrency:
• HTTP requests to external APIs: 5–20 (respect their limits).
• DB queries to a pool: equal to your connection pool size.
• File I/O on SSD: 20–50 (I/O bound).
• CPU-heavy work: 1 (you're on a single thread anyway — use worker_threads).
Bonus: Batching with Concurrency
A common need: process a large array in chunks of N, one chunk at a time. This is called chunked processing:
async function processInChunks(items, chunkSize, worker) {
const results = [];
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
const chunkResults = await Promise.all(chunk.map(worker));
results.push(...chunkResults);
console.log(`Processed ${Math.min(i + chunkSize, items.length)}/${items.length}`);
}
return results;
}
// Usage: import 1000 users in batches of 50
const imported = await processInChunks(
userRecords,
50,
(record) => db.insertUser(record)
);
11 · Production Async Patterns
Let's combine everything into patterns you'll use in real systems. Each of these has burned developers in production at least once — learn them here, not at 3 AM.
Pattern 1 — The Resilient HTTP Client
Timeout + retry + circuit breaker + logging + error normalization — in one function.
class HttpClient {
constructor({ baseUrl, defaultTimeoutMs = 10_000, retries = 3 } = {}) {
this.baseUrl = baseUrl;
this.defaultTimeoutMs = defaultTimeoutMs;
this.retries = retries;
}
async request(path, { method = 'GET', body, timeoutMs, signal } = {}) {
const url = new URL(path, this.baseUrl);
const ms = timeoutMs ?? this.defaultTimeoutMs;
return retry(
async () => {
const res = await fetch(url, {
method,
body: body ? JSON.stringify(body) : undefined,
headers: { 'Content-Type': 'application/json' },
signal: this._combineSignals(ms, signal)
});
if (!res.ok) {
throw Object.assign(new Error(`HTTP ${res.status}`), { status: res.status });
}
return res.json();
},
{
retries: this.retries,
shouldRetry: (err) => err.name !== 'AbortError' && (err.status >= 500 || !err.status),
onRetry: ({ attempt, error }) => logger.warn({ attempt, err: error.message }),
signal
}
);
}
_combineSignals(timeoutMs, externalSignal) {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
return externalSignal
? AbortSignal.any([externalSignal, timeoutSignal])
: timeoutSignal;
}
}
Pattern 2 — Graceful Shutdown
When Kubernetes sends SIGTERM, you have ~30 seconds to finish in-flight requests and exit cleanly. Without this pattern, you drop requests on every deploy.
class GracefulShutdown {
constructor() {
this.controller = new AbortController();
this.inflight = new Set();
this.isShuttingDown = false;
}
track(promise) {
this.inflight.add(promise);
promise.finally(() => this.inflight.delete(promise));
return promise;
}
get signal() { return this.controller.signal; }
async shutdown({ timeoutMs = 25_000 } = {}) {
this.isShuttingDown = true;
logger.info('Shutdown initiated, stopping new requests');
this.controller.abort('Server shutting down');
const timeout = new Promise((r) => setTimeout(r, timeoutMs));
const drain = Promise.allSettled([...this.inflight]);
await Promise.race([drain, timeout]);
logger.info('Shutdown complete');
}
}
// Wire it up
const shutdown = new GracefulShutdown();
process.on('SIGTERM', () => shutdown.shutdown().then(() => process.exit(0)));
process.on('SIGINT', () => shutdown.shutdown().then(() => process.exit(0)));
Pattern 3 — Parallel Aggregation with Fallback
Fetch from multiple sources, use what you can, fall back gracefully:
async function getUserProfile(userId) {
const [core, prefs, activity] = await Promise.allSettled([
fetchCore(userId),
fetchPrefs(userId),
fetchActivity(userId)
]);
// Core is required — if it fails, the whole thing fails
if (core.status === 'rejected') {
throw new UpstreamError('core', core.reason);
}
return {
...core.value,
prefs: prefs.status === 'fulfilled' ? prefs.value : defaultPrefs(),
activity: activity.status === 'fulfilled' ? activity.value : [],
// Log partial failures for observability
_degraded: [
prefs.status === 'rejected' && 'prefs',
activity.status === 'rejected' && 'activity'
].filter(Boolean)
};
}
Pattern 4 — Async Generator for Streaming
When you process an infinite or very large source, use for await...of:
async function* paginate(fetchPage, { pageSize = 100 } = {}) {
let cursor = null;
while (true) {
const { items, nextCursor } = await fetchPage({ cursor, pageSize });
if (!items.length) break;
for (const item of items) yield item;
cursor = nextCursor;
if (!cursor) break;
}
}
// Consume the stream — memory-efficient even for millions of rows
for await (const user of paginate(fetchUsersPage)) {
await processUser(user);
}
Why async generators matter: They give you backpressure for free.
for await...of only pulls the next item when the previous one is finished
processing. No loading the entire dataset into memory. This is how you process terabytes of
data on a 512 MB container.
Pattern 5 — Memoized Async (Preventing Duplicate Calls)
function asyncMemoize(fn, { ttlMs = 60_000, keyFn = (...args) => JSON.stringify(args) } = {}) {
const cache = new Map();
const inflight = new Map();
return async function memoized(...args) {
const key = keyFn(...args);
// Return cached if fresh
const cached = cache.get(key);
if (cached && Date.now() < cached.expires) {
return cached.value;
}
// Deduplicate concurrent identical calls
if (inflight.has(key)) {
return inflight.get(key);
}
const promise = Promise.resolve()
.then(() => fn(...args))
.then((value) => {
cache.set(key, { value, expires: Date.now() + ttlMs });
return value;
})
.finally(() => inflight.delete(key));
inflight.set(key, promise);
return promise;
};
}
This pattern is a killer optimization in microservices. When 100 requests per second ask for the same config, only one DB query actually fires. The other 99 wait on the same promise.
12 · AI Corner for Async Code
Async code is where AI assistants shine brightest — and also where they silently produce nonsense. The difference is whether you know what to ask for. Here are the prompts that consistently produce good output.
Prompts That Work
Find Serialization Bugs
"Review this async function. Identify every place where independent operations are awaited sequentially and could be parallelized with Promise.all. Explain the latency impact."
Error Flow Audit
"Trace every possible error path through this async code. Which errors are swallowed? Which become unhandled rejections? Show me a decision tree."
Add Timeouts + Retries
"Add AbortSignal.timeout, exponential backoff retry with jitter, and structured logging to this HTTP call. Only retry on 5xx and network errors."
Explain an Unhandled Rejection
"Here's the stack trace from an unhandledRejection. Which line originated the rejected promise? Why wasn't it caught?"
Refactor to Async Generators
"Convert this paginated fetch loop into an async generator. Preserve cancellation. Show the consumer using for await...of."
Generate Race Condition Tests
"Write Jest tests that expose race conditions in this code — out-of-order resolution, concurrent identical calls, cancellation mid-flight."
The meta-prompt technique: Don't just ask AI to write async code — ask it to critique code. Paste your function and say: "Act as a staff engineer reviewing this PR. List every async anti-pattern, race condition, and error-handling gap. Be brutal." The critique is often more valuable than the rewrite.
Where AI Still Gets Async Wrong
❌ Common AI mistakes
- Wrapping existing promises in
new Promise(). - Using
Promise.allwhereallSettledwas needed. - Forgetting
returnin a.then()callback. - Adding retries to non-idempotent operations.
- Using
setTimeoutwheresetImmediateis correct. - Serializing loops that should be parallel.
- Ignoring
AbortSignalwhen refactoring fetch calls.
✅ Always check for
- Correct combinator choice (all vs allSettled vs race vs any).
- Proper
finallycleanup (noreturninside). - Explicit
shouldRetrypredicates. - Timeout integration via
AbortSignal.timeout. - Cancellation propagation throughout the call chain.
- Logging that doesn't leak PII or tokens.
- Tests for rejection paths, not just success.
13 · Interactive Knowledge Check
Ten questions on the async patterns you just learned. Each one corresponds to a scenario you'll encounter within your first month of production JavaScript.
Part 2 — Async Patterns Quiz
Ten questions. No shortcuts. Take them seriously.14 · Cheat Sheet & What's Next
Async Patterns — One-Page Summary
| Pattern | When to Use | Key API |
|---|---|---|
| Sequential async steps | Each step depends on the previous result | await in sequence |
| Parallel independent steps | Few operations, all needed, fail-fast OK | Promise.all |
| Parallel with partial success | Batch ops where some failures are acceptable | Promise.allSettled |
| Timeout / first-to-settle | Racing against a deadline | Promise.race + AbortSignal.timeout |
| Redundant sources | Multiple mirrors, take first success | Promise.any |
| Cancellation | User navigates, server shuts down, deadline hits | AbortController + AbortSignal.any |
| Retry with backoff | Transient failures (network, 5xx, 429) | Custom retry() utility |
| Concurrency limiting | Large batches against limited resources | Custom limiter or p-limit |
| Streaming pagination | Very large or infinite datasets | Async generators + for await...of |
| Deduplicating concurrent calls | Same request fires from many places | Async memoization with inflight tracking |
Do / Don't — Async Edition
✅ DO
- Use
async/awaitas your default style. - Wrap async route handlers in an
asyncHandler. - Always pass
AbortSignal.timeout()to network calls. - Use
Promise.allSettledwhen partial failure is acceptable. - Add retry only to idempotent operations.
- Limit concurrency for batch operations.
- Log rejected promises — never let them vanish.
- Use
try/catch/finallyfor cleanup. - Return promises from
.then()to keep chains flat. - Cancel work you no longer need.
❌ DON'T
- Don't use
awaitin loops for independent work. - Don't wrap promise-returning functions in
new Promise(). - Don't retry 4xx errors.
- Don't ignore
AbortError— it's not a bug. - Don't mix
.then()chains withawaitin the same function. - Don't forget
returnin.then()callbacks. - Don't use
Promise.allwith 10,000 items — use a limiter. - Don't use
returninsidefinally. - Don't mark functions
asyncif they contain noawait. - Don't assume
asyncmakes CPU work non-blocking.
What's Coming in Part 3
Part 3 is about the object model — Objects, Prototypes, Classes, and this.
You'll learn:
- The prototype chain, explained without hand-waving.
- How
classis really just syntactic sugar. - The four rules of
thisbinding (and how arrow functions break them). - Composition vs inheritance in modern JavaScript.
- Immutability patterns and structural sharing.
- Symbols, iterators, and how
for...ofactually works. - AI-assisted refactoring from class-based to functional.
Practice before Part 3: Take one async function from your current codebase
and refactor it using three techniques from this article: Promise.all for
independent calls, AbortSignal.timeout for timeouts, and an
asyncHandler wrapper for error flow. Measure the before/after p99 latency. The
numbers will make the lesson stick.
Part 2 of 7 · JavaScript for Backend Developers · FreeLearning365.com
🌟 Continue Learning on FreeLearning365
Free tools, tutorials, and question banks for developers, students, and professionals.
- Learn Free ProgrammingJavaScript, Angular, Python, SQL, Data Analysis & More
- 100+ Free Online ToolsDevelopers, SEO Specialists & Daily Tasks
- Professional IT TrainingAdvance Your Career with Hands-On Courses
- AI Prompt Generator40+ Professional Prompt Types
- Drag & Drop Form GeneratorBootstrap 5.3/4, Custom CSS, Grid Layout
- Income Tax CalculatorNBR Slabs, Rebate & Minimum Tax
- NPS 2026 Salary Calculatorবাংলাদেশ জাতীয় বেতন স্কেল ২০২৬
- Electricity Bill CalculatorBERC Tariff & Appliance Report
- eBook CollectionFree for Download
- BCS / HSC / SSC Question Bankবাংলাদেশের সর্ববৃহৎ ফ্রি প্রশ্ন ব্যাংক
- AI Background RemoverRemove Image Background Free
- Free QR Code GeneratorCreate Custom QR Codes Online
- Barcode & Label GeneratorCustom Barcodes, QR Codes, A4 Sheets
- EV Class 9-10 All SubjectsPhysics, Chemistry, Biology, Math, ICT & BGS
- Our ServicesFull IT Solutions & Training
- Job Interview PreparationProgramming, Cloud, Data, ERP & More

No comments:
Post a Comment
Thanks for your valuable comment...........
Md. Mominul Islam