JavaScript for Backend Developers — Part 2: Async Patterns Mastery — Callbacks, Promises, async/await & Concurrency | FreeLearning365

JavaScript for Backend Developers — Part 2: Async Patterns Mastery — Callbacks, Promises, async/await & Concurrency | FreeLearning365

🚀 JavaScript for Backend Developers
🗺️ The Complete 7-Part Journey
Navigate anywhere in the series
Learning Path Part 1 of 7
🏆 Part 7 → AI + Observability + Production + 3 Capstone Projects

JavaScript for Backend Developers — Part 2: Async Patterns Mastery — Callbacks, Promises, async/await & Concurrency | FreeLearning365
Part 2 of 7 · JavaScript for Backend Developers

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.

📖 ~55 min deep read 🧪 6 interactive demos ⚡ 40+ code examples 🤖 AI workflows

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 truth about async:

Async programming is not about "waiting". It's about orchestrating work that happens at unknown times, in unknown order, that may fail in unknown ways. Every primitive — callback, promise, async/await — is a different tool for the same job: describing that orchestration clearly, so future-you (and your teammates) can reason about it in seconds.

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.

getUser(id, (err, user) => {
getOrders(user.id, (err, orders) => {
getOrderDetail(orders[0], (err, detail) => {
getShippingStatus(detail, (err, status) => {
notify(user.email, status, (err) => {
// 😵 you are now 6 levels deep
}); }); }); }); });

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 err must 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 return and 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:

promisify.js JavaScript
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 restaurant metaphor: You order food. The waiter doesn't stand there waiting — they hand you a receipt (the promise) and go serve other tables. When your food is ready, the receipt is "fulfilled". If the kitchen is out of ingredients, it's "rejected". Either way, you get exactly one notification.

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)

promise-constructor.js JavaScript
// ✅ 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:

then-catch-finally.js JavaScript
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

Interactive — Promise State Machine

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:

chain.js — flat, readable, one error handler JavaScript
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.

closure-in-chain.js JavaScript
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."

promise-all.js — realistic backend example JavaScript
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.

promise-allsettled.js JavaScript
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:

promise-race.js — the classic timeout pattern JavaScript
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:

promise-any.js — mirror servers / CDN fallback JavaScript
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

Interactive — Combinator Comparison

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.

🎭

The translation:

await x literally means "pause this function here, remember where I am, and resume when x settles". The engine does this by transforming your async function into a state machine — just like it does with generators. You don't see that, but it's what's happening under the hood.

Rewriting the Chain

async-await.js — chain vs await JavaScript
// 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:

still-blocks.js JavaScript
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

try-catch-async.js JavaScript
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.

unhandled.js — why your process keeps dying JavaScript
// 💀 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:

error-boundary.js — Express middleware pattern JavaScript
// 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

errors.js — domain-specific error types JavaScript
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

abort-basic.js JavaScript
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

fetch-with-deadline.js JavaScript
// 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:

cancellable-db.js JavaScript
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.

📈

Why exponential? If 10,000 clients all fail at the same time and all retry after 1s, you get a thundering herd that hammers the recovering service. Doubling the delay (1s, 2s, 4s, 8s…) spreads load out. Jitter (randomness) prevents sync-up: without it, all clients still retry at the same boundary.

The Full Retry Utility

retry.js — production-grade JavaScript
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-usage.js JavaScript
// 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

Interactive — Exponential Backoff Simulator

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 amusement park metaphor: Promise.all is like letting everyone into the ride at once — chaos. A concurrency limiter is like the ticket system that lets exactly 5 people in at a time: as each person exits, the next one enters. Same total work, dramatically smoother execution.

The Implementation — 25 Lines, Production Ready

p-limit.js — build it yourself JavaScript
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

Interactive — Concurrency Limiter
🎯

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:

chunked.js JavaScript
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.

http-client.js JavaScript
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.

shutdown.js JavaScript
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:

aggregate.js JavaScript
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-generator.js JavaScript
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)

async-memoize.js JavaScript
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.all where allSettled was needed.
  • Forgetting return in a .then() callback.
  • Adding retries to non-idempotent operations.
  • Using setTimeout where setImmediate is correct.
  • Serializing loops that should be parallel.
  • Ignoring AbortSignal when refactoring fetch calls.

✅ Always check for

  • Correct combinator choice (all vs allSettled vs race vs any).
  • Proper finally cleanup (no return inside).
  • Explicit shouldRetry predicates.
  • 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.
Score: 0 / 10

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/await as your default style.
  • Wrap async route handlers in an asyncHandler.
  • Always pass AbortSignal.timeout() to network calls.
  • Use Promise.allSettled when 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/finally for cleanup.
  • Return promises from .then() to keep chains flat.
  • Cancel work you no longer need.

❌ DON'T

  • Don't use await in 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 with await in the same function.
  • Don't forget return in .then() callbacks.
  • Don't use Promise.all with 10,000 items — use a limiter.
  • Don't use return inside finally.
  • Don't mark functions async if they contain no await.
  • Don't assume async makes 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 class is really just syntactic sugar.
  • The four rules of this binding (and how arrow functions break them).
  • Composition vs inheritance in modern JavaScript.
  • Immutability patterns and structural sharing.
  • Symbols, iterators, and how for...of actually 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.

🌍 FreeLearning365.com — Your gateway to free learning, tools & resources.

Part 2 of 7 · JavaScript for Backend Developers · © 2026 FreeLearning365


🚀 JavaScript for Backend Developers
🗺️ The Complete 7-Part Journey
Navigate anywhere in the series
Learning Path Part 1 of 7
🏆 Part 7 → AI + Observability + Production + 3 Capstone Projects

Post a Comment

0 Comments