shibomb

古い検索結果の上書きを防止!AbortControllerで直前のfetchを破棄する

検索ボックスに文字を入力するたびに通信しているのに、遅れて届いた「古い検索結果」が最新の結果を上書きしてしまう。タブを素早く切り替えると、前のタブの内容が表示される。こうした レスポンス逆転バグ は、JavaScript の非同期処理の競合が原因です。この記事では AbortController を使った fetch キャンセルで、不要になった通信を中断し、画面を常に最新の入力と一致させる実装パターンを、コード付きで解説します。

検索フォームやタブ切り替えで起きる「レスポンス逆転バグ」の原因とリスク

原因はシンプルです。リクエストを送った順番と、レスポンスが返ってくる順番は一致するとは限りません。ネットワークの状態やサーバー側の処理時間、キャッシュの有無で、後から送った通信が先に完了することがあります。

たとえば「java」と入力して通信 A を送り、続けて「javascript」と入力して通信 B を送ったとします。B が先に返って画面に描画された後、遅れて A が返ると、画面は「javascript」と入力されているのに「java」の結果を表示します。コードの上では、どちらの通信も正しく動いているため、エラーも出ません。

このバグは開発環境のようにレスポンスが速く安定した場所では再現しにくく、本番の遅いモバイル回線で初めて報告されるケースが多いです。ユーザーは間違った情報を見たまま操作を続けてしまいます。ECサイトなら誤った商品一覧を見て購入するなど、体験を損なうリスクがあります。

対策は大きく 2 つあります。「古い結果を無視する」方法と、「古い通信そのものを中断する」方法です。後者が AbortController を使うアプローチで、無駄な通信と処理も減らせます。

AbortControllerの基本:シグナル(signal)連携とfetch中断の流れを理解する

AbortController は、非同期処理に「中止してください」と伝えるための標準 API です。ブラウザだけでなく Node.js でも使えます (仕様は DOM Standard で定義され、MDN にも解説があります)。登場人物は次の 2 つです。

  1. AbortController: 中断を指示する側。abort() メソッドを持つ
  2. AbortSignal: 中断の通知を受け取る側。controller.signal で取得し、fetch に渡す

流れは「コントローラーを作る → signal を fetch のオプションに渡す → 必要になったら abort() を呼ぶ」です。abort() が呼ばれると、signal につながった fetch の Promise は reject されます。

const controller = new AbortController();

fetch("/api/search?q=js", { signal: controller.signal })
  .then((res) => res.json())
  .then((data) => console.log(data))
  .catch((err) => console.log(err.name)); // 中断されると "AbortError"

controller.abort();

注意点として、AbortController は 使い捨て です。一度 abort() した signal は、ずっと中断済みの状態になります。次の通信には新しいインスタンスを作ってください。また、すでに完了した fetch に abort() を呼んでも、何も起きません。

【コード例】直前のリクエストを自動破棄するインクリメンタル検索の実装パターン

インクリメンタルサーチでは、「新しい検索を始める直前に、前の通信を中断する」パターンが基本形です。直前のコントローラーを変数に保持しておき、検索のたびに入れ替えます。

let currentController = null;

async function search(query) {
  // 直前のリクエストを中断
  currentController?.abort();

  const controller = new AbortController();
  currentController = controller;

  showLoading(true);

  try {
    const res = await fetch(
      `/api/search?q=${encodeURIComponent(query)}`,
      { signal: controller.signal }
    );
    if (!res.ok) throw new Error(`HTTP ${res.status}`);

    const data = await res.json();
    renderResults(data);
  } catch (err) {
    if (err.name === "AbortError") return; // 意図した中断なので無視
    showError(err);
  } finally {
    // 自分が最新のリクエストのときだけローディングを解除
    if (currentController === controller) {
      showLoading(false);
    }
  }
}

input.addEventListener("input", (e) => search(e.target.value));

ポイントは finally の判定です。中断された古い通信の finally が実行されると、最新の通信が進行中なのにローディング表示が消えてしまいます。「自分が最新か」を確認してから後始末をすると、この不具合を防げます。

実運用では、入力が止まってから一定時間後に通信するデバウンス (debounce) と併用するのが一般的です。デバウンスで通信回数そのものを減らし、AbortController で「すり抜けた古い通信」を確実に破棄します。役割が違うので、どちらか片方で済ませずに組み合わせましょう。タブ切り替えでも同じ構造で書けます。切り替えのたびに abort() して新しいコントローラーを作るだけです。

中断時のAbortErrorを正しくキャッチ!通常のエラーと識別する例外処理の書き方

中断された fetch は例外を投げるため、何も考えずに catch へエラー表示を書くと、ユーザーが入力するたびに「通信に失敗しました」と出てしまいます。中断は異常ではなく意図した動作なので、通常のエラーと区別する必要があります。

識別の基本は、err.name === "AbortError" の判定です。引数なしで abort() を呼んだ場合、fetch は AbortError という名前の DOMException で reject されます。

try {
  const res = await fetch(url, { signal });
  const data = await res.json();
  render(data);
} catch (err) {
  if (err.name === "AbortError") {
    return; // 中断は正常系として扱う
  }
  console.error(err);
  showError("検索に失敗しました。時間をおいて再度お試しください。");
}

注意したいのは、res.json() などボディの読み取り中に中断された場合も、同じ AbortError が投げられることです。そのため、fetch と json() を同じ try ブロックで囲んでおくと、判定を 1 か所にまとめられます。

もう 1 点、abort(reason) に独自の理由を渡した場合、fetch はその reason そのものを例外として投げます。new Error("cancel") を渡すと、err.name は "Error" になり、AbortError 判定をすり抜けます。判定に迷う場合は、signal.aborted を確認する方法が安全です。

} catch (err) {
  if (signal.aborted) return; // 理由の種類を問わず中断扱い
  showError(err);
}

チーム開発では、この判定を共通の fetch ラッパーにまとめておくと、各画面で書き忘れるリスクが下がり、保守もしやすくなります。

一定時間で通信を打ち切る「タイムアウト処理」への応用と現場運用の注意点

fetch 自体にはタイムアウトのオプションがありません。応答が返らない通信を待ち続けないために、AbortSignal を使って時間切れを実装します。最近のブラウザや Node.js では、AbortSignal.timeout(ms) が使えます。

const res = await fetch(url, { signal: AbortSignal.timeout(5000) });

この場合、時間切れで投げられる例外は AbortError ではなく TimeoutError です。ユーザー操作による中断と時間切れは、名前で区別できます。

catch (err) {
  if (err.name === "AbortError") return; // ユーザー操作による中断
  if (err.name === "TimeoutError") {
    showError("通信に時間がかかっています。再度お試しください。");
    return;
  }
  showError(err);
}

検索の中断とタイムアウトを両立したい場合は、AbortSignal.any() で複数の signal をまとめられます。

const signal = AbortSignal.any([
  controller.signal,
  AbortSignal.timeout(5000),
]);
const res = await fetch(url, { signal });

AbortSignal.any() は比較的新しい API です。対象ブラウザのサポート状況は、導入前に MDN の互換性表で確認してください。古い環境も対象なら、setTimeout で controller.abort() を呼ぶ方法で代替できます。その際はタイマーの後始末 (clearTimeout) を忘れないようにしましょう。

最後に、現場で押さえておきたい注意点です。

  1. abort() が止めるのはクライアント側の待機と受信です。サーバー側の処理が止まるとは限らないため、重い検索 API では負荷対策を別途検討します
  2. 更新系の POST や PUT を中断すると、サーバー側では処理済みの可能性があります。重複実行に備えて冪等性を意識してください
  3. fetch を使わない非同期処理 (独自の Promise など) は、signal を渡しただけでは止まりません。signal.aborted を自分で確認する必要があります

AbortController は「古い結果を表示しない」ための道具であると同時に、「不要な通信を減らす」ための仕組みです。検索フォームやタブ切り替えなど、入力が連続する UI では、最初から組み込んでおくと安心です。

関連記事