12k
All articles

Node.js ya incluye debounce y throttle de forma nativa

Node.js 26.10.0 y 24.22.0 incorporan util.debounce y util.throttle. Conoce sus opciones, el uso de promesas y las diferencias con lodash.

OpenReplay Team
OpenReplay Team
Node.js ya incluye debounce y throttle de forma nativa

Node.js 26.10.0 y 24.22.0 añaden util.debounce() y util.throttle() al módulo node:util, de modo que el código de servidor puede aplicar debounce y limitar la tasa de llamadas de las funciones sin instalar lodash ni throttle-debounce.

Si alguna vez has escrito otro wrapper más con setTimeout/clearTimeout para un vigilante de archivos, o has añadido lodash.debounce a un servicio que no usa nada más de lodash, ya no tienes que hacerlo. Ambos helpers forman ahora parte del runtime.

Este artículo explica las líneas de versiones que los incluyen, las dos firmas y sus opciones, ejemplos de servidor para cada una y lo que debes cambiar al eliminar lodash. Continúa nuestra serie sobre las API integradas de Node.js que sustituyen a paquetes de npm.

Puntos clave

  • util.debounce() y util.throttle() están disponibles en Node.js 26.10.0+ y 24.22.0+. Ambas proceden del PR #65899 de nodejs/node.
  • Cada llamada a una función con debounce devuelve una Promise. Todas las llamadas de una misma ventana de debounce se resuelven con el resultado de una única invocación de fn.
  • util.throttle(fn, limit, interval) permite que comiencen como máximo limit invocaciones por cada interval milisegundos y, por defecto, encola las llamadas adicionales en lugar de descartarlas.
  • Las llamadas descartadas por util.throttle() se rechazan con ERR_THROTTLED, y Node marca esos rechazos como gestionados.
  • Ninguna de las dos funciones tiene opción trailing ni maxWait, por lo que el código migrado desde lodash debe adaptarse al conjunto de opciones de Node.

Qué versiones de Node.js incluyen util.debounce y util.throttle

Ambas funciones aparecieron por primera vez en Node.js 26.10.0, una versión Current publicada el 22 de septiembre de 2026. Se portaron (backport) a Node.js 24.22.0 ‘Krypton’ LTS el 14 de octubre de 2026. Los dos cambios provienen del PR #65899 de James M Snell. La documentación de util indica “Added in: v26.10.0” para cada función, pero las notas de la versión 24.22.0 incluyen ambas en Notable Changes. En la línea 24.x, la versión mínima es la 24.22.0, por lo que cualquier parche 24.x posterior también las incluye.

Si publicas una librería que da soporte a versiones antiguas de Node, comprueba que util.debounce existe antes de usarla, o mantén una alternativa de respaldo.

¿Cuál es la diferencia entre debounce y throttle?

En Node.js, util.debounce() ejecuta fn una sola vez cuando dejan de llegar llamadas durante wait milisegundos, mientras que util.throttle() limita cuántas llamadas a fn pueden comenzar en cada interval. Para ver cómo funcionan desde cero, consulta debouncing y throttling para mejorar el rendimiento.

¿Cómo funciona util.debounce en Node.js?

util.debounce(fn, wait[, options]) espera a que hayan transcurrido wait milisegundos desde la llamada más reciente y, a continuación, invoca fn. Cada llamada realizada en esa ventana devuelve una Promise que se resuelve con el resultado de esa única invocación. La referencia de util.debounce enumera tres opciones:

OpciónValor por defectoEfecto
leadingfalseSi es true, la primera llamada de una ventana invoca fn de inmediato
rejectOnCancelfalseSi es true, las llamadas reemplazadas por otras posteriores se rechazan con un AbortError
signalningunoAl abortarla, se cancelan las llamadas pendientes y se bloquean las futuras. Una señal ya abortada hace que debounce() lance una excepción

La función devuelta también expone cancel([reason]), flush(), pending, pendingCount, ref() y unref().

Requiere Node.js ≥ 24.22.0 o ≥ 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 solo recibe los argumentos de la llamada más reciente. Para recopilar todos los eventos de una ráfaga, almacénalos fuera de fn y léelos cuando se ejecute. El vigilante de archivos siguiente lo hace con un 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();
});

Llamar a unref() sobre una función con debounce o throttle permite que el proceso finalice aunque su temporizador siga pendiente. Esto resulta idóneo para vigilantes de archivos y volcadores de logs, que nunca deberían bloquear el apagado. unref() devuelve la propia función, así que puedes encadenarla a la llamada a debounce(). Durante el apagado, flush() ejecuta el último lote para que no se pierda.

A diferencia de los rechazos ERR_THROTTLED de throttle, debounce no marca todos los rechazos como gestionados. El código fuente de Node marca la promesa de una llamada como gestionada en cuanto una llamada posterior la reemplaza, pero no así la promesa de la llamada más reciente. Si cancel() o una signal abortada rechaza esa promesa y nada la captura, el proceso sufre un unhandled rejection. Para llamadas de tipo fire-and-forget, como la del callback del vigilante anterior, añade un .catch.

¿Cómo limita util.throttle la tasa de llamadas?

util.throttle(fn, limit, interval[, options]) permite que comiencen como máximo limit invocaciones de fn en cada interval milisegundos. limit debe ser mayor que 0. Por defecto, las llamadas que superan el límite se encolan por orden de llegada en lugar de descartarse. La referencia de util.throttle define estas opciones:

  • concurrency (por defecto Infinity): el número máximo de llamadas a fn en curso simultáneamente. Un hueco se libera cuando el valor de retorno de fn se resuelve.
  • maxPending (por defecto Infinity): el tamaño máximo de la cola. No tiene efecto en el modo de descarte.
  • overflow ('queue' por defecto, o 'drop').
  • strict (por defecto false).
  • signal: al abortarla, las llamadas pendientes y futuras se rechazan con un AbortError.

La capacidad de tasa se consume cuando fn comienza, no cuando una llamada entra en la cola. La función también expone cancel([reason]), hasImmediateCapacity(), pending, pendingCount, activeCount, ref() y 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

Por defecto, util.throttle() cuenta las invocaciones en ventanas fijas. Esto puede permitir que dos grupos de llamadas comiencen muy próximos entre sí en el límite entre ventanas. Si estableces strict: true, el límite se aplica en cualquier intervalo deslizante.

Para ejecutar una función como máximo una vez por segundo y descartar las llamadas adicionales en lugar de encolarlas, usa 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

Las llamadas que util.throttle() descarta se rechazan con un error ERR_THROTTLED. Una llamada se descarta porque overflow vale 'drop' o porque la cola de maxPending está llena. Node marca estas promesas como gestionadas, y las llamadas descartadas no consumen capacidad de tasa ni de concurrencia. cancel() vacía las llamadas encoladas y reinicia la ventana, pero no detiene las invocaciones que ya han comenzado.

¿Cómo sustituir lodash.debounce y lodash.throttle?

util.debounce() y util.throttle() pueden sustituir a lodash.debounce y lodash.throttle en la mayor parte del código de servidor, pero no siguen el mismo modelo que lodash. El throttle de Node limita cuántas llamadas pueden comenzar por intervalo y encola el resto. El throttle de lodash permite como máximo una llamada por cada wait. Migra tu código utilizando únicamente el conjunto de opciones de node:util:

AspectoQué hace node:utilQué cambiar
Valor de retornoCada llamada devuelve una PromiseGestiona la Promise con await o .catch
Opciones de flancoSolo leading; no existen las opciones trailing ni maxWaitElimina esas claves y replantea cualquier lógica de maxWait, por ejemplo con un throttle
Modelo de throttlePermite que comiencen limit llamadas por interval y encola las adicionalesUsa un limit de 1 junto con overflow: 'drop' para descartar las llamadas sobrantes
Control manualcancel([reason]) y flush()Llámalos directamente sobre el wrapper
thisDentro de fn, this es el wrapperVincula (bind) los métodos previamente
// 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();

Cuándo mantener lodash o un helper propio

Mantén lodash, throttle-debounce o un helper escrito a mano en lugar de node:util en dos casos:

  • Código de navegador. node:util es un módulo de servidor. Los bundles de front-end siguen necesitando su propio helper; consulta estrategias de debounce en React.
  • Versiones de Node anteriores a la 24.22.0. Esto incluye los parches 24.x anteriores y todas las líneas de versiones más antiguas. Los proyectos que deban dar soporte a esas versiones deberían conservar la dependencia o añadir una comprobación de disponibilidad.

Conclusión

En Node.js 26.10.0+ y 24.22.0+, el debounce y la limitación de tasa forman parte de node:util. Ambas funciones devuelven Promises e incorporan cancel() y unref(), y la función con debounce añade además flush(). Como siguiente paso, localiza dónde usa tu código de servidor lodash.debounce o lodash.throttle y sustituye un vigilante de archivos o un cliente saliente por la versión integrada. Vincula los métodos antes de envolverlos y añade un .catch a cada llamada que no esperes con await.

Preguntas frecuentes

¿util.debounce con leading: true ejecuta la función dos veces?

Solo si llegan más llamadas durante la ventana. Con leading establecido en true, la primera llamada de una ventana de debounce invoca fn de inmediato. Una segunda invocación, en el flanco final, solo se produce si la función con debounce se volvió a llamar antes de que terminara el periodo de espera. Por tanto, una única llamada aislada ejecuta fn exactamente una vez, en el flanco inicial, y no existe ninguna opción trailing que desactivar.

¿util.debounce espera a que termine una función asíncrona antes de abrir una nueva ventana?

No. Tras una invocación en el flanco final, util.debounce puede abrir una nueva ventana de debounce aunque la promesa devuelta por fn aún no se haya resuelto, por lo que dos ejecuciones de una fn asíncrona lenta pueden solaparse. Para evitar ejecuciones solapadas, usa util.throttle con concurrency establecido en 1. Su hueco de concurrencia solo se libera cuando el valor de retorno de fn se resuelve.

¿Cómo compruebo la disponibilidad de util.debounce sin que falle en versiones antiguas de Node.js?

Importa el módulo completo y comprueba la propiedad: import util from 'node:util', y después verifica typeof util.debounce === 'function' antes de usarla, recurriendo a un helper de userland si no existe. En ESM, una importación con nombre de una exportación inexistente falla con un SyntaxError al enlazar el módulo, por lo que el código nunca llega a ejecutar la comprobación.

¿Qué ocurre con las llamadas encoladas en util.throttle cuando se aborta su AbortSignal?

Se rechazan. Cuando se aborta la señal pasada a util.throttle, las llamadas pendientes y futuras se rechazan con un AbortError, y el reason de la señal se establece como cause del error. Las invocaciones que ya han comenzado siguen ejecutándose. A diferencia de los rechazos ERR_THROTTLED, la documentación no indica que estos rechazos AbortError se marquen como gestionados, así que añade un manejador catch a cada llamada que no esperes con 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.