jsmanifest logojsmanifest

JavaScript Generators for Async Control Flow

JavaScript Generators for Async Control Flow

Learn how JavaScript generators provide powerful control over async operations, enabling you to pause, resume, and coordinate complex asynchronous workflows with elegant syntax.

While I was looking over some legacy code the other day, I stumbled upon a generator function handling multiple API calls in sequence. At first glance, I thought "Why not just use async/await?" But as I dug deeper, I realized the developer had unlocked something fascinating—a level of control over async operations that async/await simply couldn't provide. Little did I know that generators would become one of my favorite tools for handling complex asynchronous workflows.

Why Generators Excel at Async Control Flow

When I first encountered generators, I was guilty of dismissing them as "that weird function with the asterisk." I thought they were just for creating custom iterators. But generators are actually wonderful tools for controlling asynchronous operations in ways that give you fine-grained control over execution flow.

The key insight? Generators let you pause execution at any point and resume it later. When you combine this with promises, you get a powerful mechanism for coordinating async operations that goes beyond what async/await offers out of the box.

Think about scenarios where you need to:

  • Cancel an operation mid-flight based on external conditions
  • Coordinate multiple async operations with complex dependencies
  • Implement retry logic with configurable delays
  • Stream data progressively while maintaining backpressure

These are situations where generators shine. They give you the ability to yield control back to the caller at precise moments, allowing for sophisticated orchestration patterns.

Generator Fundamentals for Async Operations

Before we dive into async patterns, let's establish what makes generators special. A generator function is declared with function* and uses the yield keyword to pause execution. When you call a generator function, it returns an iterator object with a next() method.

Here's the pattern that changed how I think about async control:

function* asyncGenerator() {
  const result1 = yield Promise.resolve('First operation');
  console.log('Got:', result1);
  
  const result2 = yield Promise.resolve('Second operation');
  console.log('Got:', result2);
  
  return 'All done!';
}
 
// Manual execution (we'll improve this soon)
const gen = asyncGenerator();
 
gen.next().value.then(result1 => {
  gen.next(result1).value.then(result2 => {
    const final = gen.next(result2);
    console.log(final.value); // 'All done!'
  });
});

Notice what's happening here. Each yield pauses the generator and returns a promise. When we call next() with the resolved value, the generator resumes and that value becomes the result of the yield expression. This is the foundation of generator-based async control flow.

Generator async flow diagram

Building a Custom Async Runner with Generators

That manual execution pattern I showed you is clunky. Luckily we can build a runner function that automates the process. This is where generators become truly practical for async control flow:

function run<T>(generatorFn: () => Generator<Promise<any>, T, any>): Promise<T> {
  const generator = generatorFn();
  
  function handle(result: IteratorResult<Promise<any>, T>): Promise<T> {
    if (result.done) {
      return Promise.resolve(result.value);
    }
    
    return Promise.resolve(result.value)
      .then(
        value => handle(generator.next(value)),
        error => handle(generator.throw(error))
      );
  }
  
  try {
    return handle(generator.next());
  } catch (error) {
    return Promise.reject(error);
  }
}
 
// Now we can write clean async code
function* fetchUserData() {
  const user = yield fetch('/api/user').then(r => r.json());
  console.log('Fetched user:', user.name);
  
  const posts = yield fetch(`/api/users/${user.id}/posts`).then(r => r.json());
  console.log('Fetched posts:', posts.length);
  
  const comments = yield fetch(`/api/posts/${posts[0].id}/comments`).then(r => r.json());
  console.log('Fetched comments:', comments.length);
  
  return { user, posts, comments };
}
 
run(fetchUserData)
  .then(result => console.log('Complete:', result))
  .catch(error => console.error('Failed:', error));

When I finally decided to implement this pattern in a production codebase, I realized how much flexibility it provided. The generator function reads like synchronous code, but the runner handles all the promise orchestration behind the scenes.

Generators vs Async/Await: When to Use Each

You might be thinking "This looks similar to async/await, so why bother?" I was once guilty of thinking the same thing. But generators offer control that async/await doesn't.

With async/await, you're locked into the execution model. Once an async function starts, you can't pause it externally or inject values from outside. Generators give you that power.

Here's a practical example. Imagine you need to implement a task that can be cancelled mid-execution:

function* cancellableTask() {
  console.log('Starting long operation...');
  
  yield new Promise(resolve => setTimeout(resolve, 1000));
  console.log('Step 1 complete');
  
  yield new Promise(resolve => setTimeout(resolve, 1000));
  console.log('Step 2 complete');
  
  yield new Promise(resolve => setTimeout(resolve, 1000));
  console.log('Step 3 complete');
  
  return 'Task finished!';
}
 
class TaskRunner {
  private generator: Generator | null = null;
  private cancelled = false;
  
  start(generatorFn: () => Generator): Promise<any> {
    this.generator = generatorFn();
    this.cancelled = false;
    return this.execute();
  }
  
  cancel() {
    this.cancelled = true;
    console.log('Task cancelled');
  }
  
  private async execute(): Promise<any> {
    if (!this.generator) return;
    
    let result = this.generator.next();
    
    while (!result.done && !this.cancelled) {
      await result.value;
      result = this.generator.next();
    }
    
    if (this.cancelled) {
      return 'Cancelled';
    }
    
    return result.value;
  }
}
 
// Usage
const runner = new TaskRunner();
runner.start(cancellableTask);
 
// Cancel after 1.5 seconds
setTimeout(() => runner.cancel(), 1500);

Try implementing that level of control with async/await alone. You'd need external flags, checking them between operations, and complex coordination logic. Generators make it natural.

Generator control flow comparison

Real-World Async Control Flow Patterns

In other words, generators enable patterns that would be cumbersome with traditional async/await. Let me share some patterns I've found incredibly useful in production code.

Retry with Exponential Backoff:

Instead of wrapping every API call in retry logic, you can build a generator that handles retries transparently:

function* retryableRequest(url: string, maxRetries = 3) {
  let lastError;
  
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = yield fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return yield response.json();
    } catch (error) {
      lastError = error;
      console.log(`Attempt ${attempt + 1} failed, retrying...`);
      
      // Exponential backoff: 1s, 2s, 4s
      const delay = Math.pow(2, attempt) * 1000;
      yield new Promise(resolve => setTimeout(resolve, delay));
    }
  }
  
  throw lastError;
}

Throttled Batch Processing:

When processing large datasets, you often need to throttle operations to avoid overwhelming APIs or databases:

function* batchProcessor<T>(items: T[], batchSize: number, delayMs: number) {
  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    
    // Process batch
    const results = yield Promise.all(
      batch.map(item => processItem(item))
    );
    
    console.log(`Processed batch ${Math.floor(i / batchSize) + 1}`);
    
    // Delay before next batch (except for last batch)
    if (i + batchSize < items.length) {
      yield new Promise(resolve => setTimeout(resolve, delayMs));
    }
    
    yield results;
  }
}
 
// Process 1000 items in batches of 10, with 500ms between batches
const processor = batchProcessor(largeDataset, 10, 500);
run(processor);

Error Handling and Cleanup in Generator-Based Async Code

I cannot stress this enough! Error handling in generators requires special attention. When a promise rejects inside a generator, you need to properly propagate that error.

The beauty of our run() function from earlier is that it uses generator.throw(error) to pass rejected promises back into the generator as thrown exceptions. This means you can use standard try/catch blocks:

function* safeAsyncOperation() {
  try {
    const user = yield fetchUser();
    const posts = yield fetchUserPosts(user.id);
    return { user, posts };
  } catch (error) {
    console.error('Operation failed:', error);
    // Attempt fallback
    const cachedData = yield fetchFromCache();
    return cachedData;
  } finally {
    // Cleanup always runs
    yield closeConnections();
    console.log('Cleanup complete');
  }
}

The finally block is wonderful for cleanup operations that must run regardless of success or failure. This pattern gives you the same guarantees as try/finally in synchronous code, but with async operations.

When Generators Make Sense for Your Project

After working with generators for async control flow across multiple projects, I've developed some guidelines for when they're the right choice:

Use generators when:

  • You need external control over async execution (pause, resume, cancel)
  • You're implementing custom async patterns (retry, throttling, streaming)
  • You need fine-grained control over promise resolution order
  • You're building libraries or frameworks that orchestrate async operations

Stick with async/await when:

  • You have straightforward async sequences
  • You don't need external control over execution
  • Team familiarity with async/await is high and generator knowledge is low
  • The simpler syntax is more important than advanced control

I've found that mixing both approaches works wonderfully. Use async/await for standard async operations, and reach for generators when you need that extra level of control.

And that concludes the end of this post! I hope you found this valuable and look out for more in the future!