スクロール・リサイズ・入力のように高頻度で発火するイベントをそのまま処理すると、パフォーマンス低下の原因になります。これを防ぐのが debounce(デバウンス)と throttle(スロットル)です。似ていますが挙動が異なり、用途で使い分けます。この記事では両者の違いと、実務で使える堅牢な実装を解説します。
debounce/throttleの違いと実装- 最初の1回をすぐ実行する immediate(leading edge)
- throttleが末尾の呼び出しを取りこぼす問題と対策(leading + trailing)
- 描画更新に最適な
requestAnimationFrame版 - 本番で使う Lodash と、scroll の
passiveリスナー
debounce(入力検索・リサイズ後)、一定間隔で間引きながら追従したいなら throttle(スクロール・マウス移動)。描画の更新なら requestAnimationFrame ベースが最適です。debounce:止まってから一度だけ
debounce は「最後のイベントから一定時間が経つまで処理を遅らせる」仕組みです。連続発火の間は実行されず、止まったタイミングで1回だけ呼ばれます。入力が落ち着いてから検索する、といった用途に向きます。
function debounce(func, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => func.apply(this, args), delay);
};
}
// 入力が止まって500ms後に検索
const handleInput = debounce((e) => {
console.log("検索ワード:", e.target.value);
}, 500);
document.querySelector("#search").addEventListener("input", handleInput);
debounceの応用:immediate(leading)と cancel
「最初の1回はすぐ実行し、その後の連打は無視」したいことがあります(ボタンの初回即時反応など)。immediate オプションで切り替えられるようにし、保留中の実行を取り消す cancel も付けると実務で使いやすくなります。
function debounce(func, delay, { immediate = false } = {}) {
let timer = null;
function debounced(...args) {
const callNow = immediate && timer === null;
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (!immediate) func.apply(this, args); // 通常は末尾で実行
}, delay);
if (callNow) func.apply(this, args); // immediate は先頭で実行
}
debounced.cancel = () => { clearTimeout(timer); timer = null; };
return debounced;
}
SPAでコンポーネントが破棄されるときなどに
handler.cancel() を呼べば、破棄後に遅延実行が走るのを防げます。タイマー制御の基礎はsetTimeout の使い方を参照してください。throttle:一定間隔ごとに実行
throttle は「一定間隔に最大1回だけ実行する」仕組みです。イベントが何度発火しても頻度を制御できます。スクロール位置の監視などに向きます。まずは直近の実行時刻を記録するシンプルな実装です。
function throttle(func, interval) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime >= interval) {
func.apply(this, args);
lastTime = now;
}
};
}
// スクロール時に200msごとに処理
const handleScroll = throttle(() => {
console.log("スクロール位置:", window.scrollY);
}, 200);
// scroll は passive: true で性能を確保
window.addEventListener("scroll", handleScroll, { passive: true });
throttleの落とし穴:末尾の呼び出しを取りこぼす
上の基本実装には弱点があります。最後のイベントが間隔内に起きると、その呼び出しが捨てられます。スクロールが止まる直前の「最終位置」を取り損ねる、といった問題です。先頭(leading)と末尾(trailing)の両方で実行する版にすると確実になります。
function throttle(func, interval) {
let lastTime = 0;
let timer = null;
let savedArgs = null;
let savedThis = null;
return function (...args) {
const now = Date.now();
const remaining = interval - (now - lastTime);
savedArgs = args;
savedThis = this;
if (remaining <= 0) {
// 間隔を過ぎた → すぐ実行(leading)
if (timer) { clearTimeout(timer); timer = null; }
lastTime = now;
func.apply(savedThis, savedArgs);
} else if (!timer) {
// 末尾で最後の1回を予約(trailing)
timer = setTimeout(() => {
lastTime = Date.now();
timer = null;
func.apply(savedThis, savedArgs);
}, remaining);
}
};
}
描画の更新は requestAnimationFrame で間引く
スクロールに合わせて要素を動かす・スタイルを変えるといった描画の更新では、ミリ秒指定の throttle より requestAnimationFrame(rAF)が最適です。ブラウザの描画タイミングに合わせて1フレームに1回だけ実行され、無駄がありません。
function rafThrottle(func) {
let scheduled = false;
return function (...args) {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
func.apply(this, args);
scheduled = false;
});
};
}
// スクロール量に応じて描画を更新(1フレーム1回)
window.addEventListener("scroll", rafThrottle(() => {
document.documentElement.style.setProperty("--scroll-y", window.scrollY + "px");
}), { passive: true });
スクロールイベントの扱い全般はスクロールイベントの使い方(throttle・IntersectionObserver)も参考になります。
debounce と throttle の比較
| 特徴 | debounce | throttle |
|---|---|---|
| 実行タイミング | イベントが止まった後に一度だけ | 一定間隔ごとに最大1回 |
| 処理回数 | 連続中は0、最後に1回 | 間隔内に1回ずつ継続 |
| 代表的な用途 | 入力検索・リサイズ後・オートセーブ | スクロール監視・マウス移動・無限スクロール |
| 体感 | 「落ち着いたら反応」 | 「定期的に追従」 |
使い分けと本番での選択肢
ユーザー操作が完了したタイミングで1回処理したいなら debounce、継続的に監視しつつ負荷を抑えたいなら throttle、描画更新なら rAF を選びます。ボタンの連打防止のように「処理が終わるまで無効化」したい場合は、debounce/throttleとは別アプローチが向きます(ボタンの連打を防止する方法)。また一定時間の無操作を検知したいなら一定時間操作がなかった場合に処理を実行する方法が参考になります。
自前実装の代わりに、
lodash.debounce / lodash.throttle を使う方法もあります。leading / trailing / maxWait などのオプションや cancel() / flush() が揃っており、エッジケースまで考慮されています。必要な関数だけ個別インポート(import debounce from "lodash/debounce")すれば軽量です。よくある質問(FAQ)
debounce(入力検索・リサイズ後)、「一定間隔で継続的に間引く」なら throttle(スクロール・マウス移動)です。要素を動かす描画更新は requestAnimationFrame ベースが最適です。throttle を使ってください。immediate(leading edge)オプションを付けます。timer === null のとき先頭で実行し、その後の連打は遅延させる実装にします。cancel メソッドを持たせ、内部の clearTimeout を呼びます。SPAでコンポーネント破棄時に呼べば、破棄後の遅延実行を防げます。Lodashなら fn.cancel() が使えます。{ passive: true } で登録し、描画更新は requestAnimationFrame ベースで間引きます。スクロール量の判定だけなら IntersectionObserver も検討してください。まとめ
- debounce:止まってから一度だけ。入力検索・リサイズ後に
- throttle:一定間隔ごとに。スクロール・マウス移動に
- throttleは末尾を取りこぼす→ leading + trailing 版で確実に
- 描画更新は rAF:1フレーム1回で滑らか
- scrollは passive、本番は Lodash も選択肢
イベントの性質に合わせて debounce / throttle / rAF を選べば、パフォーマンスと操作感を両立できます。

