skip to content

An async generator opens a database cursor before its first `yield` and closes it on the line after the yielding loop ends. If the consumer stops reading partway through, what actually runs inside the generator body, and how do you guarantee the cursor is closed?

level: seniorimportance: should knowfreq 34%

answer

  1. early exit unwinds, not continues
  2. code after the loop is skipped
  3. return() delivers a return completion
  4. try/finally is the only reliable hook
  5. abandoned generators run nothing at all

basics

~20 s

When a consumer stops early it calls the generator's return(), which resumes the body with a return completion at the suspended yield. Code after the loop never runs, so only a finally block around the yields closes the cursor — and abandoning the generator without return() runs nothing at all.

solid answer

~50 s

Stopping early does not resume the body at the next statement — it delivers a *return completion* at the suspended `yield`, exactly as if `return` had executed there. Everything after the yielding loop is therefore skipped, so a `cursor.close()` sitting on the line below leaks. The fix is `try { ... yields ... } finally { await cursor.close(); }`: a `finally` around the yields runs on normal completion, on a thrown error, and on early termination alike, and because the body is async you can await inside it. Two caveats matter in production. First, `return()` returns a promise; `for await...of` awaits it, but hand-rolled `next()` loops must call and await it themselves. Second, if a consumer simply stops calling `next()` and drops the reference, *nothing* runs — there is no finalizer and no GC hook — so the resource is held until the process exits.

code

javascript · 27 lines
javascript
const openCursor = async () => {
  const rows = [1, 2, 3];
  return {
    next: async () => rows.shift() ?? null,
    close: async () => console.log("closed"),
  };
};

async function* readRows(open) {
  const cursor = await open();
  try {
    for (;;) {
      const row = await cursor.next();
      if (row === null) break;
      yield row;
    }
  } finally {
    await cursor.close();
  }
}

(async () => {
  for await (const row of readRows(openCursor)) {
    console.log(row); // 1
    break;            // then: closed
  }
})();

go deeper

for a junior

Remember the rule of thumb: anything an async generator must release goes in a finally block wrapping the yields, never on a line after the loop.

for a middle

Explain the mechanism — early termination resumes the suspended yield with a return completion, so the body unwinds from that point and only enclosing finally blocks execute.

for a senior

Demonstrate production judgment: manual next() drivers must call and await return() themselves, an abandoned generator runs nothing at all, and a throwing cleanup can mask the original error.

for a principal

Own the design rule for shared code: decide whether a long-lived resource belongs inside a generator at all, and what explicit shutdown path your API offers so cleanup is not left to consumer discipline.

## What "stopping early" does to the body An async generator suspended at `yield` is waiting to be resumed, and there are three ways to resume it. `next(v)` resumes normally, making the `yield` expression evaluate to `v`. `throw(e)` resumes with a *throw completion*, as if `throw e` sat at the yield point. `return(v)` resumes with a *return completion*, as if `return v` sat there. Early termination is the third case. When a `for await...of` loop exits by `break`, by `return`, or because the loop body threw, it calls the iterator's `return()` method if one exists. So the generator does not continue past the yield — it unwinds from it. That is why cleanup placed *after* the loop is unreachable: ```js async function* rows(open) { const cursor = await open(); for (;;) { const row = await cursor.next(); if (row === null) break; yield row; // consumer breaks -> unwinds from here } await cursor.close(); // never reached on early exit } ``` ## finally is the only reliable place A return completion unwinds through the body exactly like a `return` statement does, which means enclosing `finally` blocks run. Put acquisition before the `try` and release in the `finally`: ```js async function* rows(open) { const cursor = await open(); try { for (;;) { const row = await cursor.next(); if (row === null) break; yield row; } } finally { await cursor.close(); // normal end, early exit, and thrown error alike } } ``` This single block covers all three exits: the loop finishing, the consumer abandoning it, and an exception from the fetch or from the consumer's body. `await` is legal in that `finally` because the body is an async function body, so asynchronous release — closing a connection, flushing a buffer, releasing a lock — is expressible. In a sync generator you have no such option. ## The promise nobody awaits `return()` is asynchronous: it hands back a promise that settles once the body has finished unwinding, including any awaits inside `finally`. `for await...of` awaits that promise before the loop statement completes, so the cleanup is genuinely done before execution continues past the loop. A hand-written driver does not get that for free: ```js const it = rows(open); try { while (true) { const { value, done } = await it.next(); if (done || found(value)) break; } } finally { await it.return(); // you must do what for await...of does for you } ``` Skipping this is a common leak in code that drives the iterator manually to interleave other work. ## The failure mode with no remedy in the language If a consumer neither exhausts the generator nor calls `return()` — it just stops calling `next()` and lets the reference go — the body stays suspended at its yield forever and the `finally` block never runs. There is no finalizer, no destructor, and no garbage-collection hook that will run it: collecting the generator object reclaims memory, not the cursor it was holding. `FinalizationRegistry` exists but is explicitly not guaranteed to run, so it is not a cleanup mechanism. The practical consequences: never write a generator that holds an expensive, limited resource open across yields if you cannot trust every consumer to terminate it properly. Where you can, prefer designs that let the caller stop the producer explicitly — passing in an abort signal, or exposing an explicit close — so shutdown is a first-class path rather than an accident of iteration. ## Edge behaviour worth knowing A `yield` inside the `finally` block delays completion: the generator is honouring the return request but is still able to emit, so the return does not take effect until the finally block finishes. This is legal, occasionally useful for emitting a final summary, and easy to get wrong — most cleanup blocks should not yield. Also note that whatever the `finally` block throws replaces the outcome of the original exit, which can mask the real error. If release can plausibly fail, catch and log inside the `finally` rather than letting a close error bury the exception that caused the unwind.

  • Does garbage collection eventually run the finally block of an abandoned async generator?
    No. Reclaiming the generator object frees its memory; it does not resume the suspended body, so `finally` never executes and any external resource stays held. `FinalizationRegistry` callbacks are explicitly not guaranteed to run and must never be used as a cleanup mechanism. The only reliable paths are exhausting the generator or calling `return()`.
  • What happens if the finally block itself throws while the generator is unwinding?
    The exception from `finally` replaces the completion that was in progress, so a failing `close()` can bury the original error that caused the unwind — the consumer sees the cleanup failure instead of the real cause. If release can plausibly fail, wrap it in its own try/catch inside the `finally` and log rather than rethrow.
  • Is it legal to yield inside the finally block, and what does that do?
    It is legal, and it defers completion: the generator has received the return request but is still allowed to emit, so the return does not take effect until the `finally` block finishes. It occasionally serves to emit a final summary, but it surprises consumers who expect the sequence to be over, so keep cleanup blocks free of yields unless you mean it.

saying these in an interview costs you the question

  • Puts close() after the yielding loop
  • Expects garbage collection to run finally
  • Thinks break resumes the body at the next statement
  • Forgets to call return() in a manual next() loop
  • Assumes async cleanup cannot be awaited in finally

context