Appearance
Resolve All Promises
Prevent event loop hangs from unresolved promises in Node.js.
Problem & Solution
Fire-and-forget async functions leave pending promises that prevent the Node.js event loop from completing. This surfaces as:
Promise resolution is still pending but the event loop has already resolvedCommon sources: background for-await loops, event listener handlers, stream processing, setTimeout/setInterval callbacks wrapped in promises, and async iterator consumption.
The fix: track every promise created by background async work, signal completion explicitly in producer/consumer patterns, and await everything during cleanup.
Pattern
Always Track and Await Background Async Work
Any async function running in the background MUST have its promise stored and awaited before the process or test exits.
❌ DON'T launch an async IIFE without storing its promise
javascript
(async () => {
for await (const event of eventSource) {
handleEvent(event);
}
})();✅ DO store the promise and await it during cleanup
javascript
const promise = (async () => {
for await (const event of eventSource) {
handleEvent(event);
}
})();
// During cleanup:
await promise;This applies equally to event listeners, stream consumers, and any other async work kicked off without an immediate await:
❌ DON'T ignore promises created inside event listeners
javascript
stream.on("data", async (chunk) => {
await processChunk(chunk);
});✅ DO collect each promise and await them all before exiting
javascript
const pending = [];
stream.on("data", async (chunk) => {
pending.push(processChunk(chunk));
});
// During cleanup:
await Promise.all(pending);Signal Completion in Producer/Consumer Patterns
Any async producer that emits a finite number of values must explicitly signal when it's done. Without a completion signal, consumers wait indefinitely and the event loop dies.
❌ DON'T rely on implicit completion in manual push/pull protocols
javascript
async function* generate() {
yield value1;
yield value2;
// Function ends but consumer may not know iteration is complete
// if the generator is wrapped in a manual push/pull protocol
}✅ DO send an explicit completion signal so consumers can exit
javascript
async function* generate() {
yield value1;
yield value2;
// Generator return signals completion automatically,
// but manual protocols need explicit signals (see Repeater note below)
}Clean Up in the Right Order
When stopping background async work, follow this sequence:
- Store the promise returned by the background work
- Signal the source to stop producing (cancel, close, return, etc.)
awaitthe stored promise to ensure consumption fully completes
javascript
const promise = consumeEvents(source);
// During cleanup:
source.close(); // Signal stop
await promise; // Wait for consumer to finishSkipping step 3 is the most common mistake — signaling stop is not enough, you must also wait for the consumer to observe the stop and exit.
Repeater-Specific Considerations
The @repeaterjs/repeater library intentionally does NOT auto-stop when the executor completes (see repeaterjs/repeater#75, #49). This makes the general rules above especially critical for Repeaters.
Finite Repeaters Must Call stop()
Without an explicit stop() call, the Repeater stays "open" indefinitely, blocking the event loop.
❌ DON'T omit stop() — the Repeater stays open and the event loop dies
javascript
new Repeater(async (push, stop) => {
push(value);
await stop; // Never resolves without stop() call
});✅ DO call stop() after the final push() so the consumer knows iteration is done
javascript
new Repeater(async (push, stop) => {
push(value1);
push(value2);
stop(); // Signal completion
await stop; // Wait for consumer acknowledgment
});Repeater Cleanup Sequence
javascript
const promise = forEach(repeater, value => handleValue(value));
// During cleanup:
await repeater.return(); // Signal stop
await promise; // Wait for loop to exitExample: debounce() Fix
This real fix in util/iter.js added an explicit stop() call after the debounce loop completes, resolving event loop death in tests:
javascript
export function debounce(wait = 0) {
return function (iterator) {
return new Repeater(async (push, stop) => {
let timeout = null;
const loopPromise = (async () => {
for await (const value of iterator) {
clearTimeout(timeout);
timeout = setTimeout(() => push(value), wait);
}
if (timeout) {
await new Promise(resolve => setTimeout(resolve, wait));
}
stop(); // CRITICAL: Signal completion
})();
await stop;
clearTimeout(timeout);
await loopPromise;
});
}
}This function demonstrates all three rules:
- The
for-awaitloop's promise is stored inloopPromise stop()is called after the loop finishesloopPromiseisawaited during cleanup
Checks
- [ ] Every background async function's promise is tracked and awaited
- [ ] Finite producers explicitly signal completion
- [ ] Cleanup awaits both the stop signal and the consumption promise
- [ ] No fire-and-forget
(async () => { ... })()
References
repeaterjs/repeater#75— Event loop can die ifstop()not calledrepeaterjs/repeater#49— Should theRepeaterautomatically stop when its executor fulfills?