競合状態(Race Condition)を一言でいうと
競合状態(Race Condition)は、
「どっちの非同期処理が先に終わるかによって、結果が変わってしまう危険な状態」 です。
コード上は「こう動くはず」と思って書いているのに、
実際には「たまたま早く終わった方が勝ってしまう」ことで、
画面表示やデータが“おかしな状態”になります。
怖いのは、
エラーにはならないのに「たまにだけおかしくなる」こと。
だからこそ、実務ではかなり厄介なバグの原因になります。
まずは直感でつかむ:検索ボックスの競合状態
状況のイメージ
検索ボックスで API を叩くシンプルな例を考えます。
ユーザーがこう入力したとします。
「a」
「ab」
「abc」
入力のたびに API を叩いて、結果を画面に表示するコードを書いたとしましょう。
async function search(query) {
const res = await fetch("/api/search?q=" + encodeURIComponent(query));
const data = await res.json();
renderResult(data);
}
JavaScriptinput イベントでこれを呼びます。
input.addEventListener("input", (e) => {
search(e.target.value);
});
JavaScript一見、普通に見えますよね。
でもここに、競合状態の罠があります。
何が起きるか
ネットワークは、いつも同じ速度ではありません。
「a」のリクエスト
「ab」のリクエスト
「abc」のリクエスト
この 3 つがサーバーに飛んだとき、
必ずしも「abc」が一番最後に返ってくるとは限りません。
例えば、こういう順番で返ってくることがあります。
- 「abc」の結果が返ってくる
- そのあとで「a」の結果が返ってくる
するとどうなるか。
renderResult は、返ってきた順に呼ばれます。
先に「abc」の結果が表示される
そのあと「a」の結果で上書きされる
ユーザーの画面には、
「abc と入力しているのに、“a の検索結果”が表示されている」
というおかしな状態が生まれます。
これが競合状態です。
「どのリクエストが先に返ってくるか」によって、
画面の最終状態が変わってしまう。
競合状態の本質
「順番を期待しているのに、順番が保証されていない」
競合状態の本質は、
「本当は A → B → C の順で反映されてほしいのに、
実際には“どれが先に終わるか分からない”のに、その順番を前提にしてしまっている」
ということです。
非同期処理は、基本的にこうです。
投げた順番と、終わる順番は一致しない
ネットワーク・サーバー・処理内容によって、毎回変わる
にもかかわらず、
「後から投げたものが、必ず後で反映されるだろう」と思い込んで書くと、
競合状態が生まれます。
ここが重要です。
非同期処理では「順番は保証されない」がデフォルト。
それを忘れて「順番前提のコード」を書くと、
たまにだけ壊れるバグになる。
もう一つの例:プロフィール更新の競合
2つの更新が同時に走るケース
ユーザーのプロフィール編集画面を想像してください。
ユーザー名と自己紹介文があり、
それぞれ別の API で更新するとします。
async function updateName(name) {
await fetch("/api/profile/name", {
method: "POST",
body: JSON.stringify({ name }),
});
}
async function updateBio(bio) {
await fetch("/api/profile/bio", {
method: "POST",
body: JSON.stringify({ bio }),
});
}
JavaScriptユーザーが素早く操作して、
ほぼ同時に 2 つの更新を行ったとします。
「名前 A → 名前 B」
「自己紹介 X → 自己紹介 Y」
もしサーバー側で「プロフィール全体」を上書きするような実装になっていると、
こういうことが起きえます。
- 名前 B の更新が先に完了(プロフィール:名前 B、自己紹介 Y)
- そのあと、名前 A の更新が遅れて反映(プロフィール:名前 A、自己紹介 X に戻る)
ユーザーからすると、
「新しい内容を保存したのに、古い内容に戻っている」
という意味不明な状態になります。
これも競合状態です。
「どの更新が最後に反映されるか」が、
ネットワークのタイミングに依存してしまっている。
競合状態を防ぐための基本的な考え方
「どれが“正しい最後の結果”か」を自分で決める
検索の例に戻りましょう。
本当に欲しいのは、
「一番最後に入力された文字列に対する結果」ですよね。
つまり、
「最後に投げたリクエストだけが画面を更新してよい」
というルールを、自分で作る必要があります。
そのための典型的な方法がいくつかあります。
対策1:リクエストに「世代番号」を付ける
「今のリクエストが最新かどうか」をチェックする
一番シンプルな考え方は、
「リクエストごとに番号を振って、最新の番号以外は無視する」
というものです。
検索の例で書いてみます。
let currentRequestId = 0;
async function search(query) {
const requestId = ++currentRequestId; // 新しいリクエストに番号を振る
const res = await fetch("/api/search?q=" + encodeURIComponent(query));
const data = await res.json();
// 自分が「最新のリクエスト」でなければ、結果を捨てる
if (requestId !== currentRequestId) {
console.log("古いリクエストの結果なので無視:", query);
return;
}
renderResult(data);
}
JavaScript流れを言葉で追うとこうです。
検索が呼ばれるたびに currentRequestId をインクリメント
その時点の値を requestId として覚えておく
レスポンスが返ってきたときに、
「自分の requestId が、今の currentRequestId と一致しているか」を確認
一致していなければ「自分は古いリクエストだ」と判断して結果を捨てる
これで、
「a」のリクエスト → requestId = 1
「ab」のリクエスト → requestId = 2
「abc」のリクエスト → requestId = 3
たとえ「abc」が先に返ってきて、
そのあと「a」が返ってきても、
「a」の結果が返ってきたときには currentRequestId は 3requestId は 1 なので不一致 → 無視
となり、
画面には「abc の結果」だけが残ります。
ここが重要です。
「どれが最新か」を自分で管理し、
“最新以外の結果は捨てる”というルールを入れることで、
競合状態を潰せる。
対策2:AbortController で古いリクエストをキャンセルする
そもそも「古いリクエストを途中で止める」
もう一つのよくある方法が、
「新しいリクエストを投げる前に、古いリクエストをキャンセルする」
というやり方です。
AbortController を使います。
let currentAbortController = null;
async function search(query) {
// すでに進行中の検索があればキャンセル
if (currentAbortController) {
currentAbortController.abort();
}
const controller = new AbortController();
currentAbortController = controller;
try {
const res = await fetch("/api/search?q=" + encodeURIComponent(query), {
signal: controller.signal,
});
const data = await res.json();
renderResult(data);
} catch (err) {
if (err.name === "AbortError") {
console.log("古い検索はキャンセルされました:", query);
return;
}
console.error("検索エラー:", err);
} finally {
if (currentAbortController === controller) {
currentAbortController = null;
}
}
}
JavaScriptこれで、
新しい検索が始まるたびに、前の検索はキャンセルされる
キャンセルされたリクエストは結果を返さない(AbortError になる)
結果として、「最後の検索だけが有効」になる
という状態になります。
ここが重要です。
「古いリクエストを“なかったこと”にする」ことで、
そもそも競合が起きる余地を減らす、という発想です。
対策3:状態管理で「どの結果を採用するか」を決める
状態オブジェクトと組み合わせる
非同期状態管理と組み合わせると、
競合状態への耐性が上がります。
例えば、検索の状態をこう持つとします。
const searchState = {
status: "idle", // "idle" | "loading" | "success" | "error"
query: "",
data: null,
error: null,
};
JavaScript検索をこう書きます。
let currentRequestId = 0;
async function search(query) {
const requestId = ++currentRequestId;
searchState.status = "loading";
searchState.query = query;
render();
try {
const res = await fetch("/api/search?q=" + encodeURIComponent(query));
const data = await res.json();
if (requestId !== currentRequestId) {
return; // 古い結果は無視
}
searchState.status = "success";
searchState.data = data;
render();
} catch (err) {
if (requestId !== currentRequestId) {
return;
}
searchState.status = "error";
searchState.error = err;
render();
}
}
JavaScriptここでのポイントは、
「状態は常に“最新のリクエスト”に対応している」
「古いリクエストは、状態を書き換えない」
というルールを徹底していることです。
これにより、
画面は常に「最新の検索状態」を映すようになります。
競合状態が起きやすい典型パターン
パターン1:同じ場所を更新する複数の非同期処理
同じ変数・同じ UI・同じストアを、
複数の非同期処理が書き換えるときに起きやすいです。
例:
同じ state.user を、
「ユーザー詳細 API」と「プロフィール更新 API」が別々に更新する
このとき、
どちらが最後に書き込むかで、state.user の中身が変わってしまいます。
対策としては、
「更新の順番を決める」
「片方は“差分”だけを更新する」
「サーバー側でバージョン管理をする」
などがありますが、
まずは「同じ場所を複数の非同期処理が触っていないか」を意識することが大事です。
パターン2:画面遷移中の非同期処理
画面 A で API を叩いている途中に、
ユーザーが画面 B に移動する。
そのあと、
画面 A のリクエスト結果が返ってきて、
まだ残っている変数や DOM を書き換えてしまう。
これも競合状態の一種です。
対策としては、
画面を離れるときに AbortController でキャンセルする
「この画面がまだ有効か」をフラグで持ち、無効なら結果を捨てる
といった方法があります。
初心者として「競合状態」で本当に押さえてほしいこと
競合状態は、
「どっちが先に終わるかで結果が変わる」状態。
非同期処理は「投げた順」と「終わる順」が一致しないのが普通。
それなのに「後から投げたものが必ず最後に反映される」と思い込むと、
たまにだけ壊れるバグになる。
よく起きるのは、
検索ボックスの連続入力
同じデータを複数の非同期処理が更新するとき
画面遷移中のリクエスト
対策の基本は、
「どれが“正しい最後の結果”か」を自分で決めること。
具体的には、
リクエストに番号(世代)を振って、最新以外は無視する
AbortController で古いリクエストをキャンセルする
状態管理と組み合わせて「最新のリクエストだけが状態を書き換える」ようにする
そして何より、
非同期処理を書くときに、
「これ、同じ場所を複数の処理が触ってないか?」
「順番に依存してないか?」
と一度立ち止まって考える癖をつけると、
競合状態の多くは“生まれる前に潰せる”ようになります。
もし今、「たまにだけ画面がおかしくなる」「再現しづらいバグ」があったら、
それは競合状態の匂いがします。
そのコードの中で、「どの非同期処理が、どの順番で、どこを書き換えているか」を
一緒に洗い出してみると、きっと核心が見えてきます。
