Node 26.10's util.debounce and util.throttle are not lodash drop-ins
published
TL;DR
Node.js 26.10.0, released on Tuesday, September 22, 2026, adds debounce() and throttle() to node:util. They share lodash’s names, but they don’t behave like lodash. util.throttle(fn, limit, interval) is a rate limiter: it queues every call and runs them oldest first, where lodash drops the calls in between and keeps the newest. Both functions return promises. If you call one fire-and-forget, the way debounced event handlers are usually called, then a cancel() or an aborted signal that arrives before the call runs rejects a promise nobody is listening to, and Node’s default unhandled-rejection mode ends the process with exit code 1.
The problem
Unless a version is named, every example below ran on the official node-v26.10.0-win-x64 build. The outputs are copied from the terminal.
The signature is different
lodash’s throttle is throttle(func, wait). Node’s is throttle(fn, limit, interval), where the second argument is how many calls are allowed per interval. If you change only the import, the call throws at startup:
import { throttle } from 'node:util'
const render = (pct) => console.log(`render ${pct}%`)
try {
throttle(render, 200) // the lodash shape: throttle(func, wait)
} catch (err) {
console.log(`${err.name} [${err.code}]: ${err.message}`)
}
TypeError [ERR_INVALID_ARG_TYPE]: The "interval" argument must be of type number. Received undefined
This failure is the easy one because it’s loud. If you fix the arguments to throttle(render, 1, 200), the code runs without an error but does something different.
It queues every call instead of keeping the latest
Here is a progress callback fired ten times in a tight loop and throttled to one render every 200 ms:
import { throttle } from 'node:util'
const t0 = performance.now()
const onProgress = throttle((pct) => {
console.log(`+${String(Math.round(performance.now() - t0)).padStart(4)}ms render ${pct}%`)
}, 1, 200)
for (let pct = 10; pct <= 100; pct += 10) onProgress(pct)
console.log('queued right after the loop:', onProgress.pendingCount)
+ 0ms render 10%
queued right after the loop: 9
+ 206ms render 20%
+ 419ms render 30%
+ 618ms render 40%
+ 832ms render 50%
+1032ms render 60%
+1246ms render 70%
+1458ms render 80%
+1672ms render 90%
+1887ms render 100%
All ten calls run. The 100% state shows up almost two seconds after the work actually finished. The exact millisecond values change by a few ms from run to run, but the shape stays the same. Here is the same loop with lodash 4.18.1, whose timing drifts by a few ms the same way:
const { throttle } = require('lodash')
const t0 = performance.now()
const onProgress = throttle((pct) => {
console.log(`+${String(Math.round(performance.now() - t0)).padStart(4)}ms render ${pct}%`)
}, 200)
for (let pct = 10; pct <= 100; pct += 10) onProgress(pct)
+ 0ms render 10%
+ 210ms render 100%
lodash runs the first call right away (leading edge), discards 20% through 90%, and runs a trailing call with the arguments from the most recent call. Its docs say so directly: “The func is invoked with the last arguments provided to the throttled function.” Node’s docs describe a different design on purpose: “By default, calls that exceed the limit are queued in the order received rather than discarded.”
The two options that bound the queue don’t restore lodash’s behavior either. overflow: 'drop' keeps only the leading call. maxPending: 1 keeps the oldest waiting call, not the newest:
import { throttle } from 'node:util'
const drop = throttle((pct) => console.log(`drop: render ${pct}%`), 1, 200, { overflow: 'drop' })
const keepOne = throttle((pct) => console.log(`maxPending 1: render ${pct}%`), 1, 200, { maxPending: 1 })
for (let pct = 10; pct <= 100; pct += 10) {
drop(pct)
keepOne(pct)
}
drop(100).catch((err) => console.log(`${err.code}: ${err.message}`))
drop: render 10%
maxPending 1: render 10%
ERR_THROTTLED: The call was dropped by the throttled function
maxPending 1: render 20%
Neither variant ever renders 100%. None of the documented options (concurrency, maxPending, overflow, signal, strict) chooses the newest call over older ones.
Everything returns a promise, and cancel() can exit the process
A debounced call returns a promise for fn’s result. By default, the promises of calls that a later call superseded resolve with the result of the final invocation:
import { debounce } from 'node:util'
const search = debounce(async (q) => `results for "${q}"`, 100)
const a = search('n')
const b = search('no')
const c = search('node')
console.log(await a)
console.log(await b)
console.log(await c)
results for "node"
results for "node"
results for "node"
The trap is in cancel(). With lodash, cancel() just clears a timer. With util.debounce, it rejects the pending promise with an AbortError. If nothing is attached to that promise, it becomes an unhandled rejection:
import { debounce } from 'node:util'
const save = debounce((doc) => console.log('saved', doc), 500)
save('draft 1') // fire-and-forget, the way every lodash debounce is called
save.cancel() // the document was closed before the timer fired
setTimeout(() => console.log('still running'), 1000)
node:internal/util/debounce:86
new AbortError() :
^
AbortError: The operation was aborted
at Function.cancel (node:internal/util/debounce:86:7)
...
code: 'ABORT_ERR'
}
Node.js v26.10.0
The ... stands for four stack frames that I removed. still running never prints, and the exit code is 1. throttle behaves the same way: calling send(1); send(2); send.cancel() prints sent 1 and then dies at node:internal/util/throttle:139 with the same AbortError.
Why it happens
Since Node 15, the default --unhandled-rejections mode has been throw. An unhandled rejection with no unhandledRejection listener gets raised as an uncaught exception. A lodash-style debounce never creates a promise, so this never came up. Node’s version creates one on every call, so fire-and-forget code now has a rejection that someone has to handle.
The implementation already handles some of these rejections for you. Node marks the dropped-call promises as handled, and that is documented for ERR_THROTTLED. The debounce source marks each superseded call’s promise the same way. Nothing else is marked: not the promise from the most recent debounced call, not any call waiting in a throttle queue (the oldest waiting call crashes the process just as the newest does), and not the already-rejected promise a call gets after the signal has aborted. cancel() and an aborted signal reject every pending promise, unmarked ones included. Here is what each case does on 26.10.0 when nobody attaches a handler:
| Rejection | Marked as handled | Fire-and-forget result |
|---|---|---|
throttle call dropped by overflow: 'drop' or a full maxPending queue | yes (documented) | keeps running |
debounce superseded call with rejectOnCancel: true | yes | keeps running |
debounce.cancel() or throttle.cancel() with a call waiting | no | exits with code 1 |
signal aborted while a call is waiting | no | exits with code 1 |
call made after the signal was aborted | no | exits with code 1 |
The last row is the easiest to hit. After the signal aborts, every call returns a rejected promise, so one late keystroke handler that fires after a component’s AbortController has aborted is enough to end the process.
There is one more lodash difference, and it doesn’t throw. The docs say “When invoked, fn has the debounced function as its this value”. lodash passes the caller’s this through. So a debounced method defined with function sees the wrong receiver:
import { debounce } from 'node:util'
const editor = {
title: 'notes.md',
save: debounce(function () {
console.log('this === editor:', this === editor)
console.log('this === editor.save:', this === editor.save)
console.log('this.title:', this.title)
}, 50),
}
await editor.save()
this === editor: false
this === editor.save: true
this.title: undefined
The fix is to close over the object in an arrow function instead of relying on this.
What to do
Use each function for the job it was designed for.
| You want | Reach for |
|---|---|
| At most N outbound calls per interval, none lost (an API rate limit) | util.throttle(fn, limit, interval) |
| Collapse a burst into one call and await its result | util.debounce(fn, wait) |
| The latest value at most every N ms (progress, scroll, resize) | keep lodash’s throttle, or your own helper |
The queueing behavior that hurt the progress bar is exactly what a client-side rate limit needs. With two calls allowed per second, all five requests run and none are dropped:
import { throttle } from 'node:util'
const t0 = performance.now()
const fetchItem = throttle(async (id) => {
const started = Math.round((performance.now() - t0) / 100) * 100
return `item ${id} started at ~${started}ms`
}, 2, 1000)
const results = await Promise.all([1, 2, 3, 4, 5].map((id) => fetchItem(id)))
console.log(results.join('\n'))
item 1 started at ~0ms
item 2 started at ~0ms
item 3 started at ~1000ms
item 4 started at ~1000ms
item 5 started at ~2000ms
Never leave the promise floating. When the caller is an event handler that can’t await, attach a handler that ignores the cancellations you caused yourself and reports everything else:
import { debounce } from 'node:util'
const save = debounce(async (doc) => {
if (doc === 'bad') throw new Error('disk full')
console.log('saved', doc)
}, 100)
function onEdit(doc) {
save(doc).catch((err) => {
if (err.name === 'AbortError') return // cancelled or aborted on purpose
console.error('save failed:', err.message)
})
}
onEdit('draft 1')
save.cancel()
onEdit('bad')
setTimeout(() => console.log('still running'), 300)
save failed: disk full
still running
The cancelled call gets ignored, the real failure gets reported, and the process keeps running. Don’t solve this with a process-wide unhandledRejection listener. That would also hide rejections that are genuine bugs.
Feature-detect if you support older Node versions. A named import fails before any of your code runs. On Node 26.2.0, import { debounce } from 'node:util' fails with:
SyntaxError: The requested module 'node:util' does not provide an export named 'debounce'
A namespace import lets you check first:
import * as util from 'node:util'
console.log(process.version, 'has util.debounce:', typeof util.debounce === 'function')
v26.2.0 has util.debounce: false
v26.10.0 has util.debounce: true
Caveats
- This is a new API, and everything here was tested on 26.10.0. The unhandled
cancel()rejection is the kind of thing a patch release could change. Rerun the cancel example above after each upgrade instead of assuming it still behaves this way. - They only exist from 26.10.0 on. Each function’s
addedhistory listsv26.10.0and nothing else, so no backport to the Node 24 or 22 lines is recorded. - A pending debounce keeps the process alive, just like a plain
setTimeout. A debounced call with a 1,000 ms wait held a script open for about a second. Call.unref()on the debounced function for inactivity timers that shouldn’t block shutdown. - The default throttle windows can bunch calls together. Node’s docs say the windowed behavior “can result in calls occurring close together at a window boundary.” Pass
strict: trueif the upstream enforces a rolling limit. It makes sure no more thanlimitcalls start within any rolling interval. - I tested lodash, not every userland library. Other debounce and throttle packages have their own defaults, so check yours before you swap it out.
References
- Node.js 26.10.0 release notes
- Node.js docs:
util.debounce() - Node.js docs:
util.throttle() - nodejs/node #65899: util: implement debounce (adds both functions)
- Source:
lib/internal/util/debounce.jsat v26.10.0 - Source:
lib/internal/util/throttle.jsat v26.10.0 - Node.js docs:
--unhandled-rejections=mode - lodash docs:
_.throttle