DEV Community

Timevolt
Timevolt

Posted on

The Async/Await Awakens: JavaScript Patterns That Will Save You Hours

The Quest Begins (The "Why")

I still remember the first time I tried to fetch data from three different APIs, show a loader, and then render a UI once everything was ready. I wrote something that looked like this:

function loadUserProfile() {
  showSpinner();
  fetch('/api/user')
    .then(res => res.json())
    .then(user => {
      fetch(`/api/posts/${user.id}`)
        .then(res => res.json())
        .then(posts => {
          fetch(`/api/notifications/${user.id}`)
            .then(res => res.json())
            .then(notifications => {
              hideSpinner();
              renderProfile({ user, posts, notifications });
            })
            .catch(err => console.error(err));
        })
        .catch(err => console.error(err));
    })
    .catch(err => console.error(err));
}
Enter fullscreen mode Exit fullscreen mode

Honestly, I felt like I was stuck in a never‑ending callback hell, scrolling through indents that resembled a staircase to nowhere. The code was fragile, hard to read, and every tiny change meant I had to re‑nest another .then. I knew there had to be a better way—something that let me write asynchronous code that looked synchronous, without losing the power of promises. That’s when I dove into async/await. Little did I know, the language had a few hidden tricks that would turn my quest from a slog into a speedrun.

The Revelation (The Insight)

When I first learned async/await, I thought it was just syntactic sugar for .then. Sure, it made the code read top‑to‑bottom, but I missed a few gems that most tutorials gloss over. These aren’t just “nice‑to‑have”; they’re the kind of shortcuts that can shave hours off debugging and refactoring. Here are the three surprises that changed the way I write JavaScript:

  1. Top‑level await – You can now await a promise directly at the module level, no IIFE wrapper needed.
  2. For‑await‑of with async iterables – Loop over streams, async generators, or any async iterable without manually chaining .then.
  3. Awaiting non‑promises – If you await a value that isn’t a promise, JavaScript automatically wraps it in a resolved promise. This can be a blessing and a sneaky gotcha if you’re not expecting it.

Understanding why these work (and where they can bite you) is what separates “I can make it work” from “I truly get async JavaScript.”

Wielding the Power (Code & Examples)

1. Top‑level await – No more wrapping IIFEs

The struggle – Before ES2022, if you wanted to fetch some config at the start of a script you had to do:

(async () => {
  const config = await fetch('/config.json').then(r => r.json());
  initApp(config);
})();
Enter fullscreen mode Exit fullscreen mode

That extra pair of parentheses felt like wearing a cloak just to step outside.

The revelation – With top‑level await you can write:

// config.js
const configRes = await fetch('/config.json');
const config = await configRes.json();
export default config;
Enter fullscreen mode Exit fullscreen mode

No wrapper, no extra indentation. The module itself becomes an async entity, and any file that imports it will wait for the config to resolve before executing its own top‑level code.

Gotcha – Top‑level await only works in modules (type="module" in <script> or "exports": {...} in package.json). Try it in a classic script and you’ll get a SyntaxError.

2. For‑await‑of – Consuming async iterables like a pro

The struggle – Imagine you’re reading a large file line‑by‑line from a server that streams chunks. The naive way:

const reader = getLineReader(); // returns an async iterator
let line;
while (!(line = await reader.next()).done) {
  process(line.value);
}
Enter fullscreen mode Exit fullscreen mode

It works, but the manual next() loop is noisy and easy to mess up (forgotten await, missing .done check).

The revelation – JavaScript gives us for await … of:

const reader = getLineReader(); // async iterable
for await (const line of reader) {
  process(line);
}
Enter fullscreen mode Exit fullscreen mode

Behind the scenes, the loop calls [Symbol.asyncIterator]() on the object, awaits each yielded promise, and breaks when the iterator signals done. It’s as clean as a synchronous for … of, but fully async.

Use case – Processing server‑sent events, reading from a WebSocket stream, or paging through a REST API that returns a cursor. You get backpressure for free: the loop won’t request the next chunk until you’ve finished processing the current one.

Gotcha – If the object you pass to for await … of isn’t an async iterable (i.e., it lacks [Symbol.asyncIterator]), you’ll get a runtime error. Make sure you’re dealing with a true async iterable or wrap it with one (async function*).

3. Awaiting non‑promises – The silent wrapper

The struggle – I once wrote a helper that tried to be flexible:

async function fetchData(input) {
  // input might already be a promise or a plain value
  const data = await input;
  return data.process();
}
Enter fullscreen mode Exit fullscreen mode

I assumed if someone passed a promise, we’d wait; if they passed a plain object, we’d just use it.

The revelation – The await operator does exactly that: if the operand is a promise, it waits; if it’s any other value, JavaScript creates a resolved promise with that value and then awaits it. So the function works for both cases without extra checks.

// works with a promise
fetchData(fetch('/api/data')).then(console.log);

// works with a plain value
fetchData({ foo: 'bar' }).then(console.log); // logs { foo: 'bar' } after a micro‑tick
Enter fullscreen mode Exit fullscreen mode

Gotcha – Because the non‑promise is wrapped, you lose synchronous execution. If you were expecting the function to return immediately when given a plain value, you’ll be surprised to see it behave asynchronously (the .then callback runs after the current call stack clears). This can bite you in tests or when you’re measuring performance. Knowing that await always yields to the event loop helps you reason about timing correctly.

Why This New Power Matters

Mastering these patterns turns you from a “promise chainer” into a fluent async storyteller.

  • Top‑level await lets you bootstrap your app with minimal ceremony—think of it as the opening crawl of a Star Wars film, setting the stage before the action begins.
  • For‑await‑of turns messy streaming logic into a clean, readable loop, letting you focus on what you do with the data, not how you get it.
  • Understanding the non‑promise behavior prevents subtle timing bugs and lets you write helpers that are truly flexible, saving you from those “why is my test flaky?” moments.

When you combine them, you can write code that reads like a saga: fetch config, stream data, process each piece, and render the result—all without a single .then in sight. Your teammates will thank you, your future self will high‑five you, and you’ll finally have time to tackle that side project you’ve been postponing.

Your Turn – A Mini‑Quest

Try refactoring a piece of your own code that currently uses a chain of .thens (or a nested callback) into one of the patterns above. Start small: replace a .then with await, then see if you can swap a manual while loop for a for await … of. If you feel brave, enable top‑level await in a module and watch the boilerplate melt away.

Drop a link to your before/after snippet in the comments, or tweet it with #AsyncAwakens. I can’t wait to see what quests you embark on next!


May your promises always resolve, and your bugs be as rare as a critical hit in a boss fight. 🚀

Top comments (0)