Appearance
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 tooCurried 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:
- Each new value from the source cancels the previous timer.
- After
waitms of quiet, the latest value is pushed. - 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?.promiseat 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
clearTimeoutside effects.
Trade-offs
| Approach | Pros | Cons |
|---|---|---|
setTimeout/clearTimeout | Simple, no polyfill | Implicit cleanup |
startTimer + withResolvers | Clean cancellation | Needs withResolvers |
| Lodash/Underscore debounce | Well-tested | Callback-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).