LEARN · DEBUGGING GUIDE

Debugging for-await-of Errors with Async Iterators

for-await-of loops silently fail when async iterators throw, reject, or terminate early. This guide walks you through the exact symptoms, root causes, and fixes for these subtle bugs.

AdvancedJavaScript8 min read

What this usually means

The for-await-of loop relies on the async iterator's next() method returning a promise that resolves to {value, done}. Common failure modes include: the async iterator protocol not being properly implemented (missing Symbol.asyncIterator or next returning malformed objects), the generator throwing before yielding (e.g., an async generator that throws synchronously), unhandled rejections inside the async generator that propagate as uncaught promise rejections instead of being caught by the loop, or the loop body itself throwing and breaking iteration without cleanup. Also, if the async iterator yields a rejected promise, for-await-of will catch it and throw, exiting the loop unless you have try/catch. Another subtle issue is forgetting to await inside the generator when calling async functions, leading to unexpected promise objects being yielded instead of resolved values.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Check the console for unhandled promise rejections – run your script with NODE_OPTIONS='--unhandled-rejections=strict' to turn them into thrown errors
  • 2Add a try/catch around the for-await-of loop and log the error to see if it's being swallowed
  • 3Verify the async iterator protocol: console.log(typeof asyncIterable[Symbol.asyncIterator]) should be 'function'
  • 4Insert a counter inside the loop to track how many iterations actually run versus expected
  • 5If using an async generator, add a console.log at the top of the generator function to confirm it's being called
  • 6Temporarily replace the async iterable with a simple array of promises to isolate the issue
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchThe async generator function definition (look for missing async keyword or throw before any yield)
  • searchThe object implementing Symbol.asyncIterator – ensure next() returns {value, done} promise
  • searchAny .catch() or try/catch inside the generator that might be swallowing errors
  • searchThe loop body – uncaught exceptions inside the loop break iteration
  • searchThe consumer code: ensure the iterable is being passed correctly (not a generator instance vs the generator function)
  • searchNode.js or browser console for unhandledrejection events
  • searchMemory heap snapshots if the loop appears to hang (infinite async generator without break)
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningAsync generator throws before the first yield (e.g., an unhandled promise rejection inside the generator)
  • warningMissing async keyword on the generator function – for-await-of expects a promise-based iterator
  • warningThe asyncIterable[Symbol.asyncIterator] is not a function – often because the object doesn't implement the protocol correctly
  • warningLoop body throws an error that is not caught, causing the loop to terminate early
  • warningAsync generator yields a promise that rejects – for-await-of unwraps it and throws
  • warningGenerator never yields (infinite loop or waiting on a never-resolving promise) causing the consumer to hang
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildWrap the generator body in try/catch and yield errors as special objects or log them instead of throwing
  • buildEnsure the generator function is declared with async function* and all async calls inside are awaited
  • buildImplement a proper async iterable by defining Symbol.asyncIterator that returns an object with a next() method returning a promise
  • buildAdd a try/catch around the for-await-of loop to handle any errors from the iterator or the body gracefully
  • buildUse a timeout or take(n) operator (e.g., with RxJS or custom logic) to limit iterations if the generator might be infinite
  • buildValidate the async iterable before the loop with a helper that checks for Symbol.asyncIterator
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedAdd a final console.log after the loop to confirm it completes
  • verifiedCount iterations with a counter and log the final count
  • verifiedRun the code with Node.js --unhandled-rejections=throw to ensure no silent rejections
  • verifiedWrite a unit test that collects all values from the async iterator into an array using an async function and assert the array length and contents
  • verifiedUse try/catch and log the error stack to see exactly where the failure occurs
  • verifiedTest with a known working async iterable (e.g., an array of promises) to confirm the loop itself works
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningAssuming for-await-of catches all errors – it only catches errors from the iterator protocol (next() rejections) and the loop body; unhandled rejections in the generator bypass the loop
  • warningForgetting that the generator function must be called to get the iterator – passing the function itself instead of calling it
  • warningUsing a regular generator (function*) with for-await-of – it will fail because it returns {value, done} synchronously, not wrapped in a promise
  • warningNot awaiting an async call inside the generator – the yielded value will be a promise object, not the resolved value
  • warningCatching errors inside the generator and not rethrowing or yielding – the loop will never see the error and may hang
  • warningAssuming the loop is the problem when it's actually the async iterable that's misconfigured
( 07 )War story

Silent Loop Exit with Async Generator Fetching Paginated API

Backend EngineerNode.js 18, Express, async generators, axios

Timeline

  1. 09:15Deploy service that fetches paginated data from external API using async generator
  2. 10:30Alert: user reports missing data – only first page processed
  3. 10:45Check logs: no errors, for-await-of loop runs exactly 3 iterations, expected >50
  4. 11:00Add console.log inside generator: generator called, yields first page, then second page, then error before third yield
  5. 11:05Identify: the API call inside generator sometimes returns a 500, and the error is not caught – generator throws
  6. 11:10Fix: wrap API call in try/catch inside generator, yield error object or break with {done: true}
  7. 11:15Redeploy and verify: loop processes all pages up to error, logs the error, continues next run

I had written an async generator that fetched pages from a paginated API and yielded each page. The consumer used for-await-of to iterate and process pages. Everything worked in dev with small datasets, but in production, the loop would exit after the first few pages without any error. I checked the logs – nothing. The loop just stopped.

I added logging inside the generator and found that after yielding the second page, the third API call threw an error (HTTP 500). But that error was never caught; it propagated as an unhandled promise rejection. Node.js 18 by default logs a warning but doesn't crash. The generator effectively threw, but the for-await-of loop saw that the next() promise rejected? Actually, it didn't – the generator threw before returning a promise, so the iterator's next() returned a rejected promise? I realized that the throw happened inside the async generator, which means the generator itself threw an exception, causing the iterator to return a rejected promise from next(). That rejection was caught by for-await-of, which then exited the loop. But because I had no try/catch around the loop, the error was lost.

The fix was to wrap the entire for-await-of loop in a try/catch and log the error. But better: inside the generator, I wrapped the API call in try/catch. If it fails, I yield an object with an error flag and then set done to true (or break the loop by returning). That way, the consumer can decide how to handle errors. I also added a global unhandledRejection handler to log any stray rejections. Lesson: never assume your async generator won't throw; always handle errors at the generator level or wrap the loop.

Root cause

Async generator threw an exception (from unhandled promise rejection) before yielding, causing the for-await-of loop to catch a rejected promise and exit without logging the error.

The fix

Wrapped the async generator logic in try/catch, yielding error objects or breaking iteration gracefully. Also added try/catch around the for-await-of loop to catch any remaining errors.

The lesson

Always handle errors inside async generators explicitly; for-await-of will catch rejections from next() but you lose context. Use try/catch in both generator and consumer.

( 08 )The Async Iterator Protocol and How for-await-of Works

for-await-of calls the async iterable's [Symbol.asyncIterator]() method to get an iterator. Then it repeatedly calls iterator.next(), which must return a promise that resolves to {value, done}. The loop awaits that promise and assigns value to the loop variable. If the promise rejects, the loop throws that error. If the loop body throws, the loop exits. This protocol is the source of many subtle bugs.

A common mistake is implementing next() incorrectly: it might return a promise that resolves to something other than an object with value and done, or it might return a non-promise. The loop will treat non-promise returns as a fulfilled promise with that value, leading to unexpected behavior. Also, if next() itself throws synchronously (not returning a promise), the loop will catch that and throw, but stack traces can be confusing.

( 09 )Unhandled Rejections in Async Generators

Inside an async generator, if you await a promise that rejects and you don't catch it, the generator function throws. This throw is converted into a rejected promise from the next() call. However, if you have no await (e.g., an unhandled synchronous throw), next() will also return a rejected promise. The for-await-of loop will catch that rejection and throw, ending the loop. But if you don't have a try/catch around the loop, the error propagates as an unhandled rejection from the loop's promise chain, which Node.js logs as a warning.

To avoid this, always use try/catch inside the async generator around any await calls. If an error occurs, decide whether to yield an error indicator or break the iteration. Additionally, attach a global process.on('unhandledRejection') or use --unhandled-rejections=throw in Node.js to turn warnings into errors.

( 10 )Debugging Techniques: Logging and Async Iteration Observability

The quickest way to debug for-await-of issues is to add logging at every step: before and after the loop, inside the generator at each yield, and inside the loop body. Use a wrapper that intercepts next() calls. For example, create a proxy around the async iterator that logs each call and its result. This gives you a clear picture of the iteration flow.

Another technique: convert the async iterator to an array using an async function that collects values. This function can have try/catch and logging. If that works but the for-await-of doesn't, the issue is likely in the loop body. If it doesn't work, the issue is in the iterator. This isolation saves time.

( 11 )Common Misconceptions: Regular Generators vs Async Generators

Using a regular generator (function*) with for-await-of will fail because next() returns {value, done} directly, not wrapped in a promise. The loop will treat that as a fulfilled promise, but the value will be the entire object, not the expected value. This often leads to 'undefined' values or weird behavior. Always ensure the generator is declared with async function*.

Similarly, if you have an object with a Symbol.asyncIterator method that returns a generator (not an async generator), the same problem occurs. The protocol requires next() to return a promise. If it returns a synchronous object, the loop will still work (because it wraps it in a promise), but the semantics change: the loop will not await the value, so if value is a promise, it won't be unwrapped. This is a common source of off-by-one errors.

Frequently asked questions

Why does for-await-of exit silently without an error when the async generator throws?

The throw inside the generator causes iterator.next() to return a rejected promise. for-await-of catches that rejection and throws it, exiting the loop. If you don't have a try/catch around the loop, the error becomes an unhandled promise rejection (logged as a warning in Node.js). To see it, wrap the loop in try/catch or use --unhandled-rejections=throw.

How do I implement a custom async iterable for use with for-await-of?

Your object must have a [Symbol.asyncIterator] method that returns an object with a next() method. next() must return a Promise that resolves to {value: any, done: boolean}. For example: { [Symbol.asyncIterator]() { return { next() { return Promise.resolve({value: 1, done: false}); } } } }. Ensure next() always returns a promise.

Can I use for-await-of with a regular (sync) iterator?

Technically yes, because for-await-of will wrap the iterator's next() return value in a promise. However, the loop will not await any promises inside the values; it will treat them as immediate values. This can lead to unexpected behavior if your values are promises. It's better to convert to async iterable explicitly.

What's the difference between catching errors inside the async generator and catching around the for-await-of loop?

Catching inside the generator allows you to control iteration flow: you can yield an error object and continue, or break. Catching around the loop catches any error from the iterator or the loop body, but it ends the loop. The best practice is to handle errors at the generator level for graceful degradation, and also wrap the loop for safety.

Why does my for-await-of loop hang indefinitely?

The most common cause is an async generator that never yields a {done: true} – either it has an infinite loop without a break, or it's awaiting a promise that never resolves. Check that your generator has a termination condition. Also, if the next() method never returns a promise that resolves (e.g., it returns a promise that never settles), the loop will hang. Use a timeout or limit iterations to debug.