skip to content

An async generator throws — or awaits a promise that rejects — after having already yielded two values. Where does that error surface for the consumer, and can the generator still produce values afterwards?

level: middleimportance: should knowfreq 32%

answer

  1. rejects the next() promise that resumed it
  2. already-yielded values are unaffected
  3. finally runs before the rejection lands
  4. completion is terminal, not poisoned
  5. throw() pushes an error the other way

basics

~20 s

The error rejects the promise returned by the next() call that resumed the body, so a for await...of loop throws at that iteration. The generator then moves to the completed state permanently: later next() calls fulfil with {value: undefined, done: true} rather than rethrowing.

solid answer

~50 s

The exception rejects the promise from the `next()` call that resumed the body — the two already-yielded values were delivered normally, and the failure arrives at the third pull. With `for await...of` that shows up as a throw at the loop, catchable by a `try`/`catch` around it. An `await` of a rejected promise inside the body is the same thing: an unhandled rejection there is a throw. Any enclosing `finally` runs before the rejection is delivered, so cleanup still happens. After that the generator is **completed**, and completion is permanent: subsequent `next()` calls fulfil with `{ value: undefined, done: true }` — they do not rethrow and they cannot resume the body. The error is therefore delivered exactly once, to whoever was pulling at that moment. Separately, `it.throw(err)` injects an error *at* the suspended yield, which the body's own `try`/`catch` can absorb and carry on from.

code

javascript · 19 lines
javascript
async function* g() {
  try {
    yield 1;
    throw new Error("boom");
  } finally {
    console.log("cleanup");
  }
}

(async () => {
  const it = g();
  console.log(await it.next());   // { value: 1, done: false }
  try {
    await it.next();              // logs "cleanup", then rejects
  } catch (e) {
    console.log("caught", e.message); // caught boom
  }
  console.log(await it.next());   // { value: undefined, done: true }
})();

go deeper

for a junior

Know that an error inside an async generator reaches the consumer as a rejected next(), which means a plain try/catch around the for await...of loop catches it.

for a middle

Explain the state change: enclosing finally blocks run, the rejection is delivered once to the pending next(), and the generator then reports done forever rather than rethrowing.

for a senior

Address the operational consequence — the consumer is left partway through with work committed, so it needs a policy for partial progress, and every next() promise created must be observed.

for a principal

Own the contract: whether your streaming API should fail positionally or expose per-item results, and how consumers checkpoint and resume rather than restarting a long stream from zero.

## Where the error goes An async generator produces one result per resumption, and the vehicle for each result is the promise that `next()` returned. A successful resumption fulfils that promise with `{ value, done }`. A resumption whose body throws **rejects** it with the thrown value. Nothing else changes: the two values already yielded were already delivered and are unaffected. ```js async function* g() { yield 1; yield 2; throw new Error("boom"); } const it = g(); await it.next(); // { value: 1, done: false } await it.next(); // { value: 2, done: false } await it.next(); // rejects with Error: boom ``` Because `for await...of` awaits each `next()`, a rejection surfaces as a throw *at the loop statement*, at the position where that item would have appeared: ```js try { for await (const n of g()) console.log(n); // 1, 2 } catch (e) { console.log("caught", e.message); // caught boom } ``` A rejected `await` inside the body behaves identically — in an async function body, an unhandled rejection *is* a throw, so `await failingCall()` and `throw new Error()` reach the consumer by the same path. ## Cleanup still runs The throw unwinds the body, so enclosing `finally` blocks execute before the rejection is delivered to the consumer. That ordering matters: by the time the consumer's `catch` runs, the generator has already released whatever its `finally` released. If the `finally` itself throws, its exception replaces the original one, which can hide the real cause — a good reason to catch and log inside cleanup rather than let a failing `close()` propagate. ## Completion is permanent Once an error escapes the body, the generator's state becomes *completed*, exactly as if it had returned. That state is terminal: ```js await it.next(); // rejects with boom await it.next(); // { value: undefined, done: true } — no rethrow ``` The error is delivered **once**, to whoever was pulling at that moment. A second consumer, or the same consumer retrying, sees a quietly exhausted iterator rather than the failure. This surprises people who expect a "poisoned" iterator to keep reporting its error. If the failure must be observable more than once, the caller has to record it — the generator will not repeat itself. The same terminality applies after a normal `return` and after `return()` was used to terminate early: once completed, `next()` always fulfils with `{ value: undefined, done: true }`. ## Errors travelling the other way: throw() The async iterator protocol also lets the *consumer* push an error into the producer. `it.throw(err)` resumes the suspended body with a throw completion at the `yield` point, so the generator's own `try`/`catch` can handle it: ```js async function* resilient() { while (true) { try { yield await fetchNext(); } catch (e) { // consumer-injected or fetch failure: log and keep going } } } ``` If the body catches it, the generator continues and `throw()`'s promise fulfils with whatever it yields next. If the body does not catch it, the error propagates out of `throw()`'s promise and the generator completes. This is the mechanism behind "tell the producer that downstream failed" designs, and it is why a body that wraps its yields in `try`/`catch` can be made resilient to consumer-side failures. ## Partial consumption is the real design question The practical consequence of positional error delivery is that a consumer can be left half-way through a sequence with work already committed. Two values were processed; the third failed. Unlike a promise of a whole array, which fails atomically, streaming makes partial progress the normal case, so the consumer needs a policy: discard the partial work, checkpoint how far it got and resume from there, or treat each item as independently committed. Deciding this is part of choosing a streaming producer in the first place — it is not something the generator can decide for you. One more trap: if you create a generator, pull from it, and drop it after a rejection without observing that rejection — for example by starting a `next()` you never await — you get an unhandled rejection report. Every `next()` promise you create is a promise someone must handle.

  • How does the consumer catch that error when driving the generator with for await...of?
    Wrap the loop in `try`/`catch`. Because the loop awaits each `next()` promise, a rejection is thrown at the loop statement itself, at the position where that item would have appeared — so an ordinary synchronous-looking `try { for await (...) {...} } catch (e) {}` around it catches it, along with anything the loop body threw.
  • What does calling it.throw(err) do that differs from the body throwing on its own?
    It injects the error *at* the suspended `yield`, inside the body, so the generator's own `try`/`catch` can absorb it and keep producing. A body that wraps its yields in try/catch survives a consumer-injected error; one that does not lets it propagate out of `throw()`'s promise and completes. The direction of travel is consumer to producer.
  • After a rejection, why does a retry loop that calls next() again see done instead of the error?
    Because completion is terminal. An escaped error ends the generator exactly as a return would, and a completed generator answers every `next()` with `{ value: undefined, done: true }`. The error was delivered once, to whoever was pulling. To retry you must construct a fresh generator; to report the failure twice, the caller has to record it.

saying these in an interview costs you the question

  • Expects every later next() to rethrow the error
  • Thinks a body throw skips the finally block
  • Believes previously yielded values are rolled back
  • Assumes the generator can resume after an escaped error
  • Says try/catch around for await cannot catch producer errors

context