How to Use Node.js 26.10 Debounce and Throttle Without npm
Node.js 26.10 adds built-in debounce and throttle utilities. Learn their Promise behavior, cancellation, queueing, concurrency controls and practical uses.
On this page
Node.js 26.10.0 added two utilities that developers have traditionally reached for through third-party packages: util.debounce() and util.throttle(). Released on September 22, 2026, the Current release also adds new cryptography, filesystem, networking and performance APIs, but these timing helpers are the easiest additions to put to work immediately. This tutorial shows how to use them, how their Promise behavior differs from older browser-oriented implementations, and when to choose one over the other.
Start with Node.js 26.10 before using the new utilities
Before writing the examples, check the runtime that will execute your code. Node.js 26.10.0 is part of the Current release line, while Node.js 24 remains the Long-Term Support line intended for production use when stability and extended maintenance matter more than the newest APIs. You can verify your local version with node --version. The new utilities are part of the built-in node:util module, so there is no package to install and no dependency to add to package.json.
node --version
If the command reports v26.10.0 or another compatible Node.js 26 release containing these APIs, you can import the functions directly. With ECMAScript modules, use a named import from node:util; CommonJS applications can use require() instead. Keeping the import explicit also makes it clear that the timing behavior comes from Node.js itself rather than a utility package hidden elsewhere in the dependency tree.
Understand why debounce and throttle solve different problems
Debouncing waits until calls stop arriving for a specified period before running a function. It is useful when many intermediate calls have little value and only the final state matters, such as waiting for a user to finish typing before performing a search. Throttling does the opposite: it limits how frequently a function may start while calls continue arriving. That makes throttling a better fit for sustained activity such as polling, event processing or sending requests at a controlled rate.
The distinction becomes clearer with a simple timeline. If a debounced function has a 500-millisecond delay and receives calls at 0, 200 and 400 milliseconds, the timer keeps moving because every call resets the waiting period. The function therefore runs after the final call has been quiet for 500 milliseconds. A throttled function with a one-second interval instead controls how many invocations can start during that interval, so repeated calls can continue while execution is being regulated.
Create your first Node.js 26.10 debounce function
Import debounce from node:util and pass it the function you want to delay and the number of milliseconds to wait. The returned value is another function that you call instead of the original function. Node.js returns a Promise from that debounced function, which means asynchronous work can be awaited directly rather than wrapped in another callback.
import { debounce } from 'node:util';
const search = debounce(async (query) => {
console.log(`Searching for: ${query}`);
return query;
}, 500);
const first = search('nod');
const second = search('node');
const third = search('node.js');
console.log(await first); // node.js
console.log(await second); // node.js
console.log(await third); // node.js
All three calls occur before the 500-millisecond delay expires, so the underlying function receives the arguments from the most recent call. By default, all three returned Promises resolve with the result of that final invocation. This behavior is particularly useful when several callers are waiting for the same final operation, because they do not each trigger a separate execution of the underlying function.
Use debounce for search boxes and idle-time work
A search endpoint is a practical place for debouncing because a request for every individual keystroke can create unnecessary work. The user may type several characters before they have finished entering the query, while the server would otherwise receive an intermediate request for each change. A 300- or 500-millisecond delay gives the user a short pause before the application commits to the search. The exact value is an application decision rather than a Node.js requirement.
import { debounce } from 'node:util';
async function findUsers(query) {
console.log('Searching:', query);
return query;
}
const debouncedSearch = debounce(findUsers, 400);
async function handleSearch(query) {
if (!query.trim()) {
return;
}
const result = await debouncedSearch(query);
console.log('Latest result:', result);
}
The important part is that handleSearch() does not need to implement its own timer. Each call to debouncedSearch() resets the waiting period, and only the most recent arguments are passed to findUsers() when the window expires. If another search arrives before that point, the previous pending execution is superseded. This makes the utility useful for any workflow where inactivity, rather than elapsed time since the first event, determines when work should begin.
Use leading mode when the first call should happen immediately
Debouncing normally waits before invoking the underlying function, but Node.js 26.10 also provides a leading option. Set it to true when the first call in a new debounce window should run immediately. Calls that arrive during the window are then delayed until there has been a full wait period since the most recent call. A trailing invocation only happens when another call occurred during that window.
import { debounce } from 'node:util';
const refresh = debounce(
async () => {
console.log('Refreshing data');
return Date.now();
},
1000,
{ leading: true }
);
await refresh();
await refresh();
await refresh();
This option changes the timing without changing the basic purpose of debounce. It is useful when the first event should receive an immediate response but repeated activity should not continually trigger the operation. A button that starts an expensive refresh is one example: the first click can act immediately while additional clicks within the debounce window are controlled. Because the exact trailing behavior depends on whether another call arrives during the window, test the sequence you need rather than assuming that leading means "run once and ignore everything else."
Cancel or flush pending debounce work
The debounced function returned by Node.js exposes methods for controlling pending work. cancel() ends the current debounce window and rejects its pending Promises with an AbortError. flush() ends the delay and invokes the pending function immediately. These methods are useful when application state changes and waiting for the original timer would no longer make sense.
import { debounce } from 'node:util';
const saveDraft = debounce(async (text) => {
console.log('Saving:', text);
return text;
}, 1000);
saveDraft('First draft');
saveDraft('Final draft');
await saveDraft.flush();
In this example, flush() prevents the application from waiting for the remaining delay before saving the latest pending value. That can be useful when a user leaves a screen and you want pending work completed before navigation continues. Conversely, cancel() is appropriate when the pending operation should no longer happen at all. The distinction is simple: flush executes the pending operation now, while cancel discards the pending window.
Use rejectOnCancel when superseded calls must fail
By default, a Promise returned by a superseded debounce call resolves or rejects using the result of the invocation that eventually runs. Node.js provides rejectOnCancel when that behavior is not appropriate. With the option enabled, a call replaced by a later call rejects with an AbortError instead. This can be useful when each caller needs to know that its specific request was cancelled rather than silently receiving the result associated with newer arguments.
import { debounce } from 'node:util';
const processValue = debounce(
async (value) => value.toUpperCase(),
500,
{ rejectOnCancel: true }
);
const first = processValue('old');
const second = processValue('new');
try {
console.log(await first);
} catch (error) {
console.log(error.name); // AbortError
}
console.log(await second); // NEW
The option changes how callers observe cancellation, not which arguments reach the underlying function. The final call still supplies the arguments used by the eventual invocation. This distinction matters in larger asynchronous applications where several parts of the program may hold the Promises returned by a shared debounced function. If those callers need explicit cancellation semantics, rejectOnCancel makes that state visible instead of resolving every superseded Promise with the newest result.
Throttle repeated work with a fixed invocation limit
Use throttle() when the function should continue running but only at a controlled rate. Its basic signature is throttle(fn, limit, interval), where limit is the maximum number of invocations during an interval measured in milliseconds. Unlike debounce, calls that exceed the rate are queued by default rather than discarded. The returned function also produces Promises for the results of the underlying calls.
import { throttle } from 'node:util';
const request = throttle(async (id) => {
console.log('Request:', id);
return id;
}, 2, 1000);
const results = await Promise.all([
request(1),
request(2),
request(3),
request(4),
]);
console.log(results);
With a limit of two and an interval of 1,000 milliseconds, the first two calls can start during the initial window. The remaining calls stay queued and are processed in later windows, preserving their original order. This is different from a throttle implementation that simply ignores extra events, so existing code should not assume that exceeding the limit means losing the call. Node.js also separates rate capacity from concurrency capacity, which lets you control both how often work starts and how much asynchronous work can remain unsettled.
Control queued calls with overflow and concurrency options
The default throttle behavior queues calls, but Node.js 26.10 lets you choose what happens when capacity is unavailable. Set overflow to 'drop' when excess calls should be rejected immediately instead of waiting. Set maxPending to limit how many calls may remain in the queue when using the default queue behavior. This gives applications a way to prevent an unlimited backlog when incoming work can arrive faster than the throttled function can process it.
import { throttle } from 'node:util';
const sendEvent = throttle(
async (event) => {
console.log('Sending:', event);
return event;
},
2,
1000,
{
maxPending: 3,
overflow: 'queue',
concurrency: 2,
}
);
try {
const result = await sendEvent('event-1');
console.log(result);
} catch (error) {
console.error(error);
}
The concurrency option limits how many invocations can have unsettled return values at the same time. That matters for asynchronous functions because a rate limit alone does not necessarily stop several slow operations from being active simultaneously. If the queue reaches maxPending, additional calls are rejected with an ERR_THROTTLED error. With overflow: 'drop', calls that cannot start because of rate or concurrency capacity are rejected immediately instead of entering the queue.
Choose debounce or throttle by looking at the event pattern
The easiest way to choose between the two utilities is to ask whether the final event or continued sampling is what you need. Debounce waits for silence, so it is a natural fit for text input, inactivity detection and operations where only the newest request matters. Throttle keeps allowing work through at a controlled rate, which suits repeated requests, event streams and workloads where intermediate activity still has value. Neither approach is inherently faster because they solve different scheduling problems.
| Requirement | Use | Reason |
|---|---|---|
| Wait until activity stops | debounce() | Runs after the latest call has been quiet for the configured delay. |
| Allow limited continuous activity | throttle() | Controls how many invocations can start during an interval. |
| Only the newest arguments matter | debounce() | The eventual invocation receives the most recent arguments. |
| Every queued call should normally run | throttle() | Calls are queued by default when capacity is unavailable. |
| Excess calls should be rejected | throttle() | overflow: 'drop' rejects calls without queueing them. |
The choice becomes especially important for network operations. Debouncing a search request can prevent intermediate queries from ever reaching the server, while throttling a telemetry stream can preserve a controlled sample of ongoing activity. If every event represents work that must eventually happen, a throttle queue may be appropriate; if old events become meaningless as soon as a newer event arrives, debounce is usually a closer match. The key is to decide what information your application can safely discard before selecting the timer.
Use AbortSignal when the operation needs an external stop
Both utilities accept an AbortSignal, the standard JavaScript mechanism for notifying asynchronous code that an operation should stop or no longer begin. When the signal is aborted, pending and future calls reject with an AbortError, and the wrapped function is not invoked for those calls. This is useful when a request belongs to a page, job or task that has been cancelled before its delayed work should start.
import { debounce } from 'node:util';
const controller = new AbortController();
const updateSearch = debounce(
async (query) => {
console.log('Searching:', query);
return query;
},
500,
{ signal: controller.signal }
);
const pending = updateSearch('node.js');
controller.abort();
try {
await pending;
} catch (error) {
console.log(error.name); // AbortError
}
An already-aborted signal also prevents the debounce or throttle operation from being created successfully. Calls that have already started are not retroactively stopped by the signal, so this feature controls pending and future invocation rather than magically terminating arbitrary JavaScript work. That distinction is important for network requests or database operations: cancelling the scheduler is separate from cancelling an operation that is already executing.
Know the event-loop behavior before relying on pending timers
Both returned functions expose ref() and unref() because their pending timers interact with the Node.js event loop. A referenced timeout can keep the process alive while work is waiting, whereas unref() allows the process to exit naturally even when a pending timer exists. This is useful for command-line programs where delayed housekeeping should not prevent the process from terminating. It is also useful for services where the lifetime of the timer should not accidentally become a reason for the process to remain alive.
import { debounce } from 'node:util';
const reportIdle = debounce(() => {
console.log('Idle timeout reached');
}, 5000).unref();
reportIdle();
In a long-running server, leaving the timer referenced may be perfectly reasonable because the process is expected to remain alive anyway. In a short command-line tool, the same timer can be unnecessary if there is no other work keeping the process running. The choice therefore depends on the lifetime of the application rather than the debounce logic itself. Treat unref() as a process-lifecycle control, not as another form of debounce configuration.
Replace small utility dependencies only after testing behavior
Node.js 26.10 makes it possible to remove some small debounce and throttle dependencies from a project, but replacing a package blindly is not a good migration strategy. Third-party libraries can expose different defaults, leading-edge or trailing-edge behavior, cancellation methods and argument handling. The built-in Node.js functions have their own Promise-based API and specific queueing rules, so existing tests should verify behavior rather than merely checking that the new import works.
A sensible migration starts with one call site that has clear tests. Compare what happens when calls arrive rapidly, when the wrapped function rejects, when several callers await the same debounced operation, and when cancellation occurs. For throttle, also test queue growth, concurrency and the behavior you expect at interval boundaries. Once those cases match your application's requirements, moving the implementation into node:util can remove a dependency while making the scheduling behavior part of the Node.js runtime itself.
Node.js 26.10.0 is a Current release rather than the Long-Term Support line, so production teams should also consider their runtime support policy before adopting these APIs broadly. For developers already testing Node.js 26, however, util.debounce() and util.throttle() are unusually practical additions because they address a problem that appears in ordinary application code without requiring another package. Start with debounce when silence determines the right moment to act, switch to throttle when continuous activity needs a ceiling, and use the cancellation and concurrency controls when the simple versions are no longer enough.
Written by


