サーバーに過剰な負荷をかけずにAPIをポーリングする方法
本番向けAPIポーラーの作り方を解説。リクエストの逐次実行、キャンセル、ジッター付きバックオフ、Retry-After、非表示タブの停止、ETagキャッシュに対応。
本番環境で安全に使えるAPIポーラーには、次の条件があります。前のレスポンスの処理が終わってから次のリクエストを送ること、ジョブが終了状態に達したら停止すること、リクエスト失敗時にはジッター付きでバックオフすること、429や503ではRetry-Afterに従うこと、タブが非表示のあいだは何もしないこと、そしてETagを使い、変更のないデータは空の304として返されるようにすることです。
ほとんどのポーラーはsetIntervalとfetchの組み合わせから始まります。この実装は、エンドポイントの応答が遅くなったり、APIが429を返し始めたり、誰かが週末にタブを開いたままにして月曜日になってもリクエストを送り続けていたりするまでは、問題なく動いているように見えます。
サーバーから更新をプッシュできるなら、そもそもポーリングは不要かもしれません。WebSockets、SSE、ロングポーリングを比較検討するか、サーバー間の連携であればポーリングの代わりにWebhookの利用を検討してください。ポーリングが適切な手段である場合に備えて、本記事では依存ライブラリなしのポーラーを、問題を1つずつ修正しながら構築していきます。各セクションでは、まず問題のあるバージョンを示し、その後に修正版を紹介します。
重要なポイント
- 各レスポンスの後に
setTimeoutを連鎖させれば、処理中のリクエストは常に1つだけになります。setIntervalではこれを保証できません。 - フルジッターでは、各リトライの遅延を
random(0, min(cap, base * 2 ** attempt))に設定します。これにより、リトライするクライアントが分散し、復旧中のサーバーに一斉に押し寄せることを防げます。 Retry-Afterには秒数とHTTP日付の2つの形式があります。ポーラーは両方をパースでき、ヘッダーがない場合やパースできない場合にはバックオフにフォールバックする必要があります。- ブラウザはバックグラウンドタブのタイマーをスロットリングしますが、スロットリングはポーラーを遅くするだけです。実際に停止させるには
document.hiddenをチェックする必要があります。 - 最後に受け取った
ETagをIf-None-Matchで送信すると、何も変更がない場合、サーバーはボディなしの304 Not Modifiedで応答できます。
APIポーリングでよくある4つの間違いとは?
素朴なAPIポーリングは、たいてい次の4つの点で失敗します。リクエストが重複する、ポーリングが止まらない、障害時にポーラーがサーバーをより激しく叩く、非表示のタブがポーリングを続ける、の4つです。次のコードには、これら4つの問題がすべて含まれています。
setInterval(async () => {
const res = await fetch('/api/jobs/42');
render(await res.json());
}, 2000); // overlaps when slow, never stops, ignores errors and hidden tabs
- 重複。
setIntervalは前のレスポンスの到着を待たず、固定の間隔で発火します。エンドポイントの応答時間がインターバルより長くなると、リクエストが次々と積み重なっていきます。 - 終了条件がない。 ジョブが完了してもループを止めるものがないため、もう変わることのない結果をフェッチし続けます。
- バックオフがない。 サーバーが500を返しているとき、すべてのクライアントが全速力でリトライを続けるため、サーバーが最も負荷に耐えられないタイミングでさらに負荷をかけてしまいます。
- 非表示のタブ。 誰も見ていないタブがポーリングを続けます。
setTimeoutを連鎖させ、完了条件で停止するには?
現在のレスポンスの処理が終わってから次のポーリングをスケジュールすれば、処理中のリクエストが2つ以上になることはありません。イテレーションの間にスリープを挟むasyncループでこれを実現でき、ジョブが終了状態に達した時点でループを抜けます。
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function pollJob(url, isDone, interval = 2000) {
while (true) {
const res = await fetch(url);
const data = await res.json();
render(data);
if (isDone(data)) return data;
await sleep(interval);
}
}
pollJob('/api/jobs/42', (job) => job.status === 'done' || job.status === 'failed');
完了条件には、成功だけでなく終了状態としての失敗も含める必要があります。'done'だけをチェックしていると、'failed'で終了したジョブが永遠にポーリングされ続けます。
AbortControllerでポーラーをキャンセルするには?
ポーラーのキャンセルとは、処理中のリクエストと待機中のタイマーの2つを止めることです。fetchに渡したAbortControllerのシグナルでリクエストは中断できますが、すでにキューに入っているsetTimeoutには何の効果もありません。そのため、スリープ側でもシグナルを監視する必要があります。
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const onAbort = () => {
clearTimeout(id);
reject(signal.reason);
};
const id = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
controller.abort()を呼び出すと、fetchとsleepのうち保留中の方がrejectされ、ループが終了します。タイマーが発火した時点でabortリスナーは削除されるため、1つのシグナルを使い回す長時間のポーリングでも、イテレーションごとにリスナーが増えていくことはありません。
上限付きのジッター入り指数バックオフを実装するには?
リクエストが失敗したら、random(0, min(cap, base * 2 ** attempt))だけ待ってから再試行します。これはAWSのExponential Backoff and Jitterの分析で紹介されている「フルジッター」戦略です。上限付きの指数バックオフでも、ランダム性がなければクライアントは同期した集団でリトライし続けます。各遅延をゼロから指数関数的な上限値の間でランダムに選ぶことで、こうした集団を分散させられます。
const backoff = (attempt, base = 1000, cap = 30000) =>
Math.random() * Math.min(cap, base * 2 ** attempt);
この1行のbackoff関数は、AWSのバックオフシミュレーターのソースコードにあるフルジッター関数と同じものです。AWSが2015年に行った、100台のクライアントが同じリソースを奪い合うシミュレーションでは、ジッターを加えることで総呼び出し回数が半分以下に減りました。ただし、このシミュレーションが対象としたのは読み取りのポーリングではなく書き込みの競合です。この数値は、あくまでそのシナリオにおけるAWSの結果として捉えてください。
ループ内では、失敗のたびにattemptをインクリメントし、成功したら必ず0にリセットします。リセットしないと、初期の一時的な障害が1回あっただけで、その後の失敗がすべて長い遅延から始まってしまいます。サーバー側のワーカーも同じリトライの問題を抱えており、ジョブキューでも同様のリトライポリシーで対処しています。
ポーラーは429、503、Retry-Afterをどう扱うべきか?
サーバーがRetry-Afterヘッダー付きで429または503のレスポンスを返した場合は、ヘッダーが指定する時間だけ待機します。独自のバックオフは、ヘッダーがない場合のフォールバックです。Retry-Afterヘッダーは、レスポンス到着後に待機すべき秒数(整数)か、再試行が可能になるHTTP日付のいずれかの形式をとります。RFC 9110では両方の形式が定義されており、MDNでは429 Too Many Requestsでの使い方が解説されています。
const MAX_RETRY_AFTER = 5 * 60_000; // design choice, not a spec rule
function retryAfterMs(res) {
const value = res.headers.get('Retry-After');
if (!value) return null;
if (/^\d+$/.test(value.trim())) return Math.min(Number(value) * 1000, MAX_RETRY_AFTER);
const date = Date.parse(value);
if (Number.isNaN(date)) return null;
return Math.min(Math.max(0, date - Date.now()), MAX_RETRY_AFTER);
}
日付形式はクライアントの時計と比較されます。時計がずれていて日付がすでに過去になっている場合、遅延はゼロに丸められます。Date.parseがNaNを返した場合、関数はnullを返し、呼び出し側は代わりにバックオフを使用します。上限のクランプは、設定ミスのあるヘッダーによってポーラーが何時間も停止してしまうのを防ぐためのものです。
クロスオリジンリクエストの場合、Retry-AfterはCORSセーフリストレスポンスヘッダーではないため、サーバーがAccess-Control-Expose-Headersに含めていない限り、JavaScriptから読み取ることはできません。公開されていない場合、res.headers.get()はnullを返し、ポーラーはエラーを出すことなく通常のバックオフにフォールバックします。
タブが非表示のときにポーリングを一時停止するには?
ブラウザはバックグラウンドタブのタイマーをスロットリングしますが、スロットリングされたポーラーも頻度が下がるだけで、リクエストは送り続けます。実際に停止させるには、Page Visibility APIの一部であるdocument.hiddenをチェックし、visibilitychangeイベントを監視します。次のヘルパーは、ページが表示されていれば即座にresolveし、そうでなければ表示されるまで待機します。
function whenVisible(signal) {
if (signal?.aborted) return Promise.reject(signal.reason);
if (!document.hidden) return Promise.resolve();
return new Promise((resolve, reject) => {
const done = () => {
document.removeEventListener('visibilitychange', onChange);
signal?.removeEventListener('abort', onAbort);
};
const onChange = () => { if (!document.hidden) { done(); resolve(); } };
const onAbort = () => { done(); reject(signal.reason); };
document.addEventListener('visibilitychange', onChange);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
各イテレーションの先頭でこれをawaitすれば、非表示のタブからはリクエストが送られず、ユーザーが戻ってきたときには最初のリクエストが即座に送信されます。ただし制約が1つあります。タブが表示状態になった時点で長いバックオフのスリープがすでに実行中の場合、ポーラーはそのスリープが終わるまで待機します。
ETagを使い、すべてを組み合わせる
最後に受け取ったETagをIf-None-Matchヘッダーで送り返すと、リソースに変更がない場合、サーバーはボディなしの304 Not Modifiedで応答できます。デフォルトのキャッシュモードのままIf-None-Matchを自分で設定すると、Fetch仕様によりHTTPキャッシュがスキップされるため、コードは304を直接受け取ることになり、それを自分で処理する必要があります。クロスオリジンリクエストでは、CORSに関する注意点が2つあります。1つは、サーバーがETagを公開する必要があることです。もう1つは、If-None-MatchはCORSセーフリストリクエストヘッダーではないため、これを送信するとプリフライトが発生することです。そのため、サーバーはAccess-Control-Allow-Headersにもこのヘッダーを含める必要があります。
次の表は、完成したポーラーが各結果にどう対応するかを示しています(ステータスコードの意味は、前述のHTTPステータスコードのリファレンスを参照してください)。
| レスポンス | ポーラーの動作 | 次の遅延 |
|---|---|---|
| 200 | UIを更新、完了をチェック、試行回数をリセット | interval |
| 304 | 更新なし、試行回数をリセット | interval |
| 429 / 503 | 試行回数をインクリメント | Retry-After、なければバックオフ |
| その他の5xx / ネットワークエラー | 試行回数をインクリメント | バックオフ |
| その他の4xx | 例外をスローしてエラーを表面化 | なし |
| 中断(Abort) | 再スローして終了 | なし |
次の関数は、前のセクションで紹介したsleep、backoff、retryAfterMs、whenVisibleを使用します。ステータスによる分岐はtry/catchの外に置いているため、4xxがネットワークエラーと誤認されてリトライされることはありません。
async function poll(url, { isDone = () => false, onUpdate, signal, interval = 2000, maxAttempts = 8 } = {}) {
let etag = null;
let attempt = 0;
while (true) {
await whenVisible(signal);
let res = null;
try {
res = await fetch(url, { signal, headers: etag ? { 'If-None-Match': etag } : {} });
} catch (err) {
if (signal?.aborted) throw err; // otherwise: network error, res stays null
}
let wait = interval;
if (res?.status === 304) {
attempt = 0; // no body: never call res.json() here
} else if (res?.ok) {
attempt = 0;
etag = res.headers.get('ETag'); // null if absent or not exposed
const data = await res.json();
onUpdate?.(data);
if (isDone(data)) return data;
} else if (!res || res.status === 429 || res.status >= 500) {
if (++attempt > maxAttempts) throw new Error('Polling gave up');
wait = (res && retryAfterMs(res)) ?? backoff(attempt);
} else {
throw new Error(`Polling failed: HTTP ${res.status}`);
}
await sleep(wait, signal);
}
}
attemptは前置インクリメントされるため、最初のリトライの時点ですでにattempt = 1となり、その遅延の上限は2秒になります。
Reactフックでラップする
import { useEffect, useState } from 'react';
function usePoll(url, isDone) {
const [state, setState] = useState({ status: 'polling', data: null });
useEffect(() => {
const controller = new AbortController();
poll(url, { isDone, signal: controller.signal, onUpdate: (data) => setState({ status: 'polling', data }) })
.then((data) => setState({ status: 'done', data }))
.catch((error) => { if (!controller.signal.aborted) setState({ status: 'error', error }); });
return () => controller.abort();
}, [url]);
return state;
}
Reactは、アンマウント時と、urlが変わってエフェクトを再実行する前にエフェクトのクリーンアップを実行するため、古いポーラーは必ず中断されます。isDoneは安定した参照を保てるよう、コンポーネントの外で定義してください。'error'ステータスは、UIで「確認を停止しました。再試行しますか?」といった状態を表示できるようにするためのものです。状態を表面化しないポーラーのセッションリプレイを見ると、裏側のネットワークリクエストはすでに止まっているか失敗し続けているのに、スピナーがいつまでも回り続けているケースがよく見られます。
まとめ
サーバーに過剰な負荷をかけないポーラーは、いくつかのルールに集約されます。リクエストは一度に1つだけ送ること、明確な終了条件を設けること、成功後にリセットされるジッター付きバックオフを使うこと、Retry-Afterに従うこと、非表示のタブからはリクエストを送らないこと、そして条件付きリクエストを使い、変更のないデータのコストをほぼゼロにすることです。setIntervalを上記のpoll関数に置き換えたら、サーバーがETagとRetry-Afterを返していること、そしてクロスオリジンのクライアントに両方を公開していることを確認してください。こうしたサーバー側のサポートがなければ、クライアントのコードは何も告げずに通常のバックオフと完全なレスポンスにフォールバックします。
よくある質問
ユーザーが別のアプリケーションウィンドウに切り替えたとき、ポーラーは一時停止しますか?
必ずしもそうとは限りません。ページが非表示になるのは、タブがバックグラウンドに移ったとき、ブラウザウィンドウが最小化されたとき、OSの画面ロックが作動したときです。画面上で部分的にでも表示されたままのページは、visibilityStateが'visible'のままであり、別のウィンドウにフォーカスが移っただけでは非表示になりません。この場合、ポーラーは動作し続けます。ウィンドウがフォーカスを失ったときにも一時停止したい場合は、blurイベントとfocusイベントを監視してください。
フルジッター、イコールジッター、デコリレーテッドジッターの違いは何ですか?
いずれも上限付きの指数バックオフをランダム化する手法ですが、その方法が異なります。フルジッターは、ゼロからmin(cap, base * 2 ** attempt)の間で遅延を選びます。イコールジッターは、その上限値の半分を固定し、残りの半分をランダム化します。デコリレーテッドジッターは、baseから前回の遅延の3倍までの間で遅延を選び、上限を適用します。AWSのシミュレーションでは、ジッターを用いる3つの手法のうちイコールジッターが最も効果が低く、フルジッターはデコリレーテッドジッターより作業量が少ない一方で、完了までの時間はわずかに長くなりました。
このポーラーはNode.jsでも使えますか?
はい、1か所変更すれば使えます。Node.js 18以降ではグローバルなfetch(Node.js 21で安定版になりました)とグローバルなAbortControllerが提供されています。ただしNode.jsにはdocumentオブジェクトがないため、whenVisibleはReferenceErrorをスローします。ブラウザ外では即座にresolveするよう、typeof documentによるチェックを追加してください。バックオフ、Retry-After、ETagの処理はそのまま動作します。Node.jsはCORSを強制しないため、すべてのレスポンスヘッダーを読み取れます。
If-None-Matchを自分で設定しない場合、fetchが304を返さないのはなぜですか?
ブラウザのHTTPキャッシュが再検証を内部で処理しているためです。保存されたレスポンスの再検証が必要になると、ブラウザは条件付きヘッダーを自動で付加します。サーバーが304で応答した場合、fetchはキャッシュされたレスポンスをステータス200と完全なボディ付きでコードに渡すため、そのリクエストが条件付きだったことを判別できません。If-None-Matchを手動で設定することで、初めて304がJavaScriptから見えるようになります。