JavaScript Async/Await in 2026: How to Stop Writing Code That Breaks in Ways You Don’t Expect

Unhandled rejections, sequential vs parallel mistakes, the Promise.all trap with partial failures, async forEach that does not work the way you think, AbortController for cancellation, and the error handling pattern that catches what try/catch misses — the async patterns that actually work in production.


A checkout page calls three independent APIs to build an order summary — pricing, inventory, and shipping estimates. One of them, shipping, times out occasionally under load. The code looks completely reasonable: Promise.all with three calls, wrapped in a try/catch. In production, the occasional shipping timeout doesn’t degrade gracefully — it takes down the entire order summary, including the pricing and inventory data that both succeeded and were sitting there, fully resolved, the instant shipping failed. Nothing about the code is wrong in the sense of a syntax error or a logic bug. Promise.all is doing exactly what it’s documented to do: reject as a whole the moment any single promise in the group rejects, discarding the settled results of everything else. The bug is a mismatch between what the code assumes Promise.all means (“run these in parallel and give me whatever comes back”) and what it actually means (“run these in parallel, and only give me anything if every single one succeeds”).

This is the recurring shape of async JavaScript bugs in production: not incorrect syntax, but a gap between the mental model a developer has of what an async primitive does and what it’s actually specified to do. This post covers six of these gaps specifically — unhandled promise rejections and why they now crash Node processes outright, sequential-await mistakes that quietly turn parallel-shaped work into a slow serial chain, the Promise.all partial-failure trap from above and its actual fix, why array.forEach(async ...) doesn’t wait for anything despite looking like it should, AbortController for genuine cancellation instead of a boolean flag nobody checks reliably, and the error-boundary pattern that catches what a scattered collection of individual try/catch blocks reliably misses.


Unhandled Rejections — Not a Warning Anymore

async function loadUserPreferences(userId) {
  const prefs = await fetchPreferences(userId); // if this rejects and nothing
  return prefs;                                  // catches it, this is now
}                                                  // an unhandled rejection

loadUserPreferences(currentUser.id); // called without .catch() or a
                                       // surrounding try/catch — the
                                       // rejection has nowhere to go

An unhandled promise rejection used to be a easy-to-miss console warning in older Node versions — visible if you were watching logs closely, silent otherwise, and the process kept running regardless. That’s no longer the default behavior. Current Node.js versions terminate the process on an unhandled rejection unless explicitly configured otherwise — a rejected promise with no .catch(), no surrounding try/catch, and no listener on process.on('unhandledRejection', ...) is treated the same severity as an uncaught synchronous exception, because from the runtime’s perspective, that’s exactly what it is: an error nothing in the program ever acknowledged.

// A safety net worth having regardless — logs and lets you decide policy,
// rather than discovering a silent crash from a customer report
process.on('unhandledRejection', (reason, promise) => {
  logger.error('Unhandled promise rejection', { reason, promise });
  // Depending on how aggressively you want to fail: keep running and rely
  // on this being caught in monitoring, or exit deliberately and let a
  // process manager restart cleanly rather than continuing in an unknown state
});

The actual fix isn’t the global handler above — that’s a safety net for a category of bug, not a substitute for making sure every async call chain that matters is actually awaited inside a try/catch or has an explicit .catch(). The specific pattern that causes this most often in real code: a fire-and-forget async call — doSomethingAsync() invoked without await and without .catch(), on the (often correct, until it isn’t) assumption that “this doesn’t need to block anything.” The moment it rejects even once, in production, under a load pattern the local dev environment never hit, that assumption becomes a process crash.

// ❌ Fire-and-forget, no rejection handling — fine until it isn't
sendAnalyticsEvent(eventData);

// ✅ Either await it inside error handling, or explicitly acknowledge
// that failures here are intentionally non-blocking
sendAnalyticsEvent(eventData).catch((err) => {
  logger.warn('Analytics event failed to send', { err });
});

Sequential vs Parallel — The await Placement That Silently Serializes Independent Work

// ❌ Looks like it's doing three things. Is actually doing them one at a time.
async function loadDashboard(userId) {
  const profile = await fetchProfile(userId);      // waits, ~200ms
  const notifications = await fetchNotifications(userId); // then waits, ~150ms
  const activity = await fetchActivity(userId);    // then waits, ~180ms

  return { profile, notifications, activity };
  // Total: ~530ms, even though none of these three calls depends on
  // the result of any other
}

Each await pauses execution of loadDashboard until that specific promise resolves before moving to the next line — and because none of these three calls actually needs the result of a previous one, this code is paying the sum of three independent network calls’ latency, sequentially, for no reason connected to any actual data dependency between them. This is the single most common async performance mistake in production JavaScript, and it’s easy to write by accident because await reads naturally, top to bottom, exactly like synchronous code — which is the feature that makes async/await pleasant to write and the exact reason it hides this specific mistake so well.

// ✅ Start all three immediately, await their results together
async function loadDashboard(userId) {
  const [profile, notifications, activity] = await Promise.all([
    fetchProfile(userId),
    fetchNotifications(userId),
    fetchActivity(userId),
  ]);

  return { profile, notifications, activity };
  // Total: ~200ms — bounded by the slowest of the three, not the sum of all three
}

The fix is not “use Promise.all instead of await” as a blanket rule — it’s specifically for the case where the calls have no data dependency on each other. The moment fetchActivity genuinely needs profile.accountType to decide which endpoint to call, sequential await is correct, not a bug — the mistake is only sequential await applied to calls that are independent of each other, and the tell is almost always: does line two ever reference a variable declared on line one? If not, and it’s still written sequentially, that’s latency being paid for no reason.


The Promise.all Partial-Failure Trap — Back to the Checkout Page

// The exact bug from the opening
async function loadOrderSummary(orderId) {
  try {
    const [pricing, inventory, shipping] = await Promise.all([
      fetchPricing(orderId),
      fetchInventory(orderId),
      fetchShippingEstimate(orderId), // occasionally times out under load
    ]);

    return { pricing, inventory, shipping };
  } catch (err) {
    // pricing and inventory may have both succeeded, fully resolved,
    // and their results are gone — Promise.all discards everything the
    // instant ANY one of the group rejects
    return null;
  }
}

Promise.all is specified to behave exactly this way: it resolves with an array of every result only if every promise in the group resolves, and rejects immediately with the first rejection reason the moment any single one rejects — the settled results of the others aren’t attached to that rejection anywhere; they’re simply not returned. For a group of calls where a partial result is actually useless without the others — a multi-step calculation that genuinely needs every piece — this is the correct behavior. For a group of calls where each result is independently useful — exactly the checkout page case — Promise.all is the wrong primitive entirely, not a primitive being used incorrectly.

// ✅ Promise.allSettled — every result comes back, success or failure,
// nothing is discarded regardless of what else in the group failed
async function loadOrderSummary(orderId) {
  const [pricing, inventory, shipping] = await Promise.allSettled([
    fetchPricing(orderId),
    fetchInventory(orderId),
    fetchShippingEstimate(orderId),
  ]);

  return {
    pricing: pricing.status === 'fulfilled' ? pricing.value : null,
    inventory: inventory.status === 'fulfilled' ? inventory.value : null,
    shipping: shipping.status === 'fulfilled' ? shipping.value : null,
    // The UI can now show pricing and inventory immediately, and render
    // a "shipping estimate unavailable, try again" state for just that
    // one piece — instead of the entire order summary failing
  };
}

Promise.allSettled never short-circuits and never discards a result — every entry in the returned array has a status of either 'fulfilled' (with a .value) or 'rejected' (with a .reason), for every promise in the group, regardless of how many others succeeded or failed. The choice between Promise.all and Promise.allSettled is a real design decision about the data, not a stylistic preference: if the group represents genuinely interdependent data where a partial result has no value, Promise.all is correct, and the fix for the checkout bug isn’t blanket-replacing every Promise.all in the codebase — it’s recognizing that three independently-displayable pieces of an order summary were never actually an all-or-nothing group in the first place.


array.forEach(async …) — Why It Doesn’t Wait for Anything

// ❌ Looks like it processes each item and waits before moving on.
// Does not wait for anything at all.
async function processOrders(orders) {
  orders.forEach(async (order) => {
    await chargeCustomer(order);
    await sendReceipt(order);
  });

  console.log('All orders processed'); // this logs IMMEDIATELY — before
                                         // a single charge has completed
}

Array.prototype.forEach calls its callback for every element and does not do anything with whatever that callback returns — it was specified years before async/await existed, and it has no special awareness that its callback might be an async function returning a promise. forEach fires off all the async callbacks essentially at once, ignores the promises they return entirely, and returns undefined immediately, having no idea any of them are still pending. console.log('All orders processed') runs the instant forEach itself returns, which is almost immediately — genuinely before a single chargeCustomer call has resolved, not “usually before,” but structurally, always before, because nothing in forEach‘s definition ever waits.

// ✅ for...of — actually respects await inside the loop body
async function processOrders(orders) {
  for (const order of orders) {
    await chargeCustomer(order);
    await sendReceipt(order);
  }

  console.log('All orders processed'); // now genuinely true — every
                                         // charge and receipt has resolved
}
// ✅ Or, if the orders can genuinely be processed in parallel (careful —
// this is the same independence question from the sequential-vs-parallel
// section: does charging order 2 need to happen after order 1 for a
// real reason, like rate limiting or a shared resource?)
async function processOrders(orders) {
  await Promise.all(orders.map(async (order) => {
    await chargeCustomer(order);
    await sendReceipt(order);
  }));

  console.log('All orders processed');
}

.map() has the same lack of awareness of async callbacks that .forEach() does — the difference is that .map() at least returns the array of promises the callback produced, which Promise.all can then actually wait on. .forEach() returns undefined unconditionally, giving you nothing to await even if you wanted to. This is the specific, memorizable rule: forEach with an async callback is silently fire-and-forget, always, and for...of (sequential) or .map() wrapped in Promise.all (parallel) are the two correct replacements depending on whether the iterations are independent of each other.


AbortController — Real Cancellation, Not a Flag Nobody Checks

A common but fragile pre-AbortController pattern: a boolean flag set from outside an async function, checked periodically inside it, hoping the check happens often enough and early enough to actually stop meaningful work.

// ❌ A flag that has to be manually checked at exactly the right points,
// and does nothing to actually cancel the underlying fetch request itself
let cancelled = false;

async function searchAsUserTypes(query) {
  const response = await fetch(`/api/search?q=${query}`); // this request
                                                             // keeps running
                                                             // regardless of
                                                             // the flag
  if (cancelled) return; // too late — the network request already completed,
                          // consuming bandwidth and server resources for a
                          // result that's about to be thrown away anyway
  return response.json();
}

AbortController fixes this at the actual source, not just at the point where the result gets discarded — it can genuinely cancel the underlying operation (a fetch request, in particular) rather than merely ignoring the result once it eventually arrives.

// ✅ AbortController — the fetch request itself is cancelled, not just ignored
let currentController = null;

async function searchAsUserTypes(query) {
  currentController?.abort(); // cancel whatever previous search is still in flight
  currentController = new AbortController();

  try {
    const response = await fetch(`/api/search?q=${query}`, {
      signal: currentController.signal,
    });
    return await response.json();
  } catch (err) {
    if (err.name === 'AbortError') {
      return null; // expected — a newer search superseded this one
    }
    throw err; // a genuine failure, not a cancellation — don't swallow it
  }
}

This is the same underlying failure mode covered elsewhere for the async-watcher-race-condition pattern in reactive frameworks — a stale, superseded async operation that resolves after it stops being relevant, and needs an explicit answer for what happens when it does. AbortController‘s signal can be passed not just to fetch but to any API that supports the standard cancellation signal, and for APIs that don’t natively accept a signal, checking signal.aborted at meaningful points inside a longer-running function is the fallback — strictly better than a raw boolean flag because it’s a standard, composable interface rather than an ad-hoc convention invented per-function.

The specific bug worth catching in code review: distinguishing a genuine AbortError (expected, from an intentional cancellation) from every other kind of failure (a real network error, a real server error) is not optional. Catching all errors identically and silently swallowing them means a genuine failure — the API actually being down — gets misreported as “oh, that was just a superseded search,” and a real outage goes unnoticed because it looks identical to routine, expected cancellation.


The Error Handling Pattern That Catches What try/catch Misses

A try/catch around an await catches a rejection from that specific awaited call. It does not catch an error thrown inside a callback passed to something else entirely — a setTimeout, an event listener, a promise created and left unawaited inside the same function — because that code runs in a different execution context than the try/catch block surrounding it, even though it’s textually nested inside it.

// ❌ The try/catch here does not protect against everything happening
// inside this function
async function processPayment(order) {
  try {
    const result = await chargeCard(order);

    setTimeout(() => {
      if (!result.success) {
        throw new Error('Charge failed'); // this throw happens INSIDE a
      }                                     // setTimeout callback — a
    }, 100);                                 // completely different call
                                              // stack from the try/catch
                                              // above, which has already
                                              // finished executing by the
                                              // time this callback runs
  } catch (err) {
    logger.error('Payment processing failed', { err }); // never catches
                                                           // the setTimeout's
                                                           // throw — that
                                                           // becomes an
                                                           // uncaught exception
                                                           // in its own right
  }
}

The try/catch block’s protection ends the moment the synchronous (or awaited) code inside it finishes running — a callback scheduled to run later, even if it’s textually written inside the try block, executes in its own separate invocation, with its own separate call stack, entirely outside whatever try/catch happened to surround the code that scheduled it.

// ✅ A top-level, function-scoped safety net specifically for the parts
// try/catch structurally cannot reach, combined with proper handling
// inside each actual async boundary
async function processPayment(order) {
  try {
    const result = await chargeCard(order);

    if (!result.success) {
      // Handle it here, synchronously, within the same async flow —
      // don't defer error-worthy logic into a setTimeout at all unless
      // the delay itself is genuinely necessary
      throw new Error('Charge failed');
    }

    return result;
  } catch (err) {
    logger.error('Payment processing failed', { err });
    throw err; // re-throw if the caller needs to know, rather than
                // silently swallowing a payment failure
  }
}

// A global safety net for whatever genuinely can't be restructured out
// of a detached callback — the backstop, not the primary strategy
window.addEventListener('unhandledrejection', (event) => {
  logger.error('Unhandled rejection reached the global handler', {
    reason: event.reason,
  });
});

window.addEventListener('error', (event) => {
  logger.error('Uncaught error reached the global handler', {
    error: event.error,
  });
});

The actual fix in the example above isn’t a cleverer try/catch — it’s restructuring the code so the error-worthy check happens inside the same async flow the try/catch actually covers, rather than deferred into a detached callback in the first place. The global listeners are a genuine backstop for whatever can’t be restructured this way (a genuinely necessary delayed callback, third-party code invoking a callback outside your control) — worth having in any production app, precisely because they’re the only thing that catches an error the scattered collection of individual try/catch blocks throughout a codebase structurally cannot reach, by design, regardless of how carefully each individual one is written.


The One Rule

Every bug in this post comes from the same root cause: async primitives that read like simple, sequential code and are specified to behave in ways that don’t match that reading. Promise.all reads like “give me whatever comes back” and is specified as “give me everything or nothing.” forEach reads like it should respect await and has no idea async functions exist. A try/catch reads like it protects everything textually inside it and only protects what’s actually inside its own call stack. None of these are bugs in JavaScript — they’re bugs in the gap between what the syntax visually suggests and what the specification actually promises, and the fix for all six patterns here is the same discipline: know what the primitive is actually specified to do, not what it looks like it does, before trusting it with something that breaks in production in a way local testing never had the load, timing, or failure conditions to surface.

Leave a Reply

Your email address will not be published. Required fields are marked *