なぜ「非同期バグの再現」がこんなに難しいのか
非同期バグって、ほんとイヤらしいですよね。
さっきまで出ていたのに、もう一回やると出ない。
本番では起きるのに、ローカルでは起きない。
理由はシンプルで、非同期バグは「タイミング」と「順番」に依存しているからです。
同じコードでも、ネットワークの速さ、クリックのタイミング、処理の重さが少し変わるだけで、
バグが出たり出なかったりします。
だからこそ大事になるのが
「どうやって“同じ状況”を人工的に作り出すか」
つまり 非同期バグを再現する技術 です。
ここでは、初心者でもできる「再現のための考え方」と「コードでの工夫」を、例題付きで整理していきます。
ステップ1:まず「何がズレているバグなのか」を言葉にする
非同期バグの典型パターンを知っておく
非同期バグは、だいたい次のようなパターンに分類できます。
結果が「古い」ものになってしまう(レースコンディション)
一部のケースでだけエラーが握りつぶされる
特定の順番で操作したときだけ UI が壊れる
キャンセルしたはずの処理が後から結果を反映してしまう
まずは、自分が遭遇しているバグが
「どのタイプっぽいか」を言葉にしてみると、
再現の糸口が見えやすくなります。
例えば、こんな感じです。
検索ボックスに素早く文字を打つと、最後の入力ではなく途中の結果が表示される
画面を切り替えたあとに、前の画面用の API の結果が反映されてしまう
ネットワークが遅いときだけ、ローディング表示が消えない
この「言語化」が、再現の第一歩です。
ステップ2:バグの例を小さく再現できるコードに落とす
典型例:検索ボックスのレースコンディション
よくある非同期バグの例として、「検索ボックス」を考えます。
ユーザーが文字を入力するたびに API を叩いて結果を表示するコードです。
まずは、バグを含んだ素直な実装を書いてみます。
const input = document.querySelector("#search");
const resultArea = document.querySelector("#result");
input.addEventListener("input", async () => {
const keyword = input.value;
const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
const data = await res.json();
resultArea.textContent = `結果: ${data.items.join(", ")}`;
});
JavaScript一見問題なさそうですが、
ユーザーが「a」「ab」「abc」と素早く入力したとき、
こういうことが起きます。
- 「a」でリクエスト A が飛ぶ
- 「ab」でリクエスト B が飛ぶ
- 「abc」でリクエスト C が飛ぶ
- ネットワークの都合で、C → B → A の順にレスポンスが返ってくる
すると、最後に A の結果が画面に反映されてしまい、
「abc」と入力しているのに「a」の結果が表示される、というバグになります。
これが典型的な「非同期バグ」です。
同じコードでも、ネットワークのタイミング次第で出たり出なかったりします。
ステップ3:「タイミング」をコントロールして再現性を上げる
意図的に遅延を入れてみる
非同期バグを再現するために有効なのが
「わざと処理を遅くする」ことです。
先ほどの検索の例で、レスポンスをわざと遅らせてみます。
まず、フェイクの fetch を作ります。
function fakeFetch(url, delayMs, responseData) {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
ok: true,
json: async () => responseData,
});
}, delayMs);
});
}
JavaScriptこれを使って、順番が入れ替わる状況を人工的に作ります。
input.addEventListener("input", async () => {
const keyword = input.value;
let delay;
if (keyword === "a") delay = 300;
else if (keyword === "ab") delay = 600;
else if (keyword === "abc") delay = 100;
const res = await fakeFetch(`/api/search?q=${keyword}`, delay, {
items: [keyword.toUpperCase()],
});
const data = await res.json();
resultArea.textContent = `結果: ${data.items.join(", ")}`;
});
JavaScriptこうすると、
「abc」の結果が一番早く返ってきて、
その後に「ab」「a」の結果が上書きする、という状況を確実に作れます。
ここで重要なのは
「バグが起きる状況を“運任せ”にしない」
ということです。
非同期バグを再現したいなら、
タイミングをコードでコントロールしてしまうのが近道です。
ステップ4:再現できたら「何が条件なのか」を切り出す
条件を言葉とコードでセットにする
さっきの例で言うと、
バグが起きる条件はこう言語化できます。
複数のリクエストをほぼ同時に投げる
後から投げたリクエストより、先に投げたリクエストのレスポンスが遅く返ってくる
レスポンスを受け取った順に、そのまま UI に反映している
これをコードで再現するために、
「遅延時間を意図的にずらす」フェイク fetch を使いました。
この「条件の言語化」と「コードでの再現」がセットになると、
バグの理解が一気に深まります。
ただ「なんかたまにおかしい」ではなく、
「こういう順番でこういうタイミングになると壊れる」
と説明できるようになるのが大事です。
ステップ5:再現用の「スイッチ」をコードに仕込む
本番コードに「デバッグモード」を持たせる発想
非同期バグは、本番環境でしか起きないことも多いです。
ネットワークが遅い、サーバーが重い、ユーザーの操作が激しい、など。
そういうときに効くのが
「再現用のスイッチをコードに仕込んでおく」
という発想です。
例えば、さっきの検索処理を少し設計し直して、
「遅延戦略」を外から渡せるようにします。
async function searchWithStrategy(keyword, { fetchImpl, delayStrategy }) {
const delay = delayStrategy(keyword);
const res = await fetchImpl(`/api/search?q=${encodeURIComponent(keyword)}`, delay);
const data = await res.json();
return data;
}
JavaScript本番では、普通の fetch と「遅延なし」の戦略を渡します。
function noDelayStrategy() {
return 0;
}
function realFetchWithDelay(url, delayMs) {
return new Promise((resolve, reject) => {
setTimeout(() => {
fetch(url).then(resolve).catch(reject);
}, delayMs);
});
}
JavaScriptデバッグ時には、
「特定のキーワードだけ遅延を変える戦略」を渡せます。
function debugDelayStrategy(keyword) {
if (keyword === "a") return 500;
if (keyword === "ab") return 800;
if (keyword === "abc") return 100;
return 0;
}
JavaScriptこうしておくと、
本番に近いコードのまま「バグが出やすい状況」をオン・オフできます。
ここが重要です。
非同期バグの再現性を上げるには、
「最初から“遅延や順番をいじれる設計”にしておく」
という発想が効いてきます。
ステップ6:ログで「時間」と「順番」を見える化する
ログに「いつ」「どのリクエスト」が動いたかを出す
非同期バグの再現で、ログはかなり強力な武器になります。
ただし、適当に console.log を増やすだけだとカオスになります。
大事なのは、
「時間」と「ID」をセットでログに出すことです。
例えば、検索リクエストに ID を振ってみます。
let requestIdCounter = 0;
async function search(keyword) {
const id = ++requestIdCounter;
console.log(`[${id}] start search: ${keyword}`);
const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
console.log(`[${id}] response received`);
const data = await res.json();
console.log(`[${id}] json parsed`);
return { id, data };
}
JavaScriptUI 側では、こう使います。
input.addEventListener("input", async () => {
const keyword = input.value;
const { id, data } = await search(keyword);
console.log(`[${id}] render result for: ${keyword}`);
resultArea.textContent = `結果: ${data.items.join(", ")}`;
});
JavaScriptこれで、コンソールにはこういうログが並びます。
[1] start search: a
[2] start search: ab
[3] start search: abc
[3] response received
[3] json parsed
[3] render result for: abc
[1] response received
[1] json parsed
[1] render result for: a
このログを見れば、
「後から来た 1 の結果が abc の結果を上書きしている」
という事実が一目で分かります。
ここが重要です。
非同期バグの再現と理解には、
「時間と順番をログで可視化する」ことがほぼ必須です。
ステップ7:再現できたら「直し方」も非同期的に考える
最新のリクエストだけを有効にするガード
再現できたら、次は修正です。
検索の例なら、「最後に入力されたキーワードの結果だけを反映する」ようにします。
よく使うパターンは「最新リクエスト ID のチェック」です。
let latestRequestId = 0;
input.addEventListener("input", async () => {
const keyword = input.value;
const id = ++latestRequestId;
const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
const data = await res.json();
if (id !== latestRequestId) {
console.log(`[${id}] outdated result, ignore`);
return;
}
console.log(`[${id}] apply result`);
resultArea.textContent = `結果: ${data.items.join(", ")}`;
});
JavaScriptこれで、
「古いリクエストの結果は無視する」というルールが入ります。
ここで大事なのは、
再現のときに使った「ID」と「順番」の考え方を、
そのまま修正にも活かしていることです。
非同期バグの再現は、
単に「バグを出すため」ではなく、
「どういう条件で壊れるのかを理解し、その条件をコードで潰すため」
にやるものです。
初心者として「非同期バグの再現」で本当に意識してほしいこと
最後に、あなたに持っていてほしい問いをまとめます。
このバグは「タイミング」「順番」「キャンセル」「複数同時実行」のどれに関係していそうか。
バグが起きる状況を、言葉で説明できるか(どういう操作・どういう遅さのときに起きるか)。
その状況を、コードで「意図的に」作り出せるか(遅延・フェイク fetch・ID など)。
ログに「いつ」「どのリクエスト」が動いたかを出して、順番を目で追えるようにしているか。
一度再現できたら、その条件を潰すためのルールをコードに落とし込めているか。
おすすめの練習は、
自分のコードの中から「たまに挙動が怪しい非同期処理」を一つ選んで、
わざと遅延を入れてみる
ID を振ってログを出してみる
複数回連続で呼び出してみる
という「再現のための実験」をしてみることです。
非同期バグを「怖いもの」ではなく、
「条件さえ分かれば再現できる現象」として扱えるようになったとき、
あなたはもう一段上のエンジニアになっています。
