Node.js 26.10.0 added util.debounce and util.throttle to node:util, so you can debounce a file-watch handler or throttle outgoing API calls without Lodash. They are promise-based and they behave differently from Lodash in a few ways that matter when you migrate. This guide shows both APIs with examples run on Node 26.10.0, then compares them with Lodash 4.17 and lists the gotchas.
Reviewed October 4, 2026 against the official node:util documentation and the Node.js 26 changelog, and all examples were run on Node.js 26.10.0.
Check that your Node version has them
Both functions were added in v26.10.0. On older versions, util.debounce is undefined. Check before you rely on them:
node --version
node --input-type=module -e "import { debounce, throttle } from 'node:util'; console.log(typeof debounce, typeof throttle);"
On 26.10.0 or later this prints function function. The examples below use ES module syntax, so save them as .mjs files or set "type": "module" in your package.json.
Debounce vs throttle in one minute
Debounce waits for a quiet period and then runs once, with the most recent arguments. Use it when you want to act after a burst ends, such as a file save that fires several watcher events. Throttle limits how often work starts. Use it when events keep arriving and you want a steady rate, such as calls to a rate-limited API.
util.debounce
The signature is util.debounce(fn, wait[, options]). The options are leading (run at the start of a window, default false), rejectOnCancel (reject the promises of superseded calls, default false) and signal (an AbortSignal that cancels pending calls).
The debounced function returns a promise. By default, every call in the same window resolves with the result of the single invocation that actually ran:
import { debounce } from 'node:util';
const save = debounce((value) => {
console.log('saved', value);
return value;
}, 100);
const results = await Promise.all([save(1), save(2), save(3)]);
console.log(results);
// saved 3
// [ 3, 3, 3 ]
The returned function also has cancel(), flush(), pending, pendingCount, ref() and unref(). Calling unref() lets the process exit while a debounce timer is pending.
Example: rebuild once after a burst of file events
fs.watch often reports several events for one save. Debouncing the handler runs the rebuild once, 300 ms after the last event:
import { debounce } from 'node:util';
import { watch } from 'node:fs';
const rebuild = debounce((file) => {
console.log(`Rebuilding after change to ${file}`);
}, 300);
function ignoreAbort(err) {
if (err.name !== 'AbortError') throw err;
}
const watcher = watch('./src', { recursive: true }, (_event, file) => {
rebuild(file).catch(ignoreAbort);
});
process.on('SIGINT', () => {
rebuild.cancel();
watcher.close();
});
Three quick writes to a file in ./src produced one "Rebuilding" line in a test run on Node 26.10.0.
The cancel() gotcha
cancel() rejects every pending promise with an AbortError. If you ignore the returned promise, which is natural in an event handler, that rejection becomes an unhandled rejection and Node exits with an error. In a test, calling cancel() on a debounced function whose promise nobody handled crashed the process. That is why the example above attaches .catch(ignoreAbort) to each call. Handle the promise, or use an AbortSignal and handle rejections the same way.
util.throttle
The signature is util.throttle(fn, limit, interval[, options]). It allows at most limit invocations of fn to start in each interval milliseconds. This differs from Lodash, where you pass a single wait time. The options are:
overflow:'queue'(default) holds extra calls in order,'drop'rejects them straight away.maxPending: the longest the queue may grow whenoverflowis'queue'.concurrency: how many invocations may be unsettled at once.strict: whentrue, no more thanlimitcalls start in any rolling interval, not just in fixed windows.signal: anAbortSignalthat cancels pending calls.
Example: call an API at most twice per second
import { throttle } from 'node:util';
const started = Date.now();
const callApi = throttle(async (id) => {
console.log(`request ${id} started at ${Date.now() - started} ms`);
return { id };
}, 2, 1_000);
const results = await Promise.all([1, 2, 3, 4, 5].map((id) => callApi(id)));
console.log(results.map((r) => r.id));
On Node 26.10.0 this printed requests 1 and 2 at about 1 ms, requests 3 and 4 at about 1,001 ms and request 5 at about 2,003 ms. Every call ran, in order, each with its own arguments. A throttled call is queued, not discarded.
Example: drop excess calls instead of queueing them
import { throttle } from 'node:util';
const notify = throttle((msg) => msg, 1, 1_000, { overflow: 'drop' });
console.log(await notify('first')); // first
try {
await notify('second');
} catch (err) {
console.log(err.code); // ERR_THROTTLED
}
A dropped call returns a promise rejected with an ERR_THROTTLED error. Node marks that promise as handled, so ignoring it does not raise an unhandledRejection. You can also call hasImmediateCapacity() to check whether a call would run right away.
Moving from Lodash
The Node functions look similar to _.debounce and _.throttle, but they are not drop-in replacements. Check these differences against the Lodash 4.17 documentation:
- Return value. Lodash returns the result of the last invocation. Node returns a promise, so callers need
awaitor.then. - Throttle shape.
_.throttle(fn, wait)runs at most once perwaitand uses the latest arguments.util.throttle(fn, limit, interval)queues every call with its own arguments unless you setoverflow: 'drop'. - maxWait. Lodash's
_.debouncehasmaxWait. The Node options listed above have no equivalent. - trailing. Lodash lets you turn off the trailing edge. The Node debounce options are
leading,rejectOnCancelandsignal. - flush and cancel. Both have
flush()andcancel(). Node'scancel()rejects pending promises.
If you only need a simple wait-based debounce and you handle the returned promise, switching is a small change. If you rely on maxWait, on Lodash's drop-the-extras throttle, or you share code with the browser, keep Lodash.
Which tool should you use?
| Need | Use |
|---|---|
| Run once after a burst of events in Node 26.10+ | util.debounce |
| Start at most N calls per interval and keep every call | util.throttle (queue) |
| Skip excess calls instead of queueing | util.throttle with overflow: 'drop' |
maxWait, trailing-edge control, or browser support | Lodash |
| Protect an HTTP endpoint from outside traffic | A rate limiter in middleware or a gateway |
Pitfalls to avoid
- Unhandled AbortError. Handle promises from debounced functions before you call
cancel(), as shown above. - Version drift. Code using these functions fails on Node versions before 26.10.0. Pin the runtime in
enginesor CI. - Not a rate limiter for incoming requests. Wrapping a route handler in
util.throttlecontrols how often the inner function runs. It does not stop requests arriving. - Queue growth. The default queue is unbounded. Set
maxPendingor useoverflow: 'drop'when callers can outpace the limit.
Key takeaways
util.debounce(fn, wait, options)andutil.throttle(fn, limit, interval, options)shipped in Node.js 26.10.0.- Both return promises and support
AbortSignalcancellation. - Throttle queues by default, which differs from Lodash.
- Always handle the promise when you use
cancel().

