12k
All articles

Node.js Now Has Built-In Debounce and Throttle

Node.js 26.10.0 and 24.22.0 add util.debounce and util.throttle. See how their options, Promise behavior, and rate limits differ from lodash.

OpenReplay Team
OpenReplay Team
Node.js Now Has Built-In Debounce and Throttle

Node.js 26.10.0 and 24.22.0 add util.debounce() and util.throttle() to the node:util module, so server code can debounce and rate-limit functions without installing lodash or throttle-debounce.

If you’ve written another setTimeout/clearTimeout wrapper for a file watcher, or added lodash.debounce to a service that otherwise uses nothing from lodash, you no longer need to. Both helpers are now part of the runtime.

This article covers the release lines, the two signatures and their options, server-side examples for each, and what you need to change when you remove lodash. It continues our series on Node.js built-in APIs that replace npm packages.

Key Takeaways

  • util.debounce() and util.throttle() are available in Node.js 26.10.0+ and 24.22.0+. Both came from nodejs/node PR #65899.
  • Every call to a debounced function returns a Promise. All calls in one debounce window settle with the result of a single invocation of fn.
  • util.throttle(fn, limit, interval) lets at most limit invocations start per interval milliseconds, and it queues extra calls by default instead of dropping them.
  • Calls dropped by util.throttle() reject with ERR_THROTTLED, and Node marks those rejections as handled.
  • Neither function has a trailing or maxWait option, so code ported from lodash has to be adjusted to Node’s option set.

Which Node.js Versions Ship util.debounce and util.throttle

Both functions first shipped in Node.js 26.10.0, a Current release from 22 September 2026. They were backported to Node.js 24.22.0 ‘Krypton’ LTS on 14 October 2026. Both changes come from PR #65899 by James M Snell. The util docs show “Added in: v26.10.0” for each function, but the 24.22.0 release notes list both under Notable Changes. On the 24.x line, the minimum version is 24.22.0, so any later 24.x patch also has them.

If you publish a library that supports older Node versions, check that util.debounce exists before using it, or keep a fallback.

What Is the Difference Between Debounce and Throttle?

In Node.js, util.debounce() runs fn once after calls stop arriving for wait milliseconds, while util.throttle() limits how many calls to fn can start in each interval. For the from-scratch mechanics, see debouncing and throttling for performance.

How Does util.debounce Work in Node.js?

util.debounce(fn, wait[, options]) waits until wait milliseconds have passed since the most recent call and then invokes fn. Every call made in that window returns a Promise that settles with the result of that one invocation. The util.debounce reference lists three options:

OptionDefaultEffect
leadingfalseWhen true, the first call in a window invokes fn immediately
rejectOnCancelfalseWhen true, superseded calls reject with an AbortError
signalnoneAborting cancels pending calls and blocks future ones. An already-aborted signal makes debounce() throw

The returned function also has cancel([reason]), flush(), pending, pendingCount, ref() and unref().

Requires Node.js ≥ 24.22.0 or ≥ 26.10.0.

import { debounce } from 'node:util';

const save = debounce(async (value) => `saved:${value}`, 100);

const calls = [save('a'), save('b'), save('c')];
console.log(save.pendingCount); // 3
console.log(await Promise.all(calls)); // [ 'saved:c', 'saved:c', 'saved:c' ]

fn receives only the arguments from the most recent call. To collect every event in a burst, store them outside fn and read them when it runs. The file watcher below does this with a Set:

import { watch } from 'node:fs';
import { debounce } from 'node:util';

const changed = new Set();

const rebuild = debounce(async () => {
  const batch = [...changed];
  changed.clear();
  console.log('rebuilding for', batch);
  return batch;
}, 200).unref();

const watcher = watch('./src', (eventType, filename) => {
  changed.add(filename);
  rebuild().catch(console.error);
});

process.on('SIGINT', () => {
  rebuild.flush();
  watcher.close();
});

Calling unref() on a debounced or throttled function lets the process exit while its timer is still pending. That suits file watchers and log flushers, which should never block shutdown. unref() returns the function, so you can chain it onto the debounce() call. On shutdown, flush() runs the final batch so it isn’t lost.

Unlike throttle’s ERR_THROTTLED rejections, debounce doesn’t mark every rejection as handled. Node’s source marks a call’s promise as handled once a later call supersedes it, but the most recent call’s promise is not. If cancel() or an aborted signal rejects that promise and nothing catches it, the process gets an unhandled rejection. For fire-and-forget calls like the watcher callback above, attach a .catch.

How Does util.throttle Rate-Limit Calls?

util.throttle(fn, limit, interval[, options]) lets at most limit invocations of fn start in each interval milliseconds. limit must be greater than 0. By default, calls over the limit are queued in arrival order instead of discarded. The util.throttle reference defines these options:

  • concurrency (default Infinity): the maximum number of fn calls in flight at once. A slot is released when fn’s return value settles.
  • maxPending (default Infinity): the queue size cap. It has no effect in drop mode.
  • overflow ('queue' by default, or 'drop').
  • strict (default false).
  • signal: when aborted, pending and future calls reject with an AbortError.

Rate capacity is used up when fn starts, not when a call joins the queue. The function also exposes cancel([reason]), hasImmediateCapacity(), pending, pendingCount, activeCount, ref() and unref().

import { setTimeout as sleep } from 'node:timers/promises';
import { throttle } from 'node:util';

const callApi = throttle(async (id) => {
  await sleep(50); // stand-in for a real request
  return { id };
}, 5, 1_000, { concurrency: 2, maxPending: 100 });

const calls = Array.from({ length: 12 }, (_, i) => callApi(i));
console.log('active:', callApi.activeCount, 'queued:', callApi.pendingCount);
console.log(await Promise.all(calls)); // results in call order

By default util.throttle() counts invocations in fixed windows. That can let two groups of calls start close together at a window boundary. Setting strict: true enforces the limit across every rolling interval instead.

To run a function at most once per second and discard the extra calls instead of queueing them, use throttle(fn, 1, 1000, { overflow: 'drop' }):

import { throttle } from 'node:util';

const ping = throttle(async () => 'pong', 1, 1_000, { overflow: 'drop' });

const results = await Promise.allSettled([ping(), ping(), ping()]);
for (const r of results) {
  console.log(r.status === 'fulfilled' ? r.value : r.reason.code);
}
// pong, ERR_THROTTLED, ERR_THROTTLED

if (ping.hasImmediateCapacity()) ping(); // gate instead of catching

Calls that util.throttle() drops reject with an ERR_THROTTLED error. A call is dropped either because overflow is 'drop' or because the maxPending queue is full. Node marks these promises as handled, and dropped calls use no rate or concurrency capacity. cancel() clears queued calls and resets the window, but it doesn’t stop invocations that have already started.

How Do You Replace lodash.debounce and lodash.throttle?

util.debounce() and util.throttle() can replace lodash.debounce and lodash.throttle in most server code, but they don’t use the same model as lodash. Node’s throttle limits how many calls can start per interval, and it queues the rest. Lodash’s throttle allows at most one call per wait. Port your code using only the node:util option set:

ConcernWhat node:util doesWhat to change
Return valueEvery call returns a PromiseHandle the Promise with await or .catch
Edge optionsOnly leading; there are no trailing or maxWait optionsRemove those keys and rethink any maxWait logic, for example with a throttle
Throttle modelAllows limit call starts per interval and queues extrasUse a limit of 1 plus overflow: 'drop' for skip-the-extras behavior
Manual controlcancel([reason]) and flush()Call them directly on the wrapper
thisInside fn, this is the wrapperBind methods first
// before: hand-rolled
let timer;
const saveLater = (doc) => {
  clearTimeout(timer);
  timer = setTimeout(() => store.save(doc), 500);
};
// after
import { debounce } from 'node:util';

const saveLater = debounce(store.save.bind(store), 500);
saveLater(doc).catch(console.error);
// on shutdown: saveLater.flush(); or discard: saveLater.cancel();

When to Keep lodash or a Custom Helper

Keep lodash, throttle-debounce or a hand-written helper instead of node:util in two cases:

  • Browser code. node:util is a server-side module. Front-end bundles still need their own helper; see React debounce strategies.
  • Node versions older than 24.22.0. This includes earlier 24.x patches and every older release line. Projects that have to support those versions should keep the dependency or add a feature check.

Conclusion

On Node.js 26.10.0+ and 24.22.0+, debouncing and rate limiting are part of node:util. Both functions return Promises and come with cancel() and unref() built in, and the debounced function adds flush(). As a next step, find where your server code uses lodash.debounce or lodash.throttle, and replace one file watcher or outbound client with the built-in. Bind any methods before wrapping them, and add a .catch to every call you don’t await.

FAQs

Does util.debounce with leading: true run the function twice?

Only if more calls arrive during the window. With leading set to true, the first call in a debounce window invokes fn immediately. A second, trailing invocation happens only if the debounced function was called again before the wait period ended. A single isolated call therefore runs fn exactly once, on the leading edge, and there is no trailing option to turn off.

Does util.debounce wait for an async function to finish before starting a new window?

No. After a trailing invocation, util.debounce can open a new debounce window even if the promise returned by fn has not settled yet, so two runs of a slow async fn can overlap. To prevent overlapping runs, use util.throttle with concurrency set to 1. Its concurrency slot is released only when the return value of fn settles.

How do I feature-detect util.debounce without breaking on older Node.js versions?

Import the whole module and check the property: import util from 'node:util', then test typeof util.debounce === 'function' before you use it, and fall back to a userland helper if it is missing. In ESM, a named import of an export that does not exist fails with a SyntaxError when the module links, so the code never gets a chance to run the check.

What happens to queued util.throttle calls when its AbortSignal is aborted?

They reject. When the signal passed to util.throttle is aborted, pending and future calls reject with an AbortError, and the signal's reason is set as the error's cause. Invocations that have already started keep running. Unlike ERR_THROTTLED rejections, the docs do not say these AbortError rejections are marked as handled, so attach a catch handler to every call you do not await.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.