【JavaScript】AbortControllerでfetchをキャンセルする方法|タイムアウト・競合防止

【JavaScript】AbortControllerでfetchをキャンセルする方法|タイムアウト・競合防止 JavaScript

画面遷移後も通信が続く、検索欄へ素早く入力すると古い検索結果が新しい結果を上書きする、応答しないAPIをいつまでも待ってしまう。このような問題は、fetch()を呼ぶだけでは防げません。

たとえば「j」の検索に2秒かかり、その直後に入力した「javascript」の検索が0.5秒で返るとします。新しい「javascript」の結果を表示した後で、遅れて届いた「j」の結果が画面を上書きします。入力順と応答順が一致するとは限らないため、古い通信を明示的に止める必要があります。

AbortControllerを使うと、JavaScriptから不要になったfetch()を明示的に中断できます。キャンセルボタンだけでなく、タイムアウト、入力検索の競合防止、コンポーネント破棄時の後始末にも使える仕組みです。

先に結論

  • AbortControllerを作り、fetch()signalへ渡します。
  • controller.abort()を呼ぶと、待機中のfetch()は通常AbortErrorで終了します。
  • タイムアウトにはAbortSignal.timeout()、古いブラウザも考慮するならsetTimeout()との組み合わせを使います。
  • 入力検索では、次の通信を始める前に前回の通信を中断すると、古い結果による画面の巻き戻りを防げます。
  • 一度中断されたAbortSignalは再利用できません。リクエストごとに新しいコントローラーを作ります。
  • キャンセルはクライアント側の待機を終える仕組みであり、サーバー側の更新を必ず取り消す仕組みではありません。

fetch()自体のGET・POST・レスポンス判定を確認したい場合は、先にfetch APIの使い方を読むと理解しやすくなります。Promiseとasync/awaitの流れは非同期処理の基本と実務パターンで整理しています。

スポンサーリンク

AbortControllerでfetchをキャンセルする基本

基本手順は3つです。AbortControllerを作成し、そのsignalfetch()へ渡し、不要になった時点でabort()を呼びます。

basic-cancel.js
const controller = new AbortController();

async function loadUsers() {
  try {
    const response = await fetch("/api/users", {
      signal: controller.signal
    });

    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }

    const users = await response.json();
    console.log(users);
  } catch (error) {
    if (error.name === "AbortError") {
      console.log("通信をキャンセルしました");
      return;
    }

    console.error("ユーザー取得に失敗しました", error);
  }
}

loadUsers();

// 通信が不要になった時点で中断する
controller.abort();

controller.signalは通信へ渡す中断通知、controller.abort()は通知を発生させる操作です。中断されるとfetch()のPromiseはrejectされるため、try...catchで処理します。

response.okの確認も省略しないでください。404や500は、通常はfetch()自体のrejectではなくResponseとして返ります。中断エラーとHTTPエラーは別々に扱う必要があります。

キャンセルボタンを実装する

ダウンロードや重い検索では、利用者が明示的に処理を止められるUIが役立ちます。通信開始時にコントローラーを保存し、キャンセルボタンからabort()を呼びます。

index.html
<button type="button" id="load-button">データを取得</button>
<button type="button" id="cancel-button" disabled>キャンセル</button>
<p id="status" aria-live="polite"></p>
cancel-button.js
const loadButton = document.querySelector("#load-button");
const cancelButton = document.querySelector("#cancel-button");
const status = document.querySelector("#status");

let activeController = null;

loadButton.addEventListener("click", async () => {
  activeController = new AbortController();
  loadButton.disabled = true;
  cancelButton.disabled = false;
  status.textContent = "取得中です";

  try {
    const response = await fetch("/api/report", {
      signal: activeController.signal
    });

    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }

    const data = await response.json();
    status.textContent = `${data.length}件を取得しました`;
  } catch (error) {
    if (error.name === "AbortError") {
      status.textContent = "取得をキャンセルしました";
    } else {
      status.textContent = "取得に失敗しました";
      console.error(error);
    }
  } finally {
    activeController = null;
    loadButton.disabled = false;
    cancelButton.disabled = true;
  }
});

cancelButton.addEventListener("click", () => {
  activeController?.abort();
});

通信中だけキャンセルボタンを有効にし、完了・失敗・中断のどの場合でもfinallyでUIを元へ戻します。状態メッセージへaria-live="polite"を付けると、支援技術にも結果を伝えられます。

fetchにタイムアウトを設定する

fetch()には、すべての環境で共通して使えるtimeoutオプションはありません。現在のブラウザではAbortSignal.timeout()を使うと簡潔に指定できます。

timeout.js
async function fetchUser(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`, {
      signal: AbortSignal.timeout(5000)
    });

    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    if (error.name === "TimeoutError") {
      throw new Error("5秒以内に応答がありませんでした");
    }

    throw error;
  }
}

AbortSignal.timeout(5000)は、指定した時間が経過すると自動的に中断されるSignalを返します。タイムアウト時の例外名は通常TimeoutErrorです。利用者の操作による中断を示すAbortErrorと区別できます。

対応環境を確認する

AbortControllerは広く利用できますが、AbortSignal.timeout()は比較的新しいAPIです。古いブラウザを対象に含める場合は、次の手動タイムアウトを使うか、機能の有無を判定してください。

setTimeoutでタイムアウトを実装する

互換性を広く取りたい場合は、setTimeout()からabort()を呼びます。タイマーを残さないよう、必ずfinallyclearTimeout()します。

fetch-with-timeout.js
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
  const controller = new AbortController();
  const timerId = setTimeout(() => {
    controller.abort(new DOMException("Request timed out", "TimeoutError"));
  }, timeoutMs);

  try {
    return await fetch(url, {
      ...options,
      signal: controller.signal
    });
  } finally {
    clearTimeout(timerId);
  }
}

try {
  const response = await fetchWithTimeout("/api/report", {}, 3000);

  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }

  const report = await response.json();
  console.log(report);
} catch (error) {
  if (error.name === "TimeoutError") {
    console.error("通信がタイムアウトしました");
  } else {
    console.error("通信に失敗しました", error);
  }
}

abort(reason)へ理由を渡すと、signal.reasonやrejectされた値から中断理由を判定できます。ただし、対象ブラウザの対応範囲が広い場合は、独自理由へ依存しすぎずAbortErrorとして処理できる設計も残しておくと安全です。

Promise.raceだけでは通信は止まらない

タイムアウト例としてPromise.race()を使うコードもあります。しかし、タイムアウト用Promiseが先に完了しても、裏側のfetch()はそのまま続きます。

promise-race-problem.js
const timeout = new Promise((_, reject) => {
  setTimeout(() => reject(new Error("timeout")), 3000);
});

// 3秒後にcatchへ進んでも、fetch自体は中断されない
const response = await Promise.race([
  fetch("/api/large-report"),
  timeout
]);

単に画面側の待機を終えたいだけなら使える場合もありますが、不要な通信やレスポンス本文の読み込みを止めたいならAbortSignalを使ってください。AbortControllerはfetchだけでなく、レスポンス本文の読み込みや対応するStreamの中断にも関係します。

検索候補で古いレスポンスの競合を防ぐ

インクリメンタル検索では、入力のたびに通信すると応答順が入れ替わることがあります。たとえば「j」の検索が遅く、「javascript」の検索が先に返ると、その後に届いた「j」の結果で画面が古い状態へ戻ります。

次のリクエストを開始する前に前回のリクエストを中断すれば、この競合を防げます。

search-latest-only.js
const searchInput = document.querySelector("#search");
const resultList = document.querySelector("#results");

let searchController = null;

searchInput.addEventListener("input", async (event) => {
  const keyword = event.target.value.trim();

  // 直前の検索が残っていれば中断する
  searchController?.abort();

  if (keyword.length < 2) {
    resultList.replaceChildren();
    return;
  }

  // 中断済みSignalは再利用せず、毎回作り直す
  searchController = new AbortController();

  try {
    const params = new URLSearchParams({ q: keyword });
    const response = await fetch(`/api/search?${params}`, {
      signal: searchController.signal
    });

    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }

    const items = await response.json();
    renderResults(items);
  } catch (error) {
    if (error.name !== "AbortError") {
      console.error("検索に失敗しました", error);
    }
  }
});

function renderResults(items) {
  const nodes = items.map((item) => {
    const li = document.createElement("li");
    li.textContent = item.name;
    return li;
  });

  resultList.replaceChildren(...nodes);
}

中断はエラー画面を出すべき失敗ではなく、「新しい入力が来たため古い処理を捨てた」という正常な制御です。そのため、AbortErrorでは利用者向けエラーを表示せず、それ以外の通信失敗だけを記録します。

入力回数そのものを減らしたい場合はデバウンスも併用します。デバウンスは「開始回数を減らす」、AbortControllerは「開始済みの不要な処理を止める」役割です。郵便番号検索を題材にしたデバウンスとfetchを組み合わせる実装例も参考になります。

デバウンスとキャンセルを組み合わせる

debounce-and-abort.js
function debounce(callback, delay) {
  let timerId;

  function debounced(...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => callback(...args), delay);
  }

  debounced.cancel = () => {
    clearTimeout(timerId);
    timerId = undefined;
  };

  return debounced;
}

let activeController = null;

const search = debounce(async (keyword) => {
  activeController = new AbortController();

  try {
    const params = new URLSearchParams({ q: keyword });
    const response = await fetch(`/api/search?${params}`, {
      signal: activeController.signal
    });

    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }

    renderResults(await response.json());
  } catch (error) {
    if (error.name !== "AbortError") {
      showSearchError();
    }
  }
}, 300);

searchInput.addEventListener("input", (event) => {
  const keyword = event.target.value.trim();

  // 入力が変わった瞬間に、現在の検索結果を無効にする
  activeController?.abort();

  if (keyword.length >= 2) {
    search(keyword);
  } else {
    // まだ開始していない予約済み検索も取り消す
    search.cancel();
    activeController?.abort();
    resultList.replaceChildren();
  }
});

入力が変わった瞬間に実行中の検索を中断し、その後300ミリ秒入力が止まるまで次の検索を始めません。これにより、デバウンスの待機中に古いレスポンスが画面へ反映されることも防げます。入力を2文字未満へ戻した場合はsearch.cancel()も呼び、まだ開始していない予約済み検索も破棄します。通信量と表示競合の両方を抑えられる構成です。

キャンセルとタイムアウトを両方使う

利用者のキャンセル操作と自動タイムアウトを両立したい場合は、AbortSignal.any()で複数のSignalをまとめられます。どれか1つが中断されると、結合したSignalも中断されます。

cancel-or-timeout.js
const userController = new AbortController();
const timeoutSignal = AbortSignal.timeout(10000);
const combinedSignal = AbortSignal.any([
  userController.signal,
  timeoutSignal
]);

cancelButton.addEventListener("click", () => {
  userController.abort(
    new DOMException("Canceled by user", "AbortError")
  );
});

try {
  const response = await fetch("/api/export", {
    signal: combinedSignal
  });

  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }

  const file = await response.blob();
  download(file);
} catch (error) {
  if (error.name === "TimeoutError") {
    showMessage("10秒以内に応答がありませんでした");
  } else if (error.name === "AbortError") {
    showMessage("処理をキャンセルしました");
  } else {
    showMessage("ファイルの取得に失敗しました");
  }
}

AbortSignal.any()も比較的新しいAPIです。対応環境が限定される場合は、1つのAbortControllerに対して、キャンセルボタンとsetTimeout()の両方からabort()を呼ぶ方法が分かりやすいでしょう。

複数のfetchをまとめてキャンセルする

同じSignalを複数のfetch()へ渡すと、1回のabort()でまとめて中断できます。ダッシュボードの画面遷移や、複数APIから組み立てるレポートで便利です。

cancel-multiple-fetches.js
const controller = new AbortController();
const options = { signal: controller.signal };

const requests = Promise.all([
  fetch("/api/profile", options),
  fetch("/api/notifications", options),
  fetch("/api/tasks", options)
]);

try {
  const responses = await requests;

  for (const response of responses) {
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
  }

  const [profile, notifications, tasks] = await Promise.all(
    responses.map((response) => response.json())
  );

  renderDashboard({ profile, notifications, tasks });
} catch (error) {
  if (error.name !== "AbortError") {
    showDashboardError();
  }
}

// 画面を離れるときに関連通信をまとめて中断
window.addEventListener("pagehide", () => {
  controller.abort();
});

一括中断したくない通信まで同じSignalへ結び付けないでください。寿命が同じ処理だけを1つのコントローラーへまとめるのが基本です。

独自の非同期関数をキャンセル対応にする

AbortSignalはfetch専用ではありません。独自関数の引数として受け取り、abortedabortイベント、throwIfAborted()を使えば、長い処理にも同じキャンセル規約を適用できます。

abortable-task.js
async function processItems(items, { signal } = {}) {
  const results = [];

  for (const item of items) {
    // 中断済みならsignal.reasonをthrowする
    signal?.throwIfAborted();

    const result = await processOne(item);
    results.push(result);
  }

  return results;
}

const controller = new AbortController();

try {
  const results = await processItems(items, {
    signal: controller.signal
  });

  console.log(results);
} catch (error) {
  if (error.name === "AbortError") {
    console.log("処理を中断しました");
  } else {
    throw error;
  }
}

関数の外側がSignalを作り、内側は受け取ったSignalに従う形にすると、fetchと独自処理を同じコントローラーで管理できます。すでに中断済みのSignalが渡される可能性もあるため、処理開始時やループ内でthrowIfAborted()を呼ぶと停止が明確です。

ReactのuseEffectで通信を後始末する

Reactでは、コンポーネントの破棄後や依存値の変更後に古い通信結果でstateを更新しないよう、useEffectのクリーンアップで中断します。

UserProfile.jsx
import { useEffect, useState } from "react";

export function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState("");

  useEffect(() => {
    const controller = new AbortController();

    async function loadUser() {
      try {
        setError("");

        const response = await fetch(`/api/users/${userId}`, {
          signal: controller.signal
        });

        if (!response.ok) {
          throw new Error(`HTTP error: ${response.status}`);
        }

        setUser(await response.json());
      } catch (error) {
        if (error.name !== "AbortError") {
          setError("ユーザー情報を取得できませんでした");
        }
      }
    }

    loadUser();

    return () => {
      controller.abort();
    };
  }, [userId]);

  if (error) return <p>{error}</p>;
  if (!user) return <p>読み込み中です</p>;

  return <p>{user.name}</p>;
}

userIdが変わると前回のEffectがクリーンアップされ、古い通信が中断されます。コンポーネントが画面から外れた場合も同様です。フラグだけでstate更新を無視する方法より、不要な通信自体を止められる点が利点です。

AbortErrorと通信エラーを分けて扱う

中断をすべて通常エラーとして記録すると、監視ログがキャンセル操作で埋まります。エラーを「中断」「タイムアウト」「HTTPエラー」「ネットワークエラー」に分けると、利用者への表示と運用ログを整理できます。

error-classification.js
async function requestJson(url, { signal } = {}) {
  try {
    const response = await fetch(url, { signal });

    if (!response.ok) {
      const error = new Error(`HTTP error: ${response.status}`);
      error.status = response.status;
      throw error;
    }

    return await response.json();
  } catch (error) {
    if (error.name === "AbortError") {
      return { ok: false, kind: "canceled" };
    }

    if (error.name === "TimeoutError") {
      return { ok: false, kind: "timeout" };
    }

    if (typeof error.status === "number") {
      return {
        ok: false,
        kind: "http",
        status: error.status
      };
    }

    return {
      ok: false,
      kind: "network",
      message: error.message
    };
  }
}

実際のアプリでは、キャンセルは表示を静かに戻す、タイムアウトは再試行を案内する、401はログインへ誘導する、500やネットワーク障害は監視へ送る、といった分岐ができます。フォーム送信中のボタン制御はfetchとローディング表示を組み合わせる方法も参考になります。

一度中断したSignalは再利用できない

AbortSignalは一度中断されると、その状態のままです。同じコントローラーを次の通信に使うと、fetch()は開始直後にrejectされます。

signal-reuse-ng.js
const controller = new AbortController();

await fetch("/api/first", {
  signal: controller.signal
});

controller.abort();

// NG: signalはすでに中断済み
await fetch("/api/second", {
  signal: controller.signal
});
signal-reuse-ok.js
function createRequest(url) {
  const controller = new AbortController();

  return {
    controller,
    promise: fetch(url, {
      signal: controller.signal
    })
  };
}

const firstRequest = createRequest("/api/first");
firstRequest.controller.abort();

// 次の通信には新しいcontrollerを使う
const secondRequest = createRequest("/api/second");
const response = await secondRequest.promise;

コントローラーは通信そのものではなく、その通信の寿命を管理する使い捨ての制御オブジェクトと考えると分かりやすくなります。

キャンセルしてもサーバー処理が戻るとは限らない

abort()はブラウザ側の通信やレスポンス待機を中断します。しかし、リクエストがすでにサーバーへ到達し、DB更新やメール送信が始まっている場合、サーバー処理まで必ず停止・ロールバックされるわけではありません。

更新APIでは冪等性も必要

  • キャンセル後に同じ操作を再送しても二重登録されないよう、リクエストIDや一意制約を使います。
  • 画面で「キャンセル済み」と表示する前に、サーバー側の処理結果を確認すべき業務もあります。
  • 注文確定や決済のような処理を、fetchの中断だけで取り消した扱いにしないでください。

ブラウザ対応を考慮した実用関数

最後に、利用者による中断、タイムアウト、HTTPエラーをまとめて扱う関数を示します。AbortSignal.timeout()AbortSignal.any()が利用できる場合は使い、未対応なら1つのコントローラーとタイマーへフォールバックします。

request-json.js
export function createJsonRequest(url, options = {}) {
  const {
    timeoutMs = 10000,
    signal: externalSignal,
    ...fetchOptions
  } = options;

  const controller = new AbortController();
  let timerId;
  let signal = controller.signal;
  let removeExternalAbortListener = () => {};

  if (
    externalSignal &&
    typeof AbortSignal.any === "function" &&
    typeof AbortSignal.timeout === "function"
  ) {
    signal = AbortSignal.any([
      controller.signal,
      externalSignal,
      AbortSignal.timeout(timeoutMs)
    ]);
  } else {
    timerId = setTimeout(() => {
      controller.abort(
        new DOMException("Request timed out", "TimeoutError")
      );
    }, timeoutMs);

    if (externalSignal?.aborted) {
      // addEventListenerより前に中断済みなら、直ちに状態を引き継ぐ
      controller.abort(externalSignal.reason);
    } else if (externalSignal) {
      const handleExternalAbort = () => {
        controller.abort(externalSignal.reason);
      };

      externalSignal.addEventListener("abort", handleExternalAbort, {
        once: true
      });

      removeExternalAbortListener = () => {
        externalSignal.removeEventListener("abort", handleExternalAbort);
      };
    }
  }

  const promise = (async () => {
    try {
      const response = await fetch(url, {
        ...fetchOptions,
        signal
      });

      if (!response.ok) {
        throw new Error(`HTTP error: ${response.status}`);
      }

      return await response.json();
    } finally {
      clearTimeout(timerId);
      removeExternalAbortListener();
    }
  })();

  return {
    promise,
    cancel: () => controller.abort()
  };
}
usage.js
import { createJsonRequest } from "./request-json.js";

const request = createJsonRequest("/api/users", {
  timeoutMs: 5000,
  headers: {
    Accept: "application/json"
  }
});

cancelButton.addEventListener("click", request.cancel);

try {
  const users = await request.promise;
  renderUsers(users);
} catch (error) {
  if (error.name === "AbortError") {
    showMessage("取得をキャンセルしました");
  } else if (error.name === "TimeoutError") {
    showMessage("通信がタイムアウトしました");
  } else {
    showMessage("ユーザーを取得できませんでした");
  }
}

実案件では、対応ブラウザが明確なら分岐を減らしてください。共通関数へ多くの機能を詰め込むより、「検索用」「ファイル取得用」など用途ごとに必要なエラー表示と再試行方針を決めるほうが保守しやすい場合もあります。

よくある質問

Q. abort()を呼んだのにcatchへ入らないことはありますか?
A. 通信とレスポンス本文の読み込みがすでに完了していれば、中断する対象がありません。また、対象の非同期関数へSignalを渡していない場合も中断されません。fetch(url, { signal: controller.signal })になっているか確認してください。
Q. AbortErrorは画面へエラー表示すべきですか?
A. 入力変更や画面遷移による中断なら、通常はエラー表示不要です。利用者がキャンセルボタンを押した場合は「キャンセルしました」と状態だけ伝えると自然です。
Q. タイムアウト後に自動再試行してよいですか?
A. GETなど安全に再実行できる処理では候補になります。POSTによる登録・決済・メール送信は、サーバーで処理済みの可能性があるため、冪等性キーや処理状況照会なしに自動再送しないでください。
Q. AxiosでもAbortControllerを使えますか?
A. 現在のAxiosはsignalを受け取る方法に対応しています。基本的な考え方はfetchと同じですが、対象バージョンの公式ドキュメントを確認してください。
Q. デバウンスだけでは不十分ですか?
A. デバウンス後に開始した通信同士でも応答順が逆転することはあります。開始回数を減らすデバウンスと、古い処理を止めるAbortControllerを組み合わせると堅牢です。

まとめ

  • AbortControllersignalfetch()へ渡し、不要になったらabort()を呼びます。
  • 中断時は通常AbortErrorになるため、通信障害と分けて処理します。
  • タイムアウトはAbortSignal.timeout()、互換性が必要ならsetTimeout()abort()で実装します。
  • 検索画面では前回リクエストを中断し、古いレスポンスによる表示競合を防ぎます。
  • 同じSignalで複数通信を一括中断できますが、寿命が同じ処理だけをまとめます。
  • 中断済みSignalは再利用せず、リクエストごとに新しいコントローラーを作ります。
  • fetchのキャンセルはサーバー処理の取り消しを保証しないため、更新APIでは冪等性も設計します。

AbortControllerは、単に「通信を止めるAPI」ではありません。画面の寿命と非同期処理の寿命を揃え、不要な結果をUIへ反映させないための制御手段です。まずは検索候補や画面遷移時の通信から導入すると、効果を確認しやすいでしょう。