“This generator opens a file and yields lines. What happens to the file handle if the caller breaks out of the loop after the first line?” Interviewers use generator cleanup to test whether you understand that generators are paused functions with a lifecycle, not just a fancy way to build arrays. The question also probes the iterator protocol, because the answer depends on for...of calling a method most developers never call directly.
A candidate who answers well can explain when finally runs, when it does not, and how the same rules carry over to async generators reading from streams or paginated APIs.
Before you start
You should know function* syntax, what yield does, and how for...of consumes iterables. Understanding try/catch/finally order of execution is essential. The async section assumes you are comfortable with async/await.
The short answer
When a for...of loop exits early through break, return or a thrown exception, it calls the iterator’s return() method. For a generator, return() resumes the paused function as if a return statement had executed at the current yield, so any enclosing finally blocks run before the generator finishes. Put cleanup in try/finally around the yields. The exception is a generator that was never started: there is no paused frame, so return() simply marks it done and its finally never runs.
How it works
A generator object has three methods: next(value) resumes it and sends a value in, throw(error) resumes it by throwing at the paused yield, and return(value) resumes it by performing a return at the paused yield. Return and throw both unwind through try/finally exactly like normal control flow.
const events = [];
function* lines() {
events.push('open');
try {
yield 'line 1';
yield 'line 2';
yield 'line 3';
} finally {
events.push('close');
}
}
for (const line of lines()) {
events.push(line);
if (line === 'line 1') break;
}
console.log(events); // [ 'open', 'line 1', 'close' ]The break triggers return() on the generator, which resumes it at yield 'line 1' with a return completion. That unwinds into the finally, logs close, and the generator is finished. Destructuring does the same thing: const [first] = lines() takes one value and then calls return().
finally can even override the outcome. If the finally block itself contains a yield, the generator pauses again inside cleanup, and return() hands back that yielded value with done: false. This is legal but confusing; keep yield out of finally blocks.
Step-by-step walkthrough
Step 1: Drive the generator by hand
Calling the methods yourself shows exactly what for...of does behind the scenes.
function* counter() {
try {
yield 1;
yield 2;
} finally {
console.log('cleanup');
}
}
const it = counter();
console.log(it.next()); // { value: 1, done: false }
console.log(it.return('bye'));
// cleanup
// { value: 'bye', done: true }
console.log(it.next()); // { value: undefined, done: true }Step 2: Notice the never-started case
A generator body does not run until the first next(). If you call return() before that, there is nothing to unwind. Any resource acquired at the top of the body was never acquired, so this is consistent, but it surprises people who expect finally to always run.
function* guarded() {
try {
yield 1;
} finally {
console.log('finally ran');
}
}
const fresh = guarded();
console.log(fresh.return('early')); // { value: 'early', done: true }Nothing else prints. The lesson: acquire resources inside the generator body, inside or just before the try, never in the code that creates the generator.
Step 3: Handle errors thrown into the generator
When the loop body throws, for...of still calls return() (not throw()) before propagating the error, so cleanup runs and the caller sees the original exception. Calling it.throw(err) directly is different: the error appears at the yield, where a catch inside the generator can handle it.
const log = [];
function* source() {
try {
yield 'a';
yield 'b';
} catch (e) {
log.push(`caught ${e.message}`);
yield 'recovered';
} finally {
log.push('cleanup');
}
}
try {
for (const v of source()) {
log.push(v);
throw new Error('consumer failed');
}
} catch (e) {
log.push(`outer ${e.message}`);
}
console.log(log); // [ 'a', 'cleanup', 'outer consumer failed' ]
const it = source();
it.next();
console.log(it.throw(new Error('boom'))); // { value: 'recovered', done: false }Step 4: Apply the same rules to async generators
Async generators work the same way, except return() returns a promise, and for await...of awaits it. Cleanup can therefore be asynchronous: closing a database cursor, releasing a lock, or cancelling an HTTP request.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const log = [];
async function* pages() {
log.push('connect');
try {
for (let page = 1; ; page++) {
await sleep(5);
yield page;
}
} finally {
await sleep(5);
log.push('disconnect');
}
}
for await (const page of pages()) {
log.push(`page ${page}`);
if (page === 2) break;
}
console.log(log); // [ 'connect', 'page 1', 'page 2', 'disconnect' ]The loop does not continue past break until the async cleanup finishes, so the disconnect entry is in place before the log prints.
Worked scenario
A CSV importer wrapped a pooled database connection in a generator. The caller stopped after finding the first invalid row. Over a day, the pool ran dry because connections were acquired at generator creation time and released only after the final row.
const pool = { inUse: 0, acquire() { this.inUse++; return { release: () => this.inUse-- }; } };
function rowsBroken(rows) {
const conn = pool.acquire();
function* gen() {
for (const r of rows) yield r;
conn.release();
}
return gen();
}
for (const row of rowsBroken(['ok', 'bad', 'ok'])) {
if (row === 'bad') break;
}
console.log(pool.inUse); // 1Two things went wrong: the release ran only on normal completion, and acquisition happened outside the generator body, so even a finally would not have covered the case where the generator was created but never iterated. The fix moves both into the body.
const pool = { inUse: 0, acquire() { this.inUse++; return { release: () => this.inUse-- }; } };
function* rows(list) {
const conn = pool.acquire();
try {
for (const r of list) yield r;
} finally {
conn.release();
}
}
for (const row of rows(['ok', 'bad', 'ok'])) {
if (row === 'bad') break;
}
const unused = rows(['ok']);
console.log(pool.inUse); // 0Created-but-unused generators no longer hold anything, because the body has not run. A broken loop triggers return(), which runs finally. A thrown exception in the consumer also runs it.
Common mistake
- “
finallyin a generator always runs.” Not if the generator was never started, and not if the consumer simply stops callingnext()without callingreturn(); a manually driven iterator that is abandoned is just garbage collected, with no cleanup. - “
breakthrows an exception into the generator.” It callsreturn(), which performs a return completion, socatchblocks are skipped and onlyfinallyruns. - Acquiring resources before the generator body. Code outside
function*runs eagerly and is not covered by the generator’sfinally. - Spreading or
Array.fromon an infinite generator. Those consume untildone, so they never finish. Take what you need withfor...ofandbreak, or with iterator helpers like.take(n)in runtimes that support them. - Yielding inside
finally. It pauses cleanup and makesreturn()reportdone: false, which confuses every consumer.
Verify the behavior
This test covers the four exit paths: normal completion, break, an exception in the consumer, and a never-started generator.
import test from 'node:test';
import assert from 'node:assert/strict';
function tracked() {
const state = { closed: 0 };
function* gen() {
try { yield 1; yield 2; } finally { state.closed++; }
}
return { state, gen };
}
test('cleanup runs on completion, break and throw', () => {
const { state, gen } = tracked();
for (const _ of gen());
for (const _ of gen()) break;
assert.throws(() => { for (const _ of gen()) throw new Error('x'); });
assert.equal(state.closed, 3);
});
test('never-started generator skips finally', () => {
const { state, gen } = tracked();
gen().return();
assert.equal(state.closed, 0);
});Run it with node --test generators.test.mjs; both tests pass.
Follow-up questions
Can a generator refuse to stop when return() is called? Its finally can yield again, which reports done: false. A well-behaved generator should not do this.
What does return() return? { value, done: true } with the value you passed, unless finally executes its own return, which overrides it.
Do iterators that are not generators get cleanup? Only if they implement an optional return() method. for...of calls it when present; plain iterators without it get no notification.
How does this relate to using declarations? Explicit resource management (using and Symbol.dispose) is a newer proposal for scoped cleanup. Generators with try/finally remain the portable way to tie cleanup to iteration.
Interview exercise
Predict the output, then explain each line.
function* g() {
try {
yield 1;
yield 2;
} catch (e) {
console.log('catch', e);
} finally {
console.log('finally');
}
}
const [x] = g();
console.log('x =', x);
const it = g();
it.next();
it.throw('oops');
// finally
// x = 1
// catch oops
// finallyAnswer and reasoning
Array destructuring pulls one value through the iterator protocol, then calls return() because it does not need more. That resumes the generator at yield 1 with a return completion, which skips catch and runs finally, so finally prints before x = 1. The second generator is advanced to yield 1, and it.throw('oops') resumes it by throwing at that point. Now the catch handles it and prints catch oops, then finally runs as control leaves the try statement. The generator then completes, so throw() returns { value: undefined, done: true } rather than rethrowing. The key distinction interviewers want: return() skips catch, throw() can be caught.
Continue learning
Browse more JavaScript interview questions and test yourself with the JavaScript MCQs. Related notes: the iterator protocol and async iteration over streams. Official reference: Generator.prototype.return on MDN.