Generator Leak in Redux-Saga — Cancel to Avoid OOM
Linear memory growth from an uncanceled generator crashes tabs.
- Generators are functions that can pause execution with
yieldand resume later via.next(). - They return an iterator object; state (variables, call stack) is preserved between calls.
function*declares a generator;yieldsends a value out;.next(value)sends a value in.- Key components: generator function, iterator protocol (
next()),done/valueobjects,yield*for delegation. - Performance insight: lazy evaluation reduces memory — compute one value at a time instead of loading entire dataset.
- Production insight: first
.next()call ignores injected values — passing data there silently fails and wastes debugging time. - Biggest mistake: treating generators like regular functions — they do not execute until
.next()is called.
Imagine you're reading a recipe book and you've placed a bookmark at step 3 so you can walk away, grab an ingredient, and come back to exactly where you left off. A JavaScript generator is that bookmark — a function that can pause itself mid-execution, hand a value back to whoever called it, and then resume from the exact same spot when told to. Unlike a normal function that runs start-to-finish in one shot, a generator says 'here's one result for now — come back when you want the next one.'
Most JavaScript functions are all-or-nothing: they start, they compute, they return, and they're gone. That works perfectly fine for most tasks, but modern applications constantly deal with problems that are inherently sequential and potentially infinite — paginated API calls, real-time data streams, complex async workflows, and lazy computation pipelines that would blow memory if evaluated all at once. Generators were introduced in ES6 precisely to solve these problems, yet they remain one of the most underused — and misunderstood — features in the language.
The core problem generators solve is control flow ownership. With a regular function, the caller has no say in when the function pauses. With a generator, the function itself decides when to yield control back, and the caller decides when to resume it. This bidirectional communication channel — values flowing out via yield, values flowing in via next(value) — creates a cooperative multitasking primitive that underpins async/await itself under the hood, powers libraries like Redux-Saga, and enables memory-efficient data pipelines that would otherwise require loading entire datasets into memory.
By the end of this article you'll understand exactly how the generator execution model works at the V8 level, how to use yield* for generator composition, how to pass values back into a running generator, how generators relate to iterators and the Symbol.iterator protocol, and the real gotchas that bite engineers in production. You'll also have concrete patterns you can drop into a codebase today.
The Anatomy of a Generator: Pausing Execution
A generator is defined using the function* syntax. When called, it doesn't execute its body immediately; instead, it returns an Iterator object. This object conforms to both the iterable and iterator protocols. The actual execution only happens when you call .next(). At every yield keyword, the function's state (including variables and the call stack) is 'frozen' in memory, only to be thawed when the next call arrives.
.next() to completion, the frame stays in memory..next()..return().Two-Way Communication: Passing Values In
Generators are not one-way streets. The .next(value) method allows the caller to 'inject' a value back into the generator at the exact point where it was previously paused. This value becomes the result of the yield expression inside the generator body. This bidirectional flow is what makes libraries like Redux-Saga capable of handling complex side effects as if they were synchronous code.
.next() call. That value is ignored. Always structure your generator so the first yield is a pure output, and the second .next() starts the input cycle..next() is a silent no-op — the engine discards it.yield that accepts no input, then pass values on subsequent calls..next() call starts the generator — it cannot receive data.yield expression evaluates to the value passed to the next .next()..next(value) to communicate back into the generator for interactive sequences.Advanced Pattern: Generator Composition with yield*
In a production environment, you often need to delegate execution from one generator to another. The yield* expression allows a generator to delegate to another iterable object (like another generator, an array, or a string), flattening the structure automatically.
for...of and yield each value, but yield* is more concise and maintains the caller's ability to pass values into the sub-generator via .next().yield* delegation creates a new nested iterator — errors inside the delegated generator propagate up.yield* in a try/catch if the delegated generator can throw.yield* flattens nested iterables into a single sequence.Async Generators: Handling Streams of Async Data
When you combine generators with Promises, you get async function* — an async generator. Each yield can produce a Promise, and the consumer uses for await...of to pause until each Promise resolves. This is the standard pattern for streaming data from APIs, reading files line by line with Node.js streams, or processing paginated results without loading everything into memory.
await inside the generator — but the generator itself returns an async iterator.next() call returns a Promise, so the consumer must always use for await...of or manually await.await before yield in an async generator — you'll yield a pending Promise instead of its resolved value.async function* produces an async iterable — use for await...of to consume.yield in an async generator should be await-ed if it depends on an async operation.next() call.Error Handling and Generator Lifecycle Management
Generators support error injection via .throw(error). This allows the caller to raise an exception inside the generator at the current pause point. The generator can catch it with try/catch and decide to continue or abort. Additionally, .return(value) terminates the generator early, while .return() without arguments causes done: true with value: undefined. Proper lifecycle management — ensuring generators are closed when no longer needed — is critical to avoid memory leaks in long-lived applications.
null to the iterator reference after finishing to allow garbage collection..throw() on a generator that has already completed (done: true), the error propagates immediately to the caller.done before throwing..throw() with a check like if (!gen.next().done) gen.throw(err); or handle errors in a wrapper..throw(error) to inject errors into a generator — it's like a synchronous try/catch for two-way control flow..return(value) terminates the generator cleanly, setting done: true.Generator Leak in Redux-Saga Background Task
takeEvery and never yielded to a cancellation effect like take('LOGOUT'). The generator continued running indefinitely, holding references to large objects (WebSocket message buffers) that prevented garbage collection. The generator's internal state (closed-over variables) kept the entire closure chain alive.redux-saga's race effect to listen for a LOGOUT action and cancel the generator via cancel() when it fires, or use takeLatest which automatically cancels previous instances.- Generators hold strong references to their local scope — they are not garbage-collected until the iterator is exhausted or explicitly returned.
- Always provide a cancellation path for long-running sagas. Use
race,takeLatest, or a dedicated cancel channel. - Memory profiling in production should check for active generator frames — they're invisible in typical heap snapshots unless you inspect closures.
.next() call — that value is silently ignored. Log every .next() invocation with its argument and the returned {value, done}.yield or a return. A while(true) without a yield will block the event loop. Add a debug yield to inspect state.try/catch. Use generator.throw(error) to inject an exception at the pause point — the generator can catch it and continue.for await...of to consume them. Check that the yield expression is await-ed inside async function*.console.log and check if the first line of the generator body prints.Key takeaways
.next().async/await (which is essentially a generator wrapped in a promise-runner).yield* for clean, modular generator composition.Common mistakes to avoid
5 patternsUsing generators as constructors
new myGenerator() throws a TypeError because generator functions are not constructible.new keyword with a generator function. Call it normally: const gen = myGenerator().Forgetting the first .next() limitation
.next() call — the value is silently ignored, causing unexpected undefined when you expected it to be received..next() without arguments. Pass the initial value to a parameter of the generator function instead, or use a second .next() call.Overusing generators for simple logic
for...of or Array.map().Exhausting the iterator and caching it
.next() after the generator is done returns { value: undefined, done: true }. If you cache the generator reference, subsequent usage yields no values.done before each .next() call. Use [...gen] for one-off consumption.Forgetting to await yields in async generators
for await...of.await the result of async operations before yielding: yield await fetch(url).Interview Questions on This Topic
Explain the Iterator Protocol and how generators implement it implicitly.
next() method that returns { value, done }. Generators implement this protocol implicitly — every generator returns an object with a next() method that conforms to the protocol. Additionally, the generator object also has a [Symbol.iterator] method, making it iterable. This dual conformance is why you can spread a generator with [...gen] and use it in for...of loops.Frequently Asked Questions
That's Advanced JS. Mark it forged?
3 min read · try the examples if you haven't