DEV Community

Timevolt
Timevolt

Posted on

May the Async Be With You: async/await Patterns That Will Save You Hours

The Quest Begins (The “Why”)

Honestly, I used to stare at my screen feeling like I was stuck in a boss fight with no health packs. Every time I needed to fetch data from three different APIs, I ended up nesting .then() callbacks like a set of Russian dolls. The code was hard to read, harder to debug, and if one request failed I’d spend hours tracing why the whole chain crashed. I kept thinking, “There’s gotta be a better way to write asynchronous JavaScript without losing my sanity.”

That “aha!” moment came when I finally gave async/await a real shot—not just as syntactic sugar, but as a tool that changes how you think about concurrency. Once I stopped treating it like a fancy try/catch wrapper and started exploring the language features that live underneath, my productivity jumped. I want to share the three surprising async/await gems that most developers miss, show the gotchas that bite you if you’re not careful, and give you a practical pattern you can start using today.

The Revelation (The Insight)

1. Top‑Level Await – Your Module Can Finally Breathe

For years we wrapped our entry point in an IIFE just to use await at the top level:

// old way – ugly and noisy
(async () => {
  const user = await fetch('/api/user').then(r => r.json());
  const posts = await fetch(`/api/posts/${user.id}`).then(r => r.json());
  render({ user, posts });
})();
Enter fullscreen mode Exit fullscreen mode

The gotcha? If you forget the IIFE, you get a SyntaxError because await is only allowed inside an async function. With ES2022, top‑level await lets you drop the wrapper entirely—but only inside ECMAScript modules (files with type="module"). In a classic script tag it still throws, which trips up folks who migrate a file without updating its <script type="module"> attribute.

Why it matters: You can now write initialization code that reads like a synchronous script, making the intent crystal clear while still staying non‑blocking under the hood.

2. Async Iteration – Consuming Streams Without the Callback Circus

Think about reading lines from a huge file, or polling a WebSocket that pushes chunks of data. The usual approach is to attach listeners, manage buffers, and manually resolve promises—code that quickly becomes a spaghetti monster.

JavaScript gives us async iterables and the for await…of loop. Anything that implements Symbol.asyncIterator (like a ReadableStream from the Fetch API) can be consumed with a simple, synchronous‑looking loop.

The gotcha? If you try to for await…of over a regular array or a plain object, you’ll get a TypeError because they lack the async iterator method. You also need to remember that each iteration pauses at the await point, letting other microtasks run—so you’re not blocking the event loop, but you are waiting for each chunk to resolve before moving on.

Why it matters: You turn a messy push‑based API into a pull‑based, readable flow that’s easier to test, debug, and reason about.

3. Promise.allSettled – The Safety Net You Didn’t Know You Needed

We all love Promise.all for firing off multiple requests in parallel. But its biggest flaw is the “all‑or‑nothing” nature: if any promise rejects, the whole thing rejects instantly, and you lose the results of the successful ones. I’ve seen teams wrap each promise in its own try/catch just to salvage the good data—talk about extra boilerplate.

Enter Promise.allSettled. It waits for every promise to settle (either fulfill or reject) and returns an array of objects describing each outcome. No more early bailout, no lost data.

The gotcha? The result shape is different: you get { status: 'fulfilled', value } or { status: 'rejected', reason }. If you treat it like Promise.all and just map over the values, you’ll swallow errors silently. You have to inspect the status field first.

Why it matters: You can now handle partial failures gracefully—show the data you have, log the errors, and keep the user experience smooth instead of throwing a blanket error page.

Wielding the Power (Code & Examples)

Example 1: Top‑Level Await in a Module

// utils/api.js  (note: imported as a module)
export async function fetchUser(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('User not found');
  return res.json();
}

// main.js  (type="module")
import { fetchUser } from './utils/api.js';

// No IIFE needed – top‑level await works here!
const user = await fetchUser(42);
console.log('Logged in as', user.name);
Enter fullscreen mode Exit fullscreen mode

Before: IIFE boilerplate, extra indentation, easy to miss a closing parenthesis.

After: Linear, readable, and you can still import fetchUser elsewhere without change.

Example 2: Async Iteration Over a Stream

// Suppose we have a endpoint that streams JSON lines
async function* streamJsonLines(response) {
  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    let newlineIndex;
    while ((newlineIndex = buffer.indexOf('\n')) >= 0) {
      const line = buffer.slice(0, newlineIndex);
      buffer = buffer.slice(newlineIndex + 1);
      yield JSON.parse(line);
    }
  }
}

// Usage
(async () => {
  const resp = await fetch('/api/events'); // returns a readable stream
  for await (const event of streamJsonLines(resp)) {
    console.log('Received event:', event);
    // process each event as it arrives
  }
})();
Enter fullscreen mode Exit fullscreen mode

Before: You’d attach ondata listeners, manually chunk bytes, and keep a state machine.

After: The for await…of loop reads like synchronous code, yet each await yields control back to the event loop, keeping the UI responsive.

Example 3: Promise.allSettled for Robust Parallel Calls

async function fetchDashboard() {
  const [userReq, notificationsReq, metricsReq] = Promise.allSettled([
    fetch('/api/user').then(r => r.json()),
    fetch('/api/notifications').then(r => r.json()),
    fetch('/api/metrics').then(r => r.json())
  ]);

  const dashboard = {};

  if (userReq.status === 'fulfilled') dashboard.user = userReq.value;
  else console.error('User fetch failed:', userReq.reason);

  if (notificationsReq.status === 'fulfilled') dashboard.notifications = notificationsReq.value;
  else console.warn('Notifications fetch failed:', notificationsReq.reason);

  if (metricsReq.status === 'fulfilled') dashboard.metrics = metricsReq.value;
  else console.error('Metrics fetch failed:', metricsReq.reason);

  return dashboard;
}

// Call it
fetchDashboard().then(dash => renderDashboard(dash));
Enter fullscreen mode Exit fullscreen mode

Before: Wrap each fetch in its own try/catch or risk losing all data on a single failure.

After: One clean call, individual status checks, and you still get whatever succeeded.

Why This New Power Matters

Mastering these patterns does more than shave a few lines off your file—it reshapes how you reason about concurrency. You start seeing asynchronous code as a series of await points where the browser can pause and let other work happen, rather than a tangled web of callbacks that block the main thread.

  • Top‑level await lets you treat modules like scripts, making entry points obvious and reducing boilerplate.
  • Async iteration turns push‑based streams into pull‑based, readable loops that are trivial to test with mock async iterables.
  • Promise.allSettled gives you a safety net for parallel work, so you can build resilient dashboards, micro‑frontend aggregates, or any feature that needs multiple data sources without the fear of total failure.

When you internalize these ideas, you stop fighting the language and start letting it work for you. Debugging becomes faster because the flow is explicit; refactoring feels safer because each await is a clear boundary where you can inspect state. In short, you write code that’s not only correct but also a joy to read—and that’s the kind of code that earns you nods from teammates and a smug grin when you look back at your old promise‑hell scripts.

Your Turn: The Quest Continues

Here’s a small challenge to lock in what you’ve learned: pick a feature you currently implement with a chain of .then() callbacks—maybe a file uploader that chunks data, or a dashboard that pulls from three microservices—and rewrite it using one of the patterns above. Share your before/after snippets in the comments, and let’s celebrate the victories together!

Happy coding, and may the async be with you! 🚀

Top comments (0)