JavaScript’s async/await syntax was supposed to save us from callback hell. Instead, it introduced a quieter, more insidious problem: code that looks correct but behaves unpredictably. You see await sitting there in the function body and assume everything pauses politely, line by line. Often it does not. Loops race ahead. Entire batches collapse because of a single failed request. Entry files sprout ugly async wrappers for no reason. If you have run into any of these, these three patterns will straighten them out.

Stop Using await Inside forEach

Here is a common mistake that looks harmless on the page:

const urls = ['/api/user', '/api/posts', '/api/comments'];

urls.forEach(async (url) => {
  const res = await fetch(url);
  const data = await res.json();
  console.log(data);
});

console.log('All done!');

Run this, and 'All done!' prints before a single response comes back. Why? forEach executes the callback for every element immediately. It does not wait for the promise inside each iteration. The async keyword turns each callback into a promise that forEach promptly ignores. Your loop finishes in microseconds; the network requests wander off on their own. If you need errors handled in order, or if you need to guarantee one request finishes before the next begins, this pattern silently breaks both guarantees.

Replace it with a for...of loop:

const urls = ['/api/user', '/api/posts', '/api/comments'];

for (const url of urls) {
  const res = await fetch(url);
  const data = await res.json();
  console.log(data);
}

console.log('All done!');

Now the loop genuinely halts at each await. The second request waits for the first. 'All done!' prints only after everything settles.

Use for...of when sequence matters—uploading files one at a time to respect rate limits, writing database rows in a specific order, or chaining API calls where the next request needs data from the previous response. If you actually want parallel execution, do not fumble around with forEach. Reach for Promise.all explicitly so your intent is visible to the next developer. But never mix await and forEach expecting synchronous behavior. It will not happen.

Reach for Promise.allSettled When Zero Cannot Be the Answer

Promise.all is semantically honest. Hand it an array of promises, and it returns an array of results. The catch is that the moment any single promise rejects, the whole thing rejects immediately. Every other pending promise is left to finish on its own, but you lose access to their results. In production, this all-or-nothing behavior stings.

Imagine your application fetches a dashboard’s widgets from four independent services: traffic analytics, revenue data, user feedback, and server health. The revenue API hits a brief timeout. Under Promise.all, your entire dashboard throws an error. The three healthy responses vanish into the void. The user sees a spinner, then a failure screen, because one quarter of the data misbehaved.

Promise.allSettled gives you a saner contract. It waits until every single promise finishes, whichever way it goes. The resolved value is an array of objects describing each outcome:

const requests = [
  fetch('/api/traffic'),
  fetch('/api/revenue'),
  fetch('/api/feedback'),
  fetch('/api/health')
];

const results = await Promise.allSettled(requests);

results.forEach((result, index) => {
  if (result.status === 'fulfilled') {
    renderWidget(index, result.value);
  } else {
    renderError(index, result.reason);
  }
});

No response gets discarded. You render what you can and isolate the failure. This pattern matters whenever you are dealing with unrelated operations—bulk notifications, third-party webhook dispatches, or importing records from multiple CSV streams. You still need centralized error tracking, but your application keeps its footing.

One practical note: allSettled returns the full set, so you still need to sift through the results and decide what "partial success" means for your feature. Do not treat the returned array as uniformly happy data. Check those status fields before you push anything into your state layer.

Declare Top-Level await and Kill the Wrapper IIFE

For years, if you wanted to await something at the root of a file, you wrapped it in an immediately invoked async function:

(async () => {
  const config = await loadConfig();
  startServer(config);
})();

This works, but it is noise. Top-level await, native in ES modules, lets you drop the ceremonial boilerplate:

const config = await loadConfig();
startServer(config);

Use this at the entry point of your application or in dedicated configuration modules where initialization must complete before anything else runs. Loading environment files, establishing a database connection pool, or fetching remote feature flags are all natural fits. Because top-level await blocks execution of the module graph—other files importing this one will wait for your promise to resolve—you get a guaranteed state. The rest of your codebase can import db and know the connection is already live.

There are two catches. First, your runtime or bundler must support ES modules. In Node.js, that means either using .mjs extensions or setting "type": "module" in your package.json. Second, because waiting at the module level delays every importer, keep the awaited work focused. Heavy sequential fetches at the top of a frequently imported utility file will slow down your entire application cold start. Reserve top-level await for true bootstrap tasks that other modules genuinely depend upon.

What Actually Changes When You Adopt These Patterns

Predictability is the first payoff. When you read a for...of loop, you know exactly when the block underneath finishes. There are no ghost promises racing behind the scenes, no foreach callbacks detaching from your error handlers. Your control flow matches the shape of the code on the screen.

Resilience comes next. Promise.allSettled forces you to think about partial failure instead of hoping every external system stays perfect. Production software is not binary. Some endpoints will flake. Some file reads will hit permission errors. Designing for the reality of scattered failures keeps your application upright without sweeping legitimate data under the rug.

Clarity ties it together. for...of reads like plain English progression. allSettled states its intent in its name. Top-level await removes cryptic IIFE wrappers so your entry files start with business logic instead of syntactic acrobatics. The next engineer who touches the file—whether that is you in six months or a teammate on a deadline—will thank you.

A Real Takeaway

Do not treat async/await as a global fix that you sprinkle over existing code. Audit your current projects for these three specific anti-patterns. Search for await inside forEach blocks and replace them with for...of or an intentional Promise.all. Review every Promise.all that talks to external services and ask whether a single failure should really torpedo the entire operation; if not, switch to Promise.allSettled and handle the mixed results. Finally, strip the async IIFEs out of your ES module entry points and let top-level await handle your bootstrap sequence directly. These are small mechanical changes, but together they turn fragile asynchronous scripts into code you can actually trust.