コンテキストと理由:イベントハンドラがアプリを壊している可能性がある
こんな場面を想像してほしい。検索ボックスを作った。ユーザーが「docker compose」と入力する——それは14回のキー入力だ——するとフロントエンドが約2秒間に14件の個別APIリクエストを発行する。バックエンドは処理しようと必死になるが、レート制限が発動し、UIがもたつき始める。思い当たる節はないだろうか?
デバウンスとスロットルはまさにこの問題を解決する——そしてJavaScriptのツールキットの中で最も即効性のあるパフォーマンス技法の2つだ。
核心的な違いはここにある:
- デバウンス — ユーザーが何かをするのを止めるまで待ってから実行する。14文字入力しても、APIコールは1回(短い停止の後)。
- スロットル — イベントの発生回数に関係なく、関数の実行頻度を制限する。毎秒500回スクロールしても、ハンドラの実行は最大で200msに1回。
私は両方を本番環境で使ってきた——検索オートコンプリート、無限スクロール、ウィンドウリサイズの再計算、フォームの自動保存など。その効果は数字で見える。検索入力だけでAPIコール数が80〜90%減少した。UIのちらつきがなくなり、ユーザー体験は実際に向上する。
それぞれをいつ使うべきかはこうだ:
- デバウンスを使う場面: 検索入力、keyup時のフォームバリデーション、下書きの自動保存、最終サイズのみで意味を持つリサイズ計算
- スロットルを使う場面: スクロールイベントリスナー、マウス移動トラッキング、ゲームループ、リアルタイムデータポーリング
インストール:デバウンスとスロットルを導入する2つの方法
2つの選択肢がある。実績のあるライブラリを使うか、最小限の実装を自分で書くかだ。どちらも機能する——プロジェクトの要件次第だ。
オプション1:Lodashを使う(多くのプロジェクトで推奨)
Lodashは2012年から存在し、数百万のプロジェクトで使われており、モダンなバンドラーでツリーシェイキング可能だ——実際にインポートしたものだけがバンドルされる。Node.jsまたはバンドル済みのフロントエンドプロジェクトの場合:
# npm
npm install lodash
# yarn
yarn add lodash
# pnpm
pnpm add lodash
Lodashのバンドルをすべてロードせずにデバウンスとスロットルだけが必要な場合は?
# 特定の関数だけ(バンドルサイズが大幅に小さくなる)
npm install lodash.debounce lodash.throttle
ブラウザのプロトタイプを作る場合は、CDNから1行でロードできる:
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js"></script>
オプション2:自分で書く(依存関係ゼロ)
依存関係ゼロは有効な選択だ——特に内部の仕組みを学んでいる場合はなおさら。これらをutils.jsファイルに追加しよう:
// utils.js
export function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
export function throttle(fn, limit) {
let lastCall = 0;
return function (...args) {
const now = Date.now();
if (now - lastCall >= limit) {
lastCall = now;
return fn.apply(this, args);
}
};
}
意図的に最小限にしている。Lodashのバージョンはキャンセル、フラッシュ、先頭/末尾実行オプションなどのエッジケースを処理するが——ユースケースの90%においては、これで十分だ。
設定:実際のシナリオへの組み込み
シナリオ1:デバウンスを使った検索入力
検索ボックスはデバウンスの典型的なユースケースだ。ユーザーが入力する際に、キーストロークごとではなく、入力が止まった後に1回だけAPIコールしたい。
// Lodashを使用
import debounce from 'lodash/debounce';
const searchInput = document.getElementById('search');
const fetchResults = async (query) => {
if (!query.trim()) return;
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
renderResults(data);
};
// ユーザーが入力を止めてから400ms後に実行
const debouncedSearch = debounce(fetchResults, 400);
searchInput.addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
400msは適切なデフォルト値だ。200ms未満ではまだコールが多すぎる。600msを超えるとユーザーがもたつきを感じ始める。ユーザーの入力速度とネットワーク環境に応じて調整しよう。
シナリオ2:スロットルを使ったスクロールハンドラ
スクロールイベントは毎秒60回以上発火することがある——高リフレッシュレートのディスプレイではさらに多い。そんな頻度でスクロール位置を確認するのは無駄だ。スロットルを使えば、実際に重要な位置更新を逃さずに、200msに1回といった制御された頻度で処理できる。
import throttle from 'lodash/throttle';
const loadMoreContent = () => {
const scrollPosition = window.scrollY + window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// ユーザーが下端から200pxの位置に来たらさらに読み込む
if (scrollPosition >= documentHeight - 200) {
fetchNextPage();
}
};
// 最大でも200msに1回だけ実行
const throttledScrollHandler = throttle(loadMoreContent, 200);
window.addEventListener('scroll', throttledScrollHandler);
// コンポーネントのアンマウント時にクリーンアップ(Reactの例)
return () => {
window.removeEventListener('scroll', throttledScrollHandler);
throttledScrollHandler.cancel(); // 保留中のコールをフラッシュするLodashのメソッド
};
シナリオ3:React Hooksとの統合
Reactのレンダリングサイクルにはここに微妙な罠がある。レンダリングごとに新しいデバウンス関数が作られると内部タイマーがリセットされる——つまり、デバウンスが実際には機能しない。useCallbackやuseRefを使って、レンダリング間で同じ関数参照を維持しよう。
import { useState, useCallback } from 'react';
import debounce from 'lodash/debounce';
function SearchBox() {
const [results, setResults] = useState([]);
// useCallbackにより、デバウンス関数は一度だけ生成される
const debouncedSearch = useCallback(
debounce(async (query) => {
if (!query) return setResults([]);
const res = await fetch(`/api/search?q=${query}`);
const data = await res.json();
setResults(data);
}, 400),
[] // 空の依存配列:マウント時に一度だけ生成
);
return (
<input
type="text"
placeholder="検索..."
onChange={(e) => debouncedSearch(e.target.value)}
/>
);
}
シナリオ4:APIレート制限の保護
レート制限のあるサードパーティAPIには、コール頻度の上限が必要だ。スロットルが適切なツールだ。デバウンスは中間のコールを削除しすぎる——スロットルは安定した予測可能なスループットを確保する。
import { throttle } from 'lodash';
const geocodeAddress = async (address) => {
const res = await fetch(
`https://api.example.com/geocode?address=${encodeURIComponent(address)}&key=${API_KEY}`
);
return res.json();
};
// サードパーティAPIは毎秒最大10リクエストまで許可
// 150msに1リクエストにスロットル = 約6.5リクエスト/秒(安全マージン)
const throttledGeocode = throttle(geocodeAddress, 150);
// これで入力変更のたびに呼び出しても問題ない
document.getElementById('address').addEventListener('input', (e) => {
throttledGeocode(e.target.value).then(displayOnMap);
});
検証とモニタリング:実際に機能していることを確認する
簡易確認:コンソールのタイミング
シンプルに始めよう。タイムスタンプ付きのconsole.logを使えば、デバウンスが正しいタイミングで発火しているかすぐわかる——DevToolsは不要だ。
// 実際に発火したタイミングをログに記録するためフェッチコールをラップする
const debouncedSearch = debounce((query) => {
console.log(`[${new Date().toISOString()}] APIコール発火: "${query}"`);
fetchResults(query);
}, 400);
検索ボックスに素早く入力してみよう。入力を止めた後にログが1行だけ表示されるはずだ——1文字ごとではなく。ログが一気に複数表示されるなら、デバウンスが接続されていない。
ブラウザDevTools:ネットワークタブ
Chrome DevToolsを開き→ネットワークタブ→APIエンドポイントでフィルタリングする。デバウンスされた検索ボックスに「javascript」(10文字)と入力してみよう。ネットワークリクエストは10件ではなく1件だけ表示されるはずだ。10件表示される場合は、デバウンスの接続が正しくない。
スロットルの場合:5秒間素早くスクロールしてリクエスト数を数えよう。200msのスロットルなら、最大25件(5,000ms ÷ 200ms)のはずだ。数百件表示されるなら、スロットルが接続されていない。
本番環境でのパフォーマンスモニタリング
デプロイ後、実際の数字を確認するために軽量なトラッキングを追加しよう。Datadog、New Relic、または既存のバックエンドログと連携できる:
// ベースライントラッキング:生のイベント数と実際のAPIコール数を比較
let rawEventCount = 0;
let debouncedCallCount = 0;
searchInput.addEventListener('input', () => rawEventCount++);
const trackedSearch = debounce((query) => {
debouncedCallCount++;
// 比率をモニタリングサービスに記録
if (window.analytics) {
window.analytics.track('search_efficiency', {
raw_events: rawEventCount,
actual_calls: debouncedCallCount,
reduction_ratio: (1 - debouncedCallCount / rawEventCount).toFixed(2)
});
}
fetchResults(query);
}, 400);
本番の検索機能にこのトラッキングを追加したとき、全体的にAPIコールが85〜90%削減された比率が維持された。バックエンドのレイテンシが目に見えて改善し、トラフィックのスパイク時でもAPIプロバイダのレート制限内に収まった。
避けるべき一般的なミス
- レンダリングごとに新しいデバウンス関数を作成する —
useCallbackでラップするか、コンポーネントの外で定義しよう - アンマウント時にキャンセルを忘れる — クリーンアップで
debouncedFn.cancel()を呼び出し、アンマウントされたコンポーネントへの状態更新を防ごう - スロットルが必要なときにデバウンスを使う — リアルタイムデータ(マウス位置、スクロール物理演算)を処理する場合、デバウンスはイベントをスキップしすぎることがある。スロットルが適切なツールだ
- 遅延を高く設定しすぎる — 検索ボックスで800ms以上のデバウンスはアプリの反応が遅いと感じさせる。ほとんどの検索UIには300〜500msが適切だ
デバウンスとスロットルは小さな追加で大きな効果をもたらす。検索ボックスの10行のコードでAPIトラフィックを80%削減し、バックエンドのコストを下げ、UIをよりキビキビした感触にする——同時にだ。今まさに作っているどんな入力にも適用して実装してみよう。違いはすぐに現れる。

