Have 30-200 Employees? Make $50k-$500k selling your data for AI Training.

Learn More

Node 26 Debounce and Throttle: Practical Guide and Lodash Comparison

SitePoint Team
SitePoint Team
Published in

The AI briefing for Developers

Stay up to date with AI tools, model releases, and developer workflows that matter.

Weekly. Free. One click to leave.

Share this article

Node 26 Debounce and Throttle: Practical Guide and Lodash Comparison
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

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 when overflow is 'queue'.
  • concurrency: how many invocations may be unsettled at once.
  • strict: when true, no more than limit calls start in any rolling interval, not just in fixed windows.
  • signal: an AbortSignal that 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 await or .then.
  • Throttle shape. _.throttle(fn, wait) runs at most once per wait and uses the latest arguments. util.throttle(fn, limit, interval) queues every call with its own arguments unless you set overflow: 'drop'.
  • maxWait. Lodash's _.debounce has maxWait. The Node options listed above have no equivalent.
  • trailing. Lodash lets you turn off the trailing edge. The Node debounce options are leading, rejectOnCancel and signal.
  • flush and cancel. Both have flush() and cancel(). Node's cancel() 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?

NeedUse
Run once after a burst of events in Node 26.10+util.debounce
Start at most N calls per interval and keep every callutil.throttle (queue)
Skip excess calls instead of queueingutil.throttle with overflow: 'drop'
maxWait, trailing-edge control, or browser supportLodash
Protect an HTTP endpoint from outside trafficA 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 engines or CI.
  • Not a rate limiter for incoming requests. Wrapping a route handler in util.throttle controls how often the inner function runs. It does not stop requests arriving.
  • Queue growth. The default queue is unbounded. Set maxPending or use overflow: 'drop' when callers can outpace the limit.

Key takeaways

  • util.debounce(fn, wait, options) and util.throttle(fn, limit, interval, options) shipped in Node.js 26.10.0.
  • Both return promises and support AbortSignal cancellation.
  • Throttle queues by default, which differs from Lodash.
  • Always handle the promise when you use cancel().

Sources

SitePoint TeamSitePoint Team

Sharing our passion for building incredible internet things.

© 2000 – 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.