Node.js Put Debounce in Core, and That’s Not Obvious

The safest dependency you can ship is the one that isn’t in your package.json. That instinct explains why util.debounce() and util.throttle() landing in node:util got a warm reception. I understand the appeal. I still think a scheduling policy is the wrong thing to freeze into the runtime, and the abort-handling argument that came out of review is the reason.

James M Snell landed both helpers in one pull request for Node.js 26.10.0, with Matteo Collina noting he uses them in essentially every app. Collina is right about the frequency. Debounce and throttle have been vendored, copied between teams, and reimplemented badly for a decade. Most node_modules trees carry throttle-debounce or drag in all of lodash to reach one function. Cutting those out is real.

The abort signal was the interesting part

Reviewers ljharb and bakkot caught something that says a lot about the domain. The first version wired cancellation through an AbortSignal. One-way door. Once it fires, it stays fired, and a debounced function is called over and over precisely so that most of those calls get discarded, which means a signal that cancels exactly once models the problem backwards.

Next call is the cancellation. No extra object is needed to express that, and a debounced function already carries the only signal it will ever require, which is why wiring a one-shot lifetime object through it read as a mismatch to the reviewers who caught it.

The cancellation model is the API. Get it wrong in a library and you ship a patch. Get it wrong in the runtime and you ship a lifetime commitment.

The rework that shipped cancels every future invocation instead of a single pending timer, and adds a leading-edge option so the first call can fire immediately. Both are the right calls. Neither is obvious from the outside, and that’s the tell. A userland package can learn this through three major versions. Core cannot.

Where core additions actually pay off

Compare crypto.parsePKCS12() in the same release. Node had parsed p12 and pfx bundles internally for years to service the TLS pfx option. The capability existed and was simply not exposed to JavaScript, so anyone who needed a private key out of a bundle shelled out to the openssl CLI or pulled in node-forge. That’s a gap with an obvious correct shape: input is bytes, output is an object carrying privateKey, certificate, and additionalCertificates. No scheduling policy, no lifecycle semantics, no opinion about leading versus trailing edges. There is nothing to get subtly wrong for the next seven years.

That’s the rule I would apply to core proposals. Add the thing whose semantics a standard fixes, not the thing whose semantics are a matter of taste.

Debounce is taste.

Ask five engineers whether the trailing call should carry the arguments of the first invocation or the last, and you get a real debate. Ask whether a throttled function should drop its trailing call or queue it, same. These are policy questions with legitimate answers on both sides, and the answers vary by product. Core picks one for everyone, forever.

Throttle makes it worse. The lodash throttle is debounce with a maxWait, which means the two functions in core are not independent primitives. They’re two configurations of one, shipped as separate top-level exports, which freezes that relationship into the type surface. If the implementation needs to change shape in 2029, both signatures are load-bearing.

The supply-chain argument is weaker than it looks

Removing a handful of small dependencies from typical trees is a fair point, though I’d weigh it less than the announcement implies. throttle-debounce is a few hundred lines with no transitive dependencies and a version that hasn’t moved in years. Pinning it costs nearly nothing. The packages that hurt in an audit have wide trees and post-install scripts, and none of those are debounce.

Which leaves the honest case for core: not supply chain, but the fact that every team writes this function badly on the first try. Fine. That argues for a good reference implementation more than it argues for freezing one in the runtime.

One addition in this release deserves more attention than the headline got. util.markPromiseAsHandled() is an explicit mechanism for framework authors to tell the runtime a promise has been handled, aimed at the long-running confusion around unhandled-rejection signalling. That is codebase tax paid by everyone who ever built a promise wrapper and watched a spurious unhandledRejection fire. No policy question, no taste involved. That’s the shape of addition I want in core.

Node 26 is Active LTS as Lithium through April 2029 now, so you’ll run this on production hosts soon whether or not the API ages well. My own split: util.debounce for the plain trailing case, a wrapper wherever I need argument semantics that aren’t the default. Both will work. Only one of them will still be changeable in 2031.

Press ⌘K to search & switch themes