Skip to content

Debouncing Async Iterables

Delay values from an async iterable until a quiet period has passed. Only the most recent value within each burst is yielded. Uses Repeater to wrap the source iterator with cancellable timers.

When to Use

  • Debouncing file watcher events to avoid redundant rebuilds
  • Throttling user input streams (search-as-you-type)
  • Coalescing rapid state changes before processing
  • Any async event source where bursts should collapse to a single value

The Pattern

Debounce file watcher events

javascript
import { fromEvent } from "./iter.js";

const changes = fromEvent(watcher, "change");
const debounced = debounce(300)(changes);

for await (const file of debounced) {
  await rebuild(file);
}

Compose with other iterators

javascript
const source = fromEvent(input, "input");
const debouncedInput = debounce(250)(source);

for await (const event of debouncedInput) {
  await search(event.target.value);
}

Test debounce behavior

javascript
const values = new Repeater(async (push, stop) => {
  push(1); await delay(5);
  push(2); await delay(5);
  push(3); await delay(5);
  push(4); await delay(5);
  push(5); await delay(500);
  push(6); await delay(500);
  stop();
});

const debounced = debounce(250)(values);
const results = [];
for await (const value of debounced) {
  results.push(value);
}
// results: [5, 6]
// Values 1-4 arrived in rapid succession, only 5 survived
// Value 6 arrived after a long gap, so it was emitted too

Curried debounce function

Accept a wait time and return a function that transforms an async iterable into a debounced one:

javascript
import { Repeater } from "@repeaterjs/repeater";

export function debounce(wait = 0) {
  if (typeof wait !== "number") {
    throw new TypeError("wait must be a number");
  }
  if (wait < 0) {
    throw new RangeError(
      "wait must be a non-negative number"
    );
  }
  
  return function (iterator) {
    return new Repeater(async (push, stop) => {
      let timeout = null;
      for await (const value of iterator) {
        clearTimeout(timeout);
        timeout = setTimeout(() => push(value), wait);
      }
    });
  };
}

How it works:

  1. Each new value from the source cancels the previous timer.
  2. After wait ms of quiet, the latest value is pushed.
  3. Rapid bursts collapse — only the final value survives.

Improved version with cancellable timers

Replace setTimeout/clearTimeout with a startTimer helper that uses Promise.withResolvers() for cleaner cancellation:

javascript
function startTimer(ms) {
  const { promise, resolve, reject } = Promise.withResolvers();
  promise.catch(() => {}); // suppress unhandled rejection
  setTimeout(resolve, ms);
  return {
    promise,
    cancel() {
      reject("cancelled");
    },
  };
}

export function debounce(wait = 0) {
  if (typeof wait !== "number") {
    throw new TypeError("wait must be a number");
  }
  if (wait < 0) {
    throw new RangeError(
      "wait must be a non-negative number"
    );
  }
  
  return function (iterator) {
    let timer = null;
    
    return new Repeater(async (push, stop) => {
      for await (const value of iterator) {
        timer?.cancel();
        timer = startTimer(wait);
        timer.promise.then(
          () => push(value),
          () => {} // ignore cancellation
        );
      }
      
      await timer?.promise;
      stop();
    });
  };
}

Key improvements:

  • await timer?.promise at the end ensures the last debounced value is emitted before the Repeater closes.
  • stop() signals completion after the final value.
  • Cancellation is explicit rather than relying on clearTimeout side effects.

Trade-offs

ApproachProsCons
setTimeout/clearTimeoutSimple, no polyfillImplicit cleanup
startTimer + withResolversClean cancellationNeeds withResolvers
Lodash/Underscore debounceWell-testedCallback-based

Curried vs. uncurried: The curried API (debounce(ms)(iterator)) composes well in pipelines. For simpler call sites, consider an uncurried version: debounce(ms, iterator).