DEV Community

Timevolt
Timevolt

Posted on

JavaScript: async/await Patterns That Will Save You Hours – The Matrix

The Quest Begins (The "Why")

I was knee‑deep in a legacy codebase that looked like a bowl of spaghetti written by someone who loved callbacks more than pizza. Every time I needed to fetch data from three different APIs, I ended up nesting .then callbacks so deep I felt like I was debugging a Russian nesting doll. The code was hard to read, harder to test, and honestly, it made me dread opening the file.

One rainy Tuesday, after yet another “undefined is not a function” error at 2 a.m., I muttered, “There has to be a better way.” That’s when I remembered async/await – the syntactic sugar that promised to turn my promise‑chains into straight‑line code. I dove in, and what I found felt like discovering a hidden cheat code in a classic game.

The Revelation (The Insight)

Async/await isn’t just about making promises look synchronous; it hides a few language quirks that, once you know them, can save you hours of debugging and refactoring. I’ll share two that most developers gloss over, plus a third that’s a subtle gotcha when you start mixing async functions with loops.

1. The Implicit Return of async Functions

Any function declared with async automatically returns a promise. If you return a plain value, that value becomes the resolved value of the promise. If you return another promise, you get that promise back unchanged. Sounds simple, but the surprise is what happens when you don’t return anything at all.

async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  // Oops! Forgot to return the JSON
  const data = await response.json();
}
Enter fullscreen mode Exit fullscreen mode

Calling fetchUser(42) gives you a promise that resolves to undefined, not the user object. The fix? Just add return data; (or return await response.json();).

Why it matters: Forgetting the return turns your async helper into a silent failure. You’ll spend time chasing why your UI shows blank fields while the network tab looks fine.

2. await Works on Any Thenable, Not Just Promises

The await operator doesn’t care if you gave it a native Promise. It only needs an object with a then method (a thenable). This means you can await custom classes, libraries that expose a thenable API, or even plain objects you wrap yourself.

class LazyNumber {
  constructor(value) {
    this.value = value;
    this._then = null;
  }
  then(resolve, reject) {
    // Simulate async work
    setTimeout(() => resolve(this.value * 2), 100);
    return {
      then: (onFulfilled) => onFulfilled(this.value * 4) // chainable
    };
  }
}

async function demo() {
  const num = new LazyNumber(5);
  const result = await num; // await calls .then under the hood
  console.log(result); // 10 after first tick, then 40 if you chain
}
Enter fullscreen mode Exit fullscreen mode

Why it matters: Knowing this lets you integrate async behavior into existing synchronous‑looking APIs without rewriting them as promises. It’s a neat trick when you’re wrapping legacy callbacks or third‑party SDKs that already expose a thenable interface.

3. Loops + await ≠ Parallel Execution (The Gotcha)

This one tripped me up more times than I’d like to admit. If you write a for loop and await inside it, each iteration waits for the previous one to finish. It’s sequential, not parallel.

// 🐢 Slow version – runs requests, one after another
async function fetchAllSlow(ids) {
  const results = [];
  for (const id of ids) {
    const data = await fetch(`/api/items/${id}`); // waits each time
    results.push(await data.json());
  }
  return results;
}
Enter fullscreen mode Exit fullscreen mode

If you have ten IDs, you’ll incur ten round‑trips back‑to‑back. The fix? Kick off all the promises first, then await them collectively with Promise.all.

// ⚡ Fast version – fire them all at once
async function fetchAllFast(ids) {
  const promises = ids.map(id => fetch(`/api/items/${id}`).then(res => res.json()));
  return Promise.all(promises);
}
Enter fullscreen mode Exit fullscreen mode

Why it matters: In performance‑sensitive code (think data dashboards or real‑time charts), confusing sequential await with parallel execution can turn a snappy UI into a sluggish one. Recognizing the pattern lets you deliberately choose when you need order (e.g., dependent steps) and when you can fire away.

Wielding the Power (Code & Examples)

Before: The Callback Nightmare

function getUserPosts(userId, cb) {
  fetch(`/api/users/${userId}`)
    .then(r => r.json())
    .then(user => {
      fetch(`/api/posts?userId=${user.id}`)
        .then(r => r.json())
        .then(posts => {
          fetch(`/api/comments?postId=${posts[0].id}`)
            .then(r => r.json())
            .then(comments => cb(null, { user, posts, comments }))
            .catch(cb);
        })
        .catch(cb);
    })
    .catch(cb);
}
Enter fullscreen mode Exit fullscreen mode

Reading this feels like trying to follow a maze blindfolded.

After: Async/Await Bliss

async function getUserPosts(userId) {
  try {
    const userResp = await fetch(`/api/users/${userId}`);
    const user = await userResp.json();

    const postsResp = await fetch(`/api/posts?userId=${user.id}`);
    const posts = await postsResp.json();

    // If we need the first post's comments, we can still await sequentially
    const commentsResp = await fetch(`/api/comments?postId=${posts[0].id}`);
    const comments = await commentsResp.json();

    return { user, posts, comments };
  } catch (err) {
    throw err; // let caller handle it
  }
}
Enter fullscreen mode Exit fullscreen mode

Same logic, but now it reads like a story. Error handling is a familiar try/catch, and you can still drop in Promise.all when you want parallelism.

A Real‑World Use Case: Batch Image Processing

Imagine you have an array of image URLs you need to download, resize, and upload to a CDN. Doing it one‑by‑one would take forever.

async function processImages(urls) {
  // Step 1: download all images concurrently
  const downloadPromises = urls.map(url => fetch(url).then(res => res.buffer()));
  const buffers = await Promise.all(downloadPromises);

  // Step 2: resize each buffer (assume a sharp-like async API)
  const resizePromises = buffers.map(buf => sharp(buf).resize(800).toBuffer());
  const resized = await Promise.all(resizePromises);

  // Step 3: upload to CDN (again, fire them all)
  const uploadPromises = resized.map(buf => cdn.upload(buf, { contentType: 'image/jpeg' }));
  await Promise.all(uploadPromises);
}
Enter fullscreen mode Exit fullscreen mode

By separating the fire phase from the await phase, we keep the code readable while squeezing out every millisecond of concurrency.

Why This New Power Matters

Mastering these nuances does more than save you a few keystrokes; it changes how you think about asynchronous flow. You start seeing promises as first‑class values you can compose, rather than opaque chains that magically resolve somewhere downstream.

  • Debugging becomes faster because you can set breakpoints on await lines and inspect intermediate values just like synchronous code.
  • Refactoring feels safer – extracting a helper that returns a promise is trivial, and you won’t accidentally lose a return value.
  • Performance gains are obvious when you know when to await sequentially and when to fire off Promise.all.

In short, you’ll write code that’s not only correct but also a joy to read and maintain.

Your Turn

Pick a piece of your own code that’s still tangled in .then chains. Try rewriting it with async/await, then look for a place where you can swap a sequential loop for a Promise.all. Share your before/after snippets in the comments – I’d love to see how you leveled up!

Happy coding, and may your promises always resolve! 🚀

Top comments (0)