Skip to content

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 resolved

Common 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:

  1. Store the promise returned by the background work
  2. Signal the source to stop producing (cancel, close, return, etc.)
  3. await the stored promise to ensure consumption fully completes
javascript
const promise = consumeEvents(source);

// During cleanup:
source.close();    // Signal stop
await promise;     // Wait for consumer to finish

Skipping 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 exit

Example: 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-await loop's promise is stored in loopPromise
  • stop() is called after the loop finishes
  • loopPromise is awaited 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