AvnishYadav
WorkProjectsBlogsNewsletterSupportAbout
Work With Me

Avnish Yadav

Engineer. Automate. Build. Scale.

Ā© 2026 Avnish Yadav. All rights reserved.

The Automation Update

AI agents, automation, and micro-SaaS. Weekly.

Explore

  • Home
  • Projects
  • Blogs
  • Newsletter Archive
  • About
  • Contact
  • Support

Legal

  • Privacy Policy

Connect

LinkedInGitHubInstagramYouTube
Modern Async Patterns: Making Asynchronous Code Readable with Async/Await and Generators
2026-02-21

Modern Async Patterns: Making Asynchronous Code Readable with Async/Await and Generators

6 min readSoftware EngineeringJavaScriptJavaScriptWeb DevelopmentAsync/AwaitGeneratorsCode QualityRefactoring

A deep dive into modern JavaScript concurrency. We refactor legacy Promise chains into linear async/await syntax and explore how Async Generators can handle pagination and data streams efficiently.

If you have been coding in JavaScript for any length of time, you remember the dark ages. First, we had callbacks—the infamous pyramid of doom. Then came Promises, which flattened the pyramid but introduced long chains of .then() and .catch() blocks that made error handling verbose and scope management a nightmare.

As an engineer building AI agents and automation systems, I deal with asynchronous operations constantly. Whether I'm streaming tokens from an LLM, polling an API for job completion, or orchestrating microservices, control flow is everything.

Today, we aren't just looking at syntax; we are looking at readability as an architectural standard. We are going to refactor messy Promise code into clean async/await patterns, and then we're going to touch on a powerful, often ignored feature: Async Generators.

The Mental Model: Synchronous-Looking Async

The biggest benefit of modern async patterns isn't performance (under the hood, it's still the Event Loop and Promises)—it's cognitive load. Async/await allows you to reason about your code linearly, just like synchronous code, while non-blocking operations happen in the background.

The Problem: Promise Chaining

Let's look at a realistic scenario. We need to:

  1. Fetch a user profile.
  2. Use the user ID to fetch their recent posts.
  3. Enrich those posts with metadata from a different service.

Here is how this looks with standard Promises:

function getUserData(userId) {
  return fetchUser(userId)
    .then(user => {
      return fetchPosts(user.id)
        .then(posts => {
          // Nested to keep access to 'user' scope
          const enrichmentPromises = posts.map(post => enrichPost(post));
          return Promise.all(enrichmentPromises)
            .then(enrichedPosts => {
               return { user, posts: enrichedPosts };
            });
        });
    })
    .catch(err => {
      console.error("Data fetch failed", err);
      throw err;
    });
}

Why this hurts:

  • Nesting: To keep user in scope for the final return object, we have to nest the second .then() inside the first.
  • Error Handling: The .catch() block is generic. It’s hard to tell which step failed without adding individual catch blocks to every promise.
  • Debuggability: Stack traces in complex promise chains can be notoriously difficult to read.

The Refactor: Async/Await

Let's rewrite this using async/await. This is the standard I expect in production codebases today.

async function getUserData(userId) {
  try {
    const user = await fetchUser(userId);
    const posts = await fetchPosts(user.id);
    
    // Parallel execution for enrichment
    const enrichedPosts = await Promise.all(
      posts.map(post => enrichPost(post))
    );

    return { user, posts: enrichedPosts };
  } catch (error) {
    // Ideally, you'd have custom error handling logic here
    console.error(`Failed to fetch data for user ${userId}`, error);
    throw error;
  }
}

The Improvements:

  • Flat Scope: user and posts are available in the same scope scope. No indentation hell.
  • Standard Try/Catch: We use standard JavaScript error handling mechanisms.
  • Intent: It reads top-to-bottom. Get user -> Get posts -> Enrich -> Return.

A Note on Parallelism

A common mistake I see junior developers make when switching to async/await is accidentally serializing code that should be parallel.

Don't do this:

// BAD: Sequential execution blocks the loop unnecessarily
for (const post of posts) {
  await enrichPost(post);
}

If you have 10 posts and each takes 1 second to enrich, this loop takes 10 seconds. Use Promise.all (as shown in the refactor) to fire them concurrently, reducing total time to roughly 1 second.

The Secret Weapon: Generators

Async/await is mainstream. Generators, however, are the tool of the craftsman. A Generator function allows you to pause execution and resume it later. When combined with Promises (Async Generators), they become incredibly powerful for handling streams of data, pagination, or heavy computational tasks without freezing the UI.

The Use Case: Pagination

Imagine fetching data from an API that is paginated. You don't know how many pages there are. You want to iterate over all items as if they were a single array, but you don't want to load 10,000 items into memory at once.

This is where async function* shines.

/**
 * A generator that yields items one by one from a paginated API
 */
async function* fetchAllItems(baseUrl) {
  let nextUrl = baseUrl;

  while (nextUrl) {
    const response = await fetch(nextUrl);
    const data = await response.json();

    // Assume API returns { items: [], next: "url..." }
    nextUrl = data.next;

    // Yield individual items from the current page
    for (const item of data.items) {
      yield item;
    }
  }
}

Consuming the Generator

Now, look at how clean the consumer code is. We can use a for await...of loop. This loop waits for the Promise to resolve and the Generator to yield before moving to the next iteration.

async function processLargeDataset() {
  const url = 'https://api.example.com/v1/users';
  
  // The generator handles the fetching logic logic internally.
  // We just see a stream of users.
  for await (const user of fetchAllItems(url)) {
    await db.save(user);
    
    if (shouldStopProcessing(user)) {
      break; // This stops the generator! No more network requests made.
    }
  }
}

Why Generators Matter for AI Engineering

In my work with LLMs, generators are non-negotiable. When you see ChatGPT typing out text word-by-word, that is a stream.

If you wait for the entire response to be generated before showing it to the user, the latency is unbearable. By using Async Generators, we can yield tokens as they arrive from the model provider and push them to the frontend instantly.

Summary: The Builder's Mindset

Writing code that works is the baseline. Writing code that is readable, maintainable, and efficient is engineering.

  1. Refactor Promises: If you see a chain more than two links deep, refactor to async/await.
  2. Watch for Waterfalls: Ensure you aren't awaiting independent tasks sequentially. Use Promise.all.
  3. Reach for Generators: When dealing with streams, pagination, or infinite sequences, Async Generators provide a clean abstraction that hides the complexity of state management.

Go check your current project. Find that one ugly utility file with the nested promises, and clean it up. Your future self will thank you.

Share

Comments

Loading comments...

Add a comment

By posting a comment, you’ll be subscribed to the newsletter. You can unsubscribe anytime.

0/2000