Node.js に debounce と throttle が標準搭載
Node.js 26.10.0と24.22.0にutil.debounceとutil.throttleが追加。オプションやPromiseの挙動、lodashとの違いを解説します。
Node.js 26.10.0 および 24.22.0 で、node:util モジュールに util.debounce() と util.throttle() が追加されました。これにより、lodash や throttle-debounce をインストールしなくても、サーバーコードで関数のデバウンスやレート制限を行えます。
ファイルウォッチャーのために setTimeout/clearTimeout のラッパーを何度も書いてきた方や、lodash の他の機能を一切使わないサービスに lodash.debounce だけを追加してきた方も、もうその必要はありません。どちらのヘルパーもランタイムに組み込まれました。
本記事では、対象となるリリースライン、2 つの関数のシグネチャとオプション、それぞれのサーバーサイドでの使用例、そして lodash を削除する際に必要な変更点を解説します。本記事は、npm パッケージを置き換える Node.js 組み込み API シリーズの続編です。
重要ポイント
util.debounce()とutil.throttle()は Node.js 26.10.0 以降および 24.22.0 以降で利用できます。どちらも nodejs/node の PR #65899 で追加されました。- デバウンスされた関数の呼び出しはすべて Promise を返します。1 つのデバウンスウィンドウ内のすべての呼び出しは、
fnの 1 回の実行結果で settle されます。 util.throttle(fn, limit, interval)は、intervalミリ秒ごとに最大limit回まで実行の開始を許可します。上限を超えた呼び出しは、デフォルトでは破棄されずにキューに入れられます。util.throttle()によって破棄された呼び出しはERR_THROTTLEDで reject され、Node はそれらの reject を処理済みとしてマークします。- どちらの関数にも
trailingオプションやmaxWaitオプションはないため、lodash から移行するコードは Node のオプション体系に合わせて調整する必要があります。
util.debounce と util.throttle が利用できる Node.js のバージョン
両関数は、2026 年 9 月 22 日にリリースされた Current リリースの Node.js 26.10.0 で初めて導入されました。その後、2026 年 10 月 14 日に Node.js 24.22.0 ‘Krypton’ LTS へバックポートされています。どちらの変更も James M Snell による PR #65899 によるものです。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() は呼び出しが wait ミリ秒間途絶えた後に fn を 1 回実行します。一方、util.throttle() は各 interval 内で開始できる fn の呼び出し回数を制限します。ゼロから実装する場合の仕組みについては、パフォーマンスのためのデバウンスとスロットリングを参照してください。
Node.js における util.debounce の仕組み
util.debounce(fn, wait[, options]) は、最後の呼び出しから wait ミリ秒が経過するまで待ってから fn を実行します。そのウィンドウ内で行われたすべての呼び出しは Promise を返し、その Promise は 1 回の実行結果で settle されます。util.debounce のリファレンスには、次の 3 つのオプションが記載されています。
| オプション | デフォルト | 効果 |
|---|---|---|
leading | false | true の場合、ウィンドウ内の最初の呼び出しで fn を即座に実行する |
rejectOnCancel | false | true の場合、後続の呼び出しに置き換えられた呼び出しは AbortError で reject される |
signal | なし | abort すると保留中の呼び出しがキャンセルされ、以降の呼び出しもブロックされる。すでに abort 済みの 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 の外側にイベントを保存しておき、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() を呼び出すと、タイマーが保留中でもプロセスを終了できるようになります。これは、シャットダウンを妨げるべきではないファイルウォッチャーやログフラッシャーに適しています。unref() は関数自身を返すため、debounce() の呼び出しにチェーンできます。シャットダウン時には flush() で最後のバッチを実行し、データが失われないようにします。
throttle の ERR_THROTTLED による reject とは異なり、debounce はすべての reject を処理済みとしてマークするわけではありません。Node のソースコードでは、後続の呼び出しに置き換えられた呼び出しの Promise は処理済みとしてマークされますが、最後の呼び出しの Promise はマークされません。cancel() や abort された signal によってその Promise が reject され、どこでも catch されなければ、プロセスで unhandled rejection が発生します。上記のウォッチャーのコールバックのような投げっぱなし (fire-and-forget) の呼び出しには、.catch を付けてください。
util.throttle による呼び出しのレート制限の仕組み
util.throttle(fn, limit, interval[, options]) は、interval ミリ秒ごとに最大 limit 回まで fn の実行開始を許可します。limit は 0 より大きい値である必要があります。デフォルトでは、上限を超えた呼び出しは破棄されず、到着順にキューに入れられます。util.throttle のリファレンスでは、次のオプションが定義されています。
concurrency(デフォルトInfinity): 同時に実行中にできるfn呼び出しの最大数。fnの戻り値が settle された時点でスロットが解放されます。maxPending(デフォルトInfinity): キューサイズの上限。drop モードでは効果がありません。overflow(デフォルトは'queue'、または'drop')。strict(デフォルトfalse)。signal: abort されると、保留中および以降の呼び出しがAbortErrorで reject されます。
レートの容量が消費されるのは、呼び出しがキューに入ったときではなく、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() は固定ウィンドウ単位で実行回数をカウントします。そのため、ウィンドウの境界付近で 2 つの呼び出しグループが立て続けに開始される可能性があります。strict: true を設定すると、あらゆるスライディング (ローリング) インターバルにわたって上限が適用されるようになります。
関数を 1 秒あたり最大 1 回だけ実行し、余分な呼び出しをキューに入れずに破棄したい場合は、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 エラーで reject されます。呼び出しが破棄されるのは、overflow が 'drop' の場合か、maxPending のキューが満杯の場合です。Node はこれらの Promise を処理済みとしてマークし、破棄された呼び出しはレートや同時実行数の容量を消費しません。cancel() はキュー内の呼び出しをクリアしてウィンドウをリセットしますが、すでに開始された実行は停止しません。
lodash.debounce と lodash.throttle の置き換え方
ほとんどのサーバーコードでは、util.debounce() と util.throttle() で lodash.debounce と lodash.throttle を置き換えられます。ただし、lodash とはモデルが異なります。Node の throttle は interval ごとに開始できる呼び出し数を制限し、残りをキューに入れます。一方、lodash の throttle は wait ごとに最大 1 回の呼び出しを許可します。コードを移行する際は、node:util のオプション体系のみを使用してください。
| 項目 | node:util の動作 | 変更すべき点 |
|---|---|---|
| 戻り値 | すべての呼び出しが Promise を返す | await または .catch で Promise を処理する |
| エッジオプション | leading のみ。trailing や maxWait オプションはない | それらのキーを削除し、maxWait のロジックは throttle を使うなどして再設計する |
| throttle のモデル | interval ごとに limit 回の呼び出し開始を許可し、超過分はキューに入れる | 超過分をスキップする動作にしたい場合は、limit を 1 にして overflow: 'drop' を指定する |
| 手動制御 | cancel([reason]) と flush() | ラッパーで直接呼び出す |
this | fn 内の this はラッパーになる | メソッドは事前に bind しておく |
// 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 や独自ヘルパーを使い続けるべきケース
次の 2 つのケースでは、node:util ではなく、lodash、throttle-debounce、または自作のヘルパーを使い続けてください。
- ブラウザ向けのコード。
node:utilはサーバーサイドのモジュールです。フロントエンドのバンドルには引き続き独自のヘルパーが必要です。React におけるデバウンス戦略を参照してください。 - 24.22.0 より古い Node のバージョン。 これには、それ以前の 24.x パッチとそれより古いすべてのリリースラインが含まれます。これらのバージョンをサポートする必要があるプロジェクトでは、依存関係を維持するか、機能検出を追加してください。
まとめ
Node.js 26.10.0 以降および 24.22.0 以降では、デバウンスとレート制限が node:util の一部になりました。どちらの関数も Promise を返し、cancel() と unref() を標準で備えています。さらに、デバウンスされた関数には flush() もあります。次のステップとして、サーバーコード内で lodash.debounce や lodash.throttle を使用している箇所を探し、ファイルウォッチャーや外部 API クライアントを 1 つ、組み込み関数に置き換えてみてください。メソッドはラップする前に bind し、await しない呼び出しにはすべて .catch を付けるようにしましょう。
よくある質問
leading: true を指定した util.debounce は関数を 2 回実行しますか?
ウィンドウ内でさらに呼び出しがあった場合に限ります。leading を true に設定すると、デバウンスウィンドウ内の最初の呼び出しで fn が即座に実行されます。2 回目の trailing 側の実行が行われるのは、待機時間が終了する前にデバウンスされた関数が再度呼び出された場合のみです。したがって、単独の呼び出しでは fn は leading 側で 1 回だけ実行されます。なお、無効化するための trailing オプションは存在しません。
util.debounce は、非同期関数の完了を待ってから新しいウィンドウを開始しますか?
いいえ。trailing 側の実行後、fn が返した Promise がまだ settle されていなくても、util.debounce は新しいデバウンスウィンドウを開始できます。そのため、処理に時間のかかる非同期の fn では、2 つの実行が重複する可能性があります。実行の重複を防ぐには、concurrency を 1 に設定した util.throttle を使用してください。throttle の同時実行スロットは、fn の戻り値が settle されたときにのみ解放されます。
古いバージョンの Node.js で壊れないように util.debounce を機能検出するにはどうすればよいですか?
モジュール全体をインポートしてプロパティを確認します。import util from 'node:util' でインポートし、使用前に typeof util.debounce === 'function' を確認して、存在しない場合はユーザーランドのヘルパーにフォールバックします。ESM では、存在しないエクスポートを名前付きインポートすると、モジュールのリンク時に SyntaxError が発生するため、確認処理を実行する機会すらありません。
util.throttle の AbortSignal が abort されると、キュー内の呼び出しはどうなりますか?
reject されます。util.throttle に渡した signal が abort されると、保留中および以降の呼び出しは AbortError で reject され、signal の reason がエラーの cause に設定されます。すでに開始された実行は継続します。ERR_THROTTLED による reject とは異なり、これらの AbortError による reject が処理済みとしてマークされるとはドキュメントに記載されていないため、await しない呼び出しにはすべて catch ハンドラーを付けてください。
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