12k
All articles

В Node.js появились встроенные debounce и throttle

В Node.js 26.10.0 и 24.22.0 появились util.debounce и util.throttle. Разберитесь в их параметрах, промисах и отличиях от lodash.

OpenReplay Team
OpenReplay Team
В Node.js появились встроенные debounce и throttle

В 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 описаны три параметра:

ПараметрПо умолчаниюДействие
leadingfalseЕсли true, первый вызов в окне сразу запускает fn
rejectOnCancelfalseЕсли 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.

DevTools for the frontend

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

We use cookies to improve your experience. By using our site, you accept cookies.