How it works
Full versions with options. Each factory call creates one environment holding timer, pendingThis, and pendingArgs; the wrapper, its timer callback, and cancel are all closures over it. pendingArgs doubles as the "a trailing call is owed" flag.
export function debounce(fn, wait, { leading = false, trailing = true } = {}) {
let timer = null;
let pendingThis;
let pendingArgs = null; // set only when a trailing call is owed
function reset() {
timer = null;
pendingThis = undefined;
pendingArgs = null;
}
function endWait() {
const context = pendingThis;
const latest = pendingArgs;
reset();
if (trailing && latest) {
timer = setTimeout(endWait, wait); // no leading call within `wait` of this one
fn.apply(context, latest);
}
}
function debounced(...args) {
const startsBurst = timer === null;
clearTimeout(timer);
timer = setTimeout(endWait, wait); // every call restarts the wait
if (leading && startsBurst) {
fn.apply(this, args);
} else {
pendingThis = this;
pendingArgs = args;
}
}
debounced.cancel = () => {
clearTimeout(timer);
reset();
};
return debounced;
}
The burst state is just timer: null means no call within the last wait ms. After a trailing call, endWait re-arms the timer once more, so a call right after it isn't treated as the leading edge of a new burst — with both edges on, calls stay at least wait apart.
Throttle keeps the same state but never restarts a running timer. The window ends on schedule; if a call is owed, it runs with the latest arguments and opens the next window, so invocations stay at least wait apart.
export function throttle(fn, wait, { leading = true, trailing = true } = {}) {
let timer = null;
let pendingThis;
let pendingArgs = null;
function reset() {
timer = null;
pendingThis = undefined;
pendingArgs = null;
}
function endWindow() {
const context = pendingThis;
const latest = pendingArgs;
reset();
if (trailing && latest) {
timer = setTimeout(endWindow, wait); // the trailing call opens a new window
fn.apply(context, latest);
}
}
function throttled(...args) {
if (timer === null) {
timer = setTimeout(endWindow, wait);
if (leading) {
fn.apply(this, args);
return;
}
}
pendingThis = this;
pendingArgs = args;
}
throttled.cancel = () => {
clearTimeout(timer);
reset();
};
return throttled;
}
once needs no flag beyond the reference itself:
export function once(fn) {
let pending = fn;
let result;
return function (...args) {
if (pending) {
const call = pending;
pending = null; // mark as called before running (re-entrancy) and release `fn`
result = call.apply(this, args);
}
return result;
};
}
Edge cases
- Last caller wins. A trailing call uses the
this and arguments of the most recent call, not the first.
leading: false, trailing: false never calls fn. It's a legal but useless configuration.
- Errors. A throw from a deferred call happens inside a timer callback, so it surfaces as an uncaught exception, not at the call site. Both wrappers arm the timer before calling
fn, so a throw doesn't break the next window.
- Fake timers and nested scheduling. In Node 24, timers that fire during one
tick(ms) see the clock at the end of that tick, so a timer they schedule starts late. When a callback re-arms a timer, as throttle's does, tick to each boundary.
// Run with: node --test (with the three functions above saved as timing.mjs)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { debounce, throttle, once } from './timing.mjs';
test('debounce: one trailing call with the last this and args', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const calls = [];
const obj = { name: 'obj', run: debounce(function (...args) { calls.push([this.name, ...args]); }, 300) };
obj.run(1);
t.mock.timers.tick(200);
obj.run(2, 'x'); // restarts the wait
t.mock.timers.tick(299);
assert.deepEqual(calls, []);
t.mock.timers.tick(1);
assert.deepEqual(calls, [['obj', 2, 'x']]);
});
test('debounce: leading + trailing, and cancel', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const calls = [];
const run = debounce((n) => calls.push(n), 100, { leading: true });
run(1); // leading
t.mock.timers.tick(500);
run(2); // leading of a new burst
run(3); // owed as trailing…
run.cancel(); // …until cancelled
t.mock.timers.tick(500);
assert.deepEqual(calls, [1, 2]);
});
test('throttle: leading call, then the latest args once per window', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const calls = [];
const run = throttle((n) => calls.push(n), 100);
run(1); // runs now
run(2);
run(3);
t.mock.timers.tick(100); // trailing: 3, which opens a new window
run(4);
t.mock.timers.tick(100); // trailing: 4
t.mock.timers.tick(100); // nothing pending: the window closes
run(5); // runs now
assert.deepEqual(calls, [1, 3, 4, 5]);
});
test('once: runs fn once, even when called re-entrantly', () => {
let runs = 0;
const init = once(function (n) {
runs++;
init(n); // re-entrant call gets the (still undefined) cached result
return this.base + n;
});
const obj = { base: 10, init };
assert.equal(obj.init(1), 11);
assert.equal(obj.init(2), 11);
assert.equal(runs, 1);
});
Real-world scenario
Search-as-you-type combines the pieces. Create the debounced search once per input (outside render, or memoised so it survives re-renders — a new wrapper each render is a new closure with a new timer). Debounce on the trailing edge so the request starts after typing pauses. Inside the search, abort the previous request with an AbortController before starting the next one, because debouncing doesn't stop responses arriving out of order. On teardown, call cancel() and abort the in-flight request.
Trade-offs
These timer-based versions cost one timer per window or burst and are easy to reason about. A timestamp-based throttle (compare Date.now() with the last run) avoids a timer on the leading path but still needs one for the trailing call. Debounce has a starvation risk: if calls never pause, fn never runs; a maxWait option — force a call after at most that long — fixes it, at the cost of more state. Keep the API small: options you don't need are more edge cases to test.