В Node.js появились встроенные debounce и throttle
В Node.js 26.10.0 и 24.22.0 появились util.debounce и util.throttle. Разберитесь в их параметрах, промисах и отличиях от lodash.
В Node.js 26.10.0 и 24.22.0 в модуль node:util добавлены функции util.debounce() и util.throttle(). Теперь серверный код может применять debounce и ограничивать частоту вызовов функций без установки lodash или throttle-debounce.
Если вы в очередной раз писали обёртку на setTimeout/clearTimeout для отслеживания изменений файлов или подключали lodash.debounce к сервису, который больше ничего из lodash не использует, теперь в этом нет необходимости. Обе функции стали частью среды выполнения.
В статье рассматриваются релизные ветки, в которых появились новые функции, их сигнатуры и параметры, примеры серверного кода для каждой из них, а также изменения, необходимые при отказе от lodash. Статья продолжает наш цикл о встроенных API Node.js, заменяющих npm-пакеты.
Ключевые выводы
util.debounce()иutil.throttle()доступны в Node.js 26.10.0+ и 24.22.0+. Обе функции добавлены в nodejs/node PR #65899.- Каждый вызов debounce-функции возвращает Promise. Все вызовы в пределах одного окна debounce завершаются с результатом единственного вызова
fn. util.throttle(fn, limit, interval)допускает запуск не болееlimitвызовов заintervalмиллисекунд, а лишние вызовы по умолчанию ставит в очередь, а не отбрасывает.- Вызовы, отброшенные
util.throttle(), отклоняются с ошибкойERR_THROTTLED, и Node помечает такие отклонения как обработанные. - Ни у одной из функций нет параметров
trailingилиmaxWait, поэтому код, перенесённый с lodash, необходимо адаптировать к набору параметров Node.
В каких версиях Node.js есть util.debounce и util.throttle
Обе функции впервые появились в Node.js 26.10.0 — релизе ветки Current от 22 сентября 2026 года. 14 октября 2026 года они были бэкпортированы в Node.js 24.22.0 ‘Krypton’ LTS. Оба изменения внесены в PR #65899 от James M Snell. В документации util для каждой функции указано «Added in: v26.10.0», однако в примечаниях к релизу 24.22.0 обе функции перечислены в разделе Notable Changes. В ветке 24.x минимальная версия — 24.22.0, поэтому они есть и во всех последующих патчах 24.x.
Если вы публикуете библиотеку с поддержкой старых версий Node, перед использованием проверяйте наличие util.debounce или сохраняйте запасной вариант.
Чем debounce отличается от throttle?
В Node.js util.debounce() выполняет fn один раз после того, как вызовы перестают поступать в течение wait миллисекунд, а util.throttle() ограничивает число вызовов fn, которые могут быть запущены в каждом интервале interval. О том, как эти механизмы устроены изнутри, читайте в статье о debounce и throttle для повышения производительности.
Как работает util.debounce в Node.js?
util.debounce(fn, wait[, options]) ждёт, пока с момента последнего вызова пройдёт wait миллисекунд, и затем вызывает fn. Каждый вызов, сделанный в этом окне, возвращает Promise, который завершается с результатом этого единственного вызова. В справочнике по util.debounce описаны три параметра:
| Параметр | По умолчанию | Действие |
|---|---|---|
leading | false | Если true, первый вызов в окне сразу запускает fn |
rejectOnCancel | false | Если true, вытесненные вызовы отклоняются с ошибкой AbortError |
signal | нет | Прерывание отменяет ожидающие вызовы и блокирует будущие. Если сигнал уже прерван, debounce() выбрасывает исключение |
У возвращаемой функции также есть cancel([reason]), flush(), pending, pendingCount, ref() и unref().
Требуется Node.js ≥ 24.22.0 или ≥ 26.10.0.
import { debounce } from 'node:util';
const save = debounce(async (value) => `saved:${value}`, 100);
const calls = [save('a'), save('b'), save('c')];
console.log(save.pendingCount); // 3
console.log(await Promise.all(calls)); // [ 'saved:c', 'saved:c', 'saved:c' ]
fn получает только аргументы последнего вызова. Чтобы собрать все события из серии, сохраняйте их вне fn и считывайте при её запуске. Так и поступает приведённый ниже обработчик изменений файлов, использующий Set:
import { watch } from 'node:fs';
import { debounce } from 'node:util';
const changed = new Set();
const rebuild = debounce(async () => {
const batch = [...changed];
changed.clear();
console.log('rebuilding for', batch);
return batch;
}, 200).unref();
const watcher = watch('./src', (eventType, filename) => {
changed.add(filename);
rebuild().catch(console.error);
});
process.on('SIGINT', () => {
rebuild.flush();
watcher.close();
});
Вызов unref() для debounce- или throttle-функции позволяет процессу завершиться, даже если её таймер ещё не сработал. Это удобно для отслеживания изменений файлов и сброса логов: такие задачи не должны блокировать завершение работы. unref() возвращает саму функцию, поэтому его можно вызвать цепочкой сразу после debounce(). При завершении работы flush() обрабатывает последний пакет изменений, чтобы он не был потерян.
В отличие от отклонений ERR_THROTTLED у throttle, debounce помечает как обработанные не все отклонения. В исходном коде Node промис вызова помечается как обработанный, когда его вытесняет более поздний вызов, но промис самого последнего вызова таковым не помечается. Если cancel() или прерванный signal отклонит этот промис, а его никто не перехватит, процесс получит необработанное отклонение (unhandled rejection). Для вызовов по принципу «вызвал и забыл», как в обработчике выше, добавляйте .catch.
Как util.throttle ограничивает частоту вызовов?
util.throttle(fn, limit, interval[, options]) допускает запуск не более limit вызовов fn в каждом интервале длительностью interval миллисекунд. Значение limit должно быть больше 0. По умолчанию вызовы сверх лимита не отбрасываются, а ставятся в очередь в порядке поступления. В справочнике по util.throttle определены следующие параметры:
concurrency(по умолчаниюInfinity): максимальное число одновременно выполняющихся вызововfn. Слот освобождается, когда завершается значение, возвращённоеfn.maxPending(по умолчаниюInfinity): максимальный размер очереди. В режиме отбрасывания не действует.overflow(по умолчанию'queue', либо'drop').strict(по умолчаниюfalse).signal: при прерывании ожидающие и будущие вызовы отклоняются с ошибкойAbortError.
Лимит частоты расходуется в момент запуска fn, а не при постановке вызова в очередь. Функция также предоставляет cancel([reason]), hasImmediateCapacity(), pending, pendingCount, activeCount, ref() и unref().
import { setTimeout as sleep } from 'node:timers/promises';
import { throttle } from 'node:util';
const callApi = throttle(async (id) => {
await sleep(50); // stand-in for a real request
return { id };
}, 5, 1_000, { concurrency: 2, maxPending: 100 });
const calls = Array.from({ length: 12 }, (_, i) => callApi(i));
console.log('active:', callApi.activeCount, 'queued:', callApi.pendingCount);
console.log(await Promise.all(calls)); // results in call order
По умолчанию util.throttle() подсчитывает вызовы в фиксированных окнах. Из-за этого на границе окон две группы вызовов могут запуститься почти одновременно. Параметр strict: true обеспечивает соблюдение лимита в любом скользящем интервале.
Чтобы выполнять функцию не чаще одного раза в секунду и отбрасывать лишние вызовы, а не ставить их в очередь, используйте throttle(fn, 1, 1000, { overflow: 'drop' }):
import { throttle } from 'node:util';
const ping = throttle(async () => 'pong', 1, 1_000, { overflow: 'drop' });
const results = await Promise.allSettled([ping(), ping(), ping()]);
for (const r of results) {
console.log(r.status === 'fulfilled' ? r.value : r.reason.code);
}
// pong, ERR_THROTTLED, ERR_THROTTLED
if (ping.hasImmediateCapacity()) ping(); // gate instead of catching
Вызовы, отброшенные util.throttle(), отклоняются с ошибкой ERR_THROTTLED. Вызов отбрасывается либо потому, что overflow имеет значение 'drop', либо потому, что очередь maxPending заполнена. Node помечает такие промисы как обработанные, а отброшенные вызовы не расходуют ни лимит частоты, ни слоты параллелизма. cancel() очищает очередь и сбрасывает окно, но не останавливает уже запущенные вызовы.
Как заменить lodash.debounce и lodash.throttle?
util.debounce() и util.throttle() могут заменить lodash.debounce и lodash.throttle в большей части серверного кода, однако их модель отличается от модели lodash. Throttle в Node ограничивает число вызовов, запускаемых за интервал, а остальные ставит в очередь. Throttle в lodash допускает не более одного вызова за wait. При переносе кода опирайтесь только на набор параметров node:util:
| Аспект | Поведение node:util | Что изменить |
|---|---|---|
| Возвращаемое значение | Каждый вызов возвращает Promise | Обрабатывайте Promise с помощью await или .catch |
| Параметры срабатывания | Только leading; параметров trailing и maxWait нет | Удалите эти ключи и переосмыслите логику maxWait, например, с помощью throttle |
| Модель throttle | Допускает limit запусков за interval, лишние ставит в очередь | Для пропуска лишних вызовов используйте limit, равный 1, и overflow: 'drop' |
| Ручное управление | cancel([reason]) и flush() | Вызывайте их непосредственно у обёртки |
this | Внутри fn this указывает на обёртку | Заранее привязывайте методы |
// before: hand-rolled
let timer;
const saveLater = (doc) => {
clearTimeout(timer);
timer = setTimeout(() => store.save(doc), 500);
};
// after
import { debounce } from 'node:util';
const saveLater = debounce(store.save.bind(store), 500);
saveLater(doc).catch(console.error);
// on shutdown: saveLater.flush(); or discard: saveLater.cancel();
Когда стоит оставить lodash или собственную реализацию
Сохраните lodash, throttle-debounce или собственную вспомогательную функцию вместо node:util в двух случаях:
- Браузерный код.
node:util— серверный модуль. Фронтенд-бандлам по-прежнему нужна собственная реализация; см. стратегии debounce в React. - Версии Node ниже 24.22.0. Это более ранние патчи 24.x и все более старые релизные ветки. Проектам, которые должны их поддерживать, следует сохранить зависимость или добавить проверку наличия функций.
Заключение
В Node.js 26.10.0+ и 24.22.0+ debounce и ограничение частоты вызовов входят в node:util. Обе функции возвращают Promise и имеют встроенные cancel() и unref(), а debounce-функция дополнительно предоставляет flush(). В качестве следующего шага найдите места в серверном коде, где используются lodash.debounce или lodash.throttle, и замените встроенной функцией хотя бы один обработчик изменений файлов или клиент исходящих запросов. Привязывайте методы перед оборачиванием и добавляйте .catch к каждому вызову, результат которого вы не ожидаете через await.
Часто задаваемые вопросы
Запускает ли util.debounce с leading: true функцию дважды?
Только если в течение окна поступают новые вызовы. При leading, равном true, первый вызов в окне debounce сразу запускает fn. Второй, завершающий (trailing) вызов происходит, только если debounce-функция была вызвана повторно до окончания периода ожидания. Поэтому одиночный вызов запускает fn ровно один раз — по переднему фронту, а параметра trailing, который можно было бы отключить, нет.
Ждёт ли util.debounce завершения асинхронной функции, прежде чем открыть новое окно?
Нет. После завершающего вызова util.debounce может открыть новое окно debounce, даже если промис, возвращённый fn, ещё не завершился, поэтому два запуска медленной асинхронной fn могут пересечься. Чтобы исключить пересечение запусков, используйте util.throttle с concurrency, равным 1. Слот параллелизма в нём освобождается только после завершения значения, возвращённого fn.
Как проверить наличие util.debounce, не нарушив работу в старых версиях Node.js?
Импортируйте модуль целиком и проверьте свойство: import util from 'node:util', затем перед использованием убедитесь, что typeof util.debounce === 'function', а при отсутствии функции используйте пользовательскую реализацию. В ESM именованный импорт несуществующего экспорта завершается ошибкой SyntaxError на этапе связывания модуля, поэтому код проверки даже не успеет выполниться.
Что происходит с вызовами util.throttle в очереди при прерывании его AbortSignal?
Они отклоняются. При прерывании сигнала, переданного в util.throttle, ожидающие и будущие вызовы отклоняются с ошибкой AbortError, а причина (reason) сигнала устанавливается в качестве cause ошибки. Уже запущенные вызовы продолжают выполняться. В отличие от отклонений ERR_THROTTLED, в документации не сказано, что отклонения AbortError помечаются как обработанные, поэтому добавляйте обработчик catch к каждому вызову, результат которого вы не ожидаете через await.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k