DEV Community

Timevolt
Timevolt

Posted on

JavaScript: async/await patterns that will save you hours – The Matrix Reloaded Edition

The Quest Begins (The “Why”)

I remember the first time I tried to fetch data from three different APIs, process each response, and then display a combined result. I wrote something like:

fetchUser(id)
  .then(user => fetchPosts(user.id))
  .then(posts => fetchComments(posts[0].id))
  .then(comments => render({ user, posts, comments }))
  .catch(err => console.error(err));
Enter fullscreen mode Exit fullscreen mode

It worked, but reading it felt like trying to follow a maze blindfolded. Every new requirement meant nesting another .then, and error handling turned into a game of whack‑a‑mole. I spent hours debugging a missing catch that swallowed a rejection silently, and I kept wishing there was a way to write asynchronous code that looked synchronous — clean, top‑to‑bottom, and easy to reason about.

That’s when I dove deeper into async/await. What I found weren’t just syntactic sugar; they were a handful of surprising language features that most tutorials gloss over. Mastering them didn’t just shave minutes off my debugging sessions — it changed how I think about concurrency altogether.

The Revelation (The Insight)

1. Top‑level await – the “press start” button for modules

For years we wrapped our entry point in an IIFE just to use await:

(async () => {
  const config = await fetchConfig();
  startApp(config);
})();
Enter fullscreen mode Exit fullscreen mode

That extra pair of parentheses always felt like boilerplate noise. Then ES2022 gave us top‑level await: you can now place await directly at the top of a module, as long as the module is treated as a module (type="module" in a script tag or .mjs file).

Gotcha: If you accidentally forget to set the module type, the script is parsed as a classic script and top‑level await throws a SyntaxError. Also, top‑level await pauses the entire module graph — any module that imports yours will wait for your await to resolve before it can execute. Use it wisely (usually for configuration loading or feature flags).

Practical use case: Loading environment‑specific settings before the rest of your app boots.

// config.mjs  (note the .mjs extension or "type":"module" in package.json)
export const config = await fetch('/api/config').then(r => r.json());

// app.js
import { config } from './config.mjs';
initApp(config);
Enter fullscreen mode Exit fullscreen mode

No IIFE, no extra indentation — just straight‑line code that reads like a script.

2. for await…of – looping over async iterables as if they were arrays

When you need to process a stream of data — say, reading lines from a large file, consuming a WebSocket, or paging through a REST API that returns a cursor — you often end up with a recursive .then chain or a while loop that manually resolves promises. It’s easy to lose track of where the await lives, and you can accidentally create an infinite loop if you forget to break.

Enter for await…of. It works on any object that implements the async iterator protocol (has a Symbol.asyncIterator method). The loop automatically awaits each yielded promise before moving to the next iteration.

Gotcha: If the async iterable never returns { done: true }, the loop will run forever. Also, any thrown error inside the loop aborts it unless you wrap the whole thing in a try/catch. Forgetting that the loop itself is asynchronous can lead to trying to use its results outside the loop before they’re ready.

Practical use case: Fetching paginated results from an API that uses a next cursor.

async function* fetchPages() {
  let url = '/api/items?limit=100';
  while (url) {
    const resp = await fetch(url);
    const data = await resp.json();
    yield data.items;          // each iteration yields an array of items
    url = data.next;           // null when there are no more pages
  }
}

// Usage
(async () => {
  let allItems = [];
  for await const page of fetchPages() {
    allItems.push(...page);
    console.log(`Fetched ${allItems.length} items so far…`);
  }
  console.log('All done!', allItems);
})();
Enter fullscreen mode Exit fullscreen mode

The code reads like a simple for…of loop over an array, yet it handles asynchronous waiting behind the scenes. No manual cursor management, no recursive promises — just clean, declarative flow.

3. Promise.allSettled – waiting for every promise, win or lose

Promise.all is great when you need all promises to succeed, but it fails fast: the first rejection rejects the whole lot. Sometimes you do want to know the outcome of every request, even if some of them error out (think dashboard widgets that should show stale data or an error message rather than blank space).

Promise.allSettled returns an array of objects, each with a status ('fulfilled' or 'rejected') and either a value or a reason. It never short‑circuits.

Gotcha: The result array is always the same length as the input, but you must inspect each object to decide what to do. Forgetting to check status and blindly using .value will throw when the promise rejected.

Practical use case: Loading several independent microservice endpoints for a user profile page.

const [user, posts, followers, notifications] = await Promise.allSettled([
  fetch(`/api/user/${id}`).then(r => r.json()),
  fetch(`/api/posts/${id}`).then(r => r.json()),
  fetch(`/api/followers/${id}`).then(r => r.json()),
  fetch(`/api/notifications/${id}`).then(r => r.json()),
]);

const profile = {
  user: user.status === 'fulfilled' ? user.value : null,
  posts: posts.status === 'fulfilled' ? posts.value : [],
  followers: followers.status === 'fulfilled' ? followers.value : [],
  notifications: notifications.status === 'fulfilled' ? notifications.value : [],
  errors: [
    user.status === 'rejected' && user.reason,
    posts.status === 'rejected' && posts.reason,
    followers.status === 'rejected' && followers.reason,
    notifications.status === 'rejected' && notifications.reason,
  ].filter(Boolean),
};

renderProfile(profile);
Enter fullscreen mode Exit fullscreen mode

Now the page never shows a blank screen just because one service hiccuped; instead, you get partial data and a clear list of what went wrong.

Wielding the Power (Code & Examples)

Let’s put the three patterns together in a realistic scenario: a dashboard that loads configuration, streams live metrics, and aggregates data from multiple services — all while keeping the code flat and readable.

Before (the struggle)

// config.js
export function loadConfig() {
  return fetch('/config').then(r => r.json());
}

// metrics.js
function startMetricsStream(cb) {
  const evtSource = new EventSource('/metrics');
  evtSource.onmessage = e => cb(JSON.parse(e.data));
}

// dashboard.js
import { loadConfig } from './config.js';
import { startMetricsStream } from './metrics.js';

loadConfig()
  .then(config => {
    // fetch several REST endpoints
    return Promise.all([
      fetch(`/api/users/${config.userId}`).then(r => r.json()),
      fetch(`/api/orders/${config.userId}`).then(r => r.json()),
      fetch(`/api/inventory/${config.userId}`).then(r => r.json()),
    ]);
  })
  .then(([user, orders, inventory]) => {
    renderInitial({ user, orders, inventory });
    // start listening to live metrics
    startMetricsStream(metric => updateLiveChart(metric));
  })
  .catch(err => {
    console.error('Dashboard failed:', err);
    showErrorToast('Something went wrong');
  });
Enter fullscreen mode Exit fullscreen mode

The nesting is already noticeable, and if we wanted to handle partial failures in the Promise.all we’d have to rewrite it with Promise.allSettled and then manually check each result. Adding more async steps only deepens the indentation.

After (the victory)

// config.mjs
export const config = await fetch('/config').then(r => r.json());

// metrics.mjs
export async function* metricsStream() {
  const evtSource = new EventSource('/metrics');
  for await const event of evtSource) {
    yield JSON.parse(event.data);
  }
}

// dashboard.mjs
import { config } from './config.mjs';
import { metricsStream } from './metrics.mjs';

(async () => {
  try {
    // 1️⃣ Load config (top‑level await already gave us config)
    // 2️⃣ Fetch several independent services, but we want all results
    const [userResp, ordersResp, inventoryResp] = await Promise.allSettled([
      fetch(`/api/users/${config.userId}`),
      fetch(`/api/orders/${config.userId}`),
      fetch(`/api/inventory/${config.userId}`),
    ]);

    const user = userResp.status === 'fulfilled' ? await userResp.json() : null;
    const orders = ordersResp.status === 'fulfilled' ? await ordersResp.json() : [];
    const inventory = inventoryResp.status === 'fulfilled' ? await inventoryResp.json() : [];

    renderInitial({ user, orders, inventory });

    // 3️⃣ Consume live metrics with for await…of
    for await const metric of metricsStream()) {
      updateLiveChart(metric);
    }
  } catch (err) {
    console.error('Dashboard crashed:', err);
    showErrorToast('Unable to load dashboard – please try again later');
  }
})();
Enter fullscreen mode Exit fullscreen mode

What changed?

  • The configuration is loaded once, at the top of the module, with no extra wrapper.
  • We used Promise.allSettled to gather data from three endpoints, guaranteeing we get each outcome, then handled fulfilled/rejected cases explicitly.
  • The live metrics stream is now an async iterator; for await…of reads it exactly like a simple array loop, eliminating manual event‑listener boilerplate.

The flow reads top‑to‑bottom, error handling is centralized in one try/catch, and each concern (config, data fetch, live stream) is visually separated. It’s easier to test, easier to modify, and far less prone to the “callback hell”‑style mistakes that used to eat up my debugging time.

Why This New Power Matters

Mastering these patterns does more than shave a few lines off your file — it reshapes how you reason about asynchronous work:

  • Predictability – Top‑level await lets you treat a module like a script that runs after its prerequisites are satisfied, eliminating guesswork about when something is ready.
  • ResiliencePromise.allSettled forces you to think about partial success, leading to UIs that degrade gracefully instead of blowing up.
  • Clarityfor await…of turns awkward event‑handler callbacks into familiar loops, making streaming data feel as natural as iterating over an array.

When you internalize these tools, you spend less time wrestling with the mechanics of async code and more time solving the actual product problems. You’ll find yourself reaching for these patterns instinctively, and your pull requests will start to look like clean, readable stories rather than tangled webs of .then chains.

Your Turn – A Mini‑Quest

Pick a piece of your current project that still relies on nested .thens or a manual while loop for polling. Refactor it using one of the patterns above (top‑level await, for await…of, or Promise.allSettled). Notice how the mental load shifts.

If you feel daring, try combining two of them in the same module and see how the code flows.

What surprised you the most when you made the switch? Drop a comment below — let’s learn from each other’s victories!


Happy coding, and may your async flows always be smooth! 🚀

Top comments (0)