~/blog

Node 26.10's util.debounce and util.throttle are not lodash drop-ins

published

#node#javascript#async#release

A dense burst of yellow-green strokes on a dark background narrows into a wave, passes an empty white box, and becomes a single-file row of pink squares ending in two green squares, all along a thin white timeline.
Generated illustration

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:

RejectionMarked as handledFire-and-forget result
throttle call dropped by overflow: 'drop' or a full maxPending queueyes (documented)keeps running
debounce superseded call with rejectOnCancel: trueyeskeeps running
debounce.cancel() or throttle.cancel() with a call waitingnoexits with code 1
signal aborted while a call is waitingnoexits with code 1
call made after the signal was abortednoexits 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 wantReach 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 resultutil.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

References