fetch API完全実践ガイド〜GET/POST・Headers・FormData・Stream・AbortController・40サンプル【2026年版】〜

JavaScript の fetch API は、ブラウザ・Node.js 22+・Deno・Bun・Cloudflare Workers・Vercel Edge Runtime まですべての JS ランタイムで動く唯一の標準 HTTP クライアントになりました。かつての XMLHttpRequest も、Node 専用の http モジュールも、axios すら不要なケースが急増しています。それでも「fetch は薄くて使いにくい」「エラー処理が直感的でない」「タイムアウトが効かない」と感じている人は少なくないはずです。

本記事では ECMAScript 2025 / WHATWG Fetch Standard / Node.js 22+ / TypeScript 5.x 準拠で、コピペで動く 40 以上のコードサンプルを通して、fetch の GET/POST から始まり、Headers・URLSearchParams・FormData・File アップロード・ReadableStream・AbortController・AbortSignal.timeout/any・指数バックオフリトライ・並列 fetch・JWT 自動更新・Zod スキーマ検証・safeFetch ラッパー・SSE・Service Worker・メモリリーク対策・axios/ky/ofetch との比較まで、fetch 単体で完結する実践知見を完全網羅します。

本記事は fetch API そのものに完全特化しているため、Promise 完全実践ガイド(Promise の観点)、async/await 完全実践ガイド(async 構文の観点)、および TanStack Query 解説記事(React フック層の観点)とは別軸のレイヤーを扱います。fetch を素手で使いこなせるエンジニアは、どんなライブラリに乗り換えても揺らがない HTTP の基礎体力を手に入れられます。

  1. 1. fetch API の全体像と最小コード
    1. 1.1 fetch GET の最小コード
    2. 1.2 JSON レスポンスのパース
    3. 1.3 fetch は HTTP エラーで reject しない
  2. 2. GET リクエストとクエリパラメータ
    1. 2.1 URLSearchParams でクエリ組み立て
    2. 2.2 URL オブジェクトで安全に組み立てる
    3. 2.3 配列パラメータの渡し方
  3. 3. POST リクエストとボディの作り方
    1. 3.1 JSON POST
    2. 3.2 application/x-www-form-urlencoded
    3. 3.3 PUT / PATCH / DELETE
  4. 4. Headers と認証
    1. 4.1 Headers クラスの使い方
    2. 4.2 Authorization: Bearer トークン
    3. 4.3 Basic 認証
    4. 4.4 Cookie 送信(credentials)
    5. 4.5 CORS と mode
  5. 5. FormData と File アップロード
    1. 5.1 FormData の基本
    2. 5.2 input[type=file] からアップロード
    3. 5.3 Blob を直接ボディに渡す
    4. 5.4 Node.js でファイルアップロード
  6. 6. Response の読み方と検査
    1. 6.1 各種読み出しメソッド
    2. 6.2 ステータスとヘッダの検査
    3. 6.3 二重読み出しを避ける clone()
  7. 7. AbortController とキャンセル
    1. 7.1 AbortController の基本
    2. 7.2 AbortSignal.timeout で 1 行タイムアウト
    3. 7.3 AbortSignal.any で複合シグナル
    4. 7.4 React useEffect でのキャンセル
  8. 8. ReadableStream とストリーミング
    1. 8.1 ReadableStream を逐次読む
    2. 8.2 進捗付きダウンロード
    3. 8.3 JSON Lines / NDJSON のストリーム処理
    4. 8.4 OpenAI 風 SSE ストリーム処理
  9. 9. リトライ・並列・スロットリング
    1. 9.1 指数バックオフリトライ
    2. 9.2 Retry-After ヘッダ対応
    3. 9.3 並列 fetch with Promise.all
    4. 9.4 部分失敗を許容する Promise.allSettled
    5. 9.5 同時実行数の制限(セマフォ)
  10. 10. fetch ラッパーと型安全な設計
    1. 10.1 TypeScript ジェネリック fetch
    2. 10.2 Zod でレスポンス検証
    3. 10.3 Result 型を返す safeFetch
    4. 10.4 共通ベース URL とデフォルトヘッダ
    5. 10.5 JWT 自動更新インターセプタ風
  11. 11. Node.js / Edge / Service Worker 環境別の注意点
    1. 11.1 Node.js 22+ の fetch と undici
    2. 11.2 SSR で fetch する場合の絶対 URL
    3. 11.3 Cloudflare Workers / Edge Runtime
    4. 11.4 Service Worker での fetch インターセプト
    5. 11.5 fetch + キャッシュ API
  12. 12. 周辺ライブラリ比較と移行ガイド
    1. 12.1 axios との比較
    2. 12.2 ky の薄さと使い勝手
    3. 12.3 ofetch(Nuxt エコシステム)
    4. 12.4 wretch の流麗 API
  13. 13. SSE・WebSocket・GraphQL との位置づけ
    1. 13.1 EventSource(SSE)との比較
    2. 13.2 WebSocket との使い分け
    3. 13.3 GraphQL を fetch だけで叩く
  14. 14. 落とし穴・メモリリーク・パフォーマンス
    1. 14.1 ReadableStream を読まない場合のリーク
    2. 14.2 巨大レスポンスを json() で食わない
    3. 14.3 default キャッシュ挙動とブラウザ差分
    4. 14.4 リダイレクトの追跡を止めたいとき
    5. 14.5 keepalive で離脱時送信
    6. 14.6 fetch を握る独自エラー型
    7. 14.7 まとめ:fetch を選ぶ判断軸

1. fetch API の全体像と最小コード

fetch は Promise を返すグローバル関数です。第 1 引数に URL、第 2 引数に RequestInit オブジェクトを取り、Response オブジェクトの Promise を返します。ここを起点に、すべての応用が枝分かれしていきます。

1.1 fetch GET の最小コード

もっとも基本的な GET リクエストはこの 1 行から始まります。await fetch(url) の戻り値は本文ではなく Response オブジェクトである点に注意してください。

// fetch GET の最小コード
const res = await fetch("https://api.example.com/users/1");
console.log(res.status);       // 200
console.log(res.ok);           // true
console.log(res.headers.get("content-type"));

1.2 JSON レスポンスのパース

res.json() は body を読み切って JSON にパースする Promise を返すメソッドです。同期的にプロパティを触ろうとして undefined になるのが最頻出のハマりポイントです。

// JSON レスポンスをパースする
const res = await fetch("https://api.example.com/users/1");
const user = await res.json();
console.log(user.id, user.name);

// NG: res.body は ReadableStream であって JSON ではない
// console.log(res.body.name); // undefined

1.3 fetch は HTTP エラーで reject しない

これが fetch 最大の落とし穴です。404 / 500 でも Promise は resolve します。ネットワーク障害(DNS 解決失敗・接続拒否など)でだけ reject します。

// 404 でも reject しない
const res = await fetch("https://api.example.com/not-found");
console.log(res.ok);     // false
console.log(res.status); // 404

// 自前でエラー化するのが定石
if (!res.ok) {
  throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}

2. GET リクエストとクエリパラメータ

GET 系の操作はクエリパラメータの組み立てが本体です。文字列連結ではなく URL / URLSearchParams を使うのが現代の作法です。

2.1 URLSearchParams でクエリ組み立て

クエリの URL エンコードを自前でやってはいけません。URLSearchParams はスペース・日本語・記号をすべて正しくエンコードしてくれます。

// URLSearchParams で組み立てる
const params = new URLSearchParams({
  q: "fetch API 使い方",
  page: "1",
  sort: "new",
});

const res = await fetch(`https://api.example.com/search?${params}`);
const data = await res.json();
console.log(data);
// 実際のURL: ?q=fetch+API+%E4%BD%BF%E3%81%84%E6%96%B9&page=1&sort=new

2.2 URL オブジェクトで安全に組み立てる

ベース URL とパスを分けて扱うなら URL コンストラクタが安全です。searchParams プロパティ経由でクエリを操作できます。

// URL オブジェクトで安全に組み立てる
const url = new URL("/search", "https://api.example.com");
url.searchParams.set("q", "fetch");
url.searchParams.set("page", 1);
url.searchParams.append("tag", "js");
url.searchParams.append("tag", "ts");

console.log(url.toString());
// https://api.example.com/search?q=fetch&page=1&tag=js&tag=ts

const res = await fetch(url);

2.3 配列パラメータの渡し方

配列を 1 つのキーに複数値として渡すには append を繰り返します。API の流儀(tag[]=js&tag[]=ts 形式など)に合わせて整形してください。

// 配列パラメータ: tag=js&tag=ts
const params = new URLSearchParams();
["js", "ts", "node"].forEach((t) => params.append("tag", t));

// 配列パラメータ: tag[]=js&tag[]=ts(Rails / PHP 互換)
const railsParams = new URLSearchParams();
["js", "ts"].forEach((t) => railsParams.append("tag[]", t));

3. POST リクエストとボディの作り方

fetch の POST はメソッド・ヘッダ・ボディの 3 点セットで決まります。JSON・フォーム・ファイル・テキストで作法が異なるため、すべて押さえておきます。

3.1 JSON POST

もっともよく使う JSON POST です。Content-Type を必ず明示してください。JSON.stringify を忘れるとオブジェクトが [object Object] として送信されます。

// JSON POST
const res = await fetch("https://api.example.com/users", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Taro", age: 30 }),
});

if (!res.ok) throw new Error(`HTTP ${res.status}`);
const created = await res.json();
console.log(created.id);

3.2 application/x-www-form-urlencoded

旧来のフォーム送信や OAuth トークンエンドポイントなど、フォーム形式を要求する API はまだ多いです。URLSearchParams を body に渡すだけで自動的にこの形式になります。

// application/x-www-form-urlencoded
const body = new URLSearchParams({
  grant_type: "password",
  username: "alice",
  password: "secret",
});

const res = await fetch("https://api.example.com/oauth/token", {
  method: "POST",
  body, // Content-Type は自動付与される
});

3.3 PUT / PATCH / DELETE

POST 以外のメソッドも method オプションを変えるだけです。DELETE は body を持たないのが慣習ですが、必要なら付けることもできます。

// PUT(完全置換)
await fetch("https://api.example.com/users/1", {
  method: "PUT",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Taro", age: 31 }),
});

// PATCH(部分更新)
await fetch("https://api.example.com/users/1", {
  method: "PATCH",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ age: 31 }),
});

// DELETE
await fetch("https://api.example.com/users/1", { method: "DELETE" });

4. Headers と認証

Headers の組み立てはオブジェクトリテラルでも可能ですが、複数値・大文字小文字・複製対応のためには Headers クラスを使うのが安全です。

4.1 Headers クラスの使い方

Headers はキーが大文字小文字を区別しませんset は上書き、append は追加、delete は削除です。

// Headers の基本操作
const headers = new Headers();
headers.set("Accept", "application/json");
headers.set("X-Request-Id", crypto.randomUUID());
headers.append("X-Trace", "client");
headers.append("X-Trace", "v2"); // 同じキーに追加

console.log(headers.get("accept"));       // 大文字小文字不問
console.log(headers.get("x-trace"));      // "client, v2"

const res = await fetch("https://api.example.com/me", { headers });

4.2 Authorization: Bearer トークン

JWT 認証や API トークン認証で最も使うパターンです。トークンは環境変数や安全なストレージから取得し、決してハードコーディングしないでください。

// Bearer トークン認証
const token = localStorage.getItem("access_token");

const res = await fetch("https://api.example.com/me", {
  headers: {
    Authorization: `Bearer ${token}`,
    Accept: "application/json",
  },
});

if (res.status === 401) {
  // トークン失効 → リフレッシュへ
}

4.3 Basic 認証

Basic 認証は btoa(Node では Buffer.from)で user:pass を Base64 化します。HTTPS 必須です。

// Basic 認証(ブラウザ)
const credentials = btoa("alice:secret");
const res1 = await fetch("https://api.example.com/admin", {
  headers: { Authorization: `Basic ${credentials}` },
});

// Basic 認証(Node.js)
const nodeCredentials = Buffer.from("alice:secret").toString("base64");
const res2 = await fetch("https://api.example.com/admin", {
  headers: { Authorization: `Basic ${nodeCredentials}` },
});

4.4 Cookie 送信(credentials)

セッション Cookie を送るには credentials オプションを明示する必要があります。同一オリジンでもデフォルトでは省略 Cookie を送らないブラウザ仕様変更が進んでいるため、明示推奨です。

// Cookie 送信オプション
// "omit"        : Cookie を送らない
// "same-origin" : 同一オリジンのみ送る(デフォルト)
// "include"     : クロスオリジンでも送る(CORS 設定要)
const res = await fetch("https://api.example.com/me", {
  credentials: "include",
});

4.5 CORS と mode

クロスオリジン通信では mode オプションが効きます。cors がデフォルト、no-cors はレスポンスを読まずに送り捨てるモード(画像や計測ピクセル用)です。

// CORS preflight が発生するリクエスト(カスタムヘッダ付き)
const res = await fetch("https://api.example.com/data", {
  method: "POST",
  mode: "cors",                                  // デフォルト
  credentials: "include",
  headers: {
    "Content-Type": "application/json",
    "X-Custom-Header": "yes",                    // ← preflight 発生
  },
  body: JSON.stringify({ ok: true }),
});

// 計測ピクセル(レスポンス不要)
fetch("https://analytics.example.com/pixel", { mode: "no-cors" });

5. FormData と File アップロード

ファイルアップロードや multipart 送信には FormData を使います。Content-Type を自分で設定してはいけません。boundary が壊れて送信に失敗します。

5.1 FormData の基本

FormData は HTML の <form> をそのまま JS で扱える API です。テキスト・ファイル・Blob を混在させて送れます。

// FormData の基本
const fd = new FormData();
fd.append("name", "Taro");
fd.append("age", "30");
fd.append("tags", "js");
fd.append("tags", "ts"); // 同じキー複数可

const res = await fetch("https://api.example.com/users", {
  method: "POST",
  body: fd, // Content-Type は自動付与(boundary 込み)
});

5.2 input[type=file] からアップロード

<input type="file">files プロパティから File オブジェクトを取り出し、FormData に詰めるだけです。複数ファイルは multiple 属性で対応します。

// <input type="file" id="upload" multiple>
const input = document.getElementById("upload");

input.addEventListener("change", async () => {
  const fd = new FormData();
  for (const file of input.files) {
    fd.append("files", file, file.name);
  }
  fd.append("note", "uploaded from browser");

  const res = await fetch("https://api.example.com/upload", {
    method: "POST",
    body: fd,
  });
  console.log(await res.json());
});

5.3 Blob を直接ボディに渡す

multipart が不要で、生のバイナリをそのまま送りたい場合は Blob をボディに渡せます。画像生成 API などへの送信で頻出パターンです。

// 生バイナリを直接 POST
const blob = new Blob(["hello world"], { type: "text/plain" });

const res = await fetch("https://api.example.com/upload-raw", {
  method: "POST",
  headers: { "Content-Type": "text/plain" },
  body: blob,
});

5.4 Node.js でファイルアップロード

Node 22+ では node:fs/promisesopenAsBlob でファイルを Blob 化できます。ブラウザと同じ FormData コードがそのまま動きます。

// Node.js でファイルアップロード
import { openAsBlob } from "node:fs/promises";

const blob = await openAsBlob("./report.pdf");

const fd = new FormData();
fd.append("file", blob, "report.pdf");
fd.append("title", "Q1 レポート");

const res = await fetch("https://api.example.com/upload", {
  method: "POST",
  body: fd,
});

6. Response の読み方と検査

Response オブジェクトはストリームベースです。json() / text() / blob() / arrayBuffer() / formData() のいずれかで 1 度だけ読み切るのが基本です。2 回呼ぶとエラーになります。

6.1 各種読み出しメソッド

用途に応じて読み出し方法を選びます。JSON / プレーンテキスト / バイナリ / 画像 / フォーム形式のどれかに必ず分類できます。

// JSON
const data = await res.json();

// プレーンテキスト・HTML・XML
const html = await res.text();

// バイナリ(画像・PDF・音声)
const blob = await res.blob();

// ArrayBuffer(WebAssembly / WebGL 用)
const buf = await res.arrayBuffer();

// multipart/form-data(レアだが標準)
const fd = await res.formData();

6.2 ステータスとヘッダの検査

レスポンスの状態を見るには status / ok / statusText / headers を使います。ok は 200-299 の範囲で true です。

// ステータス検査
const res = await fetch("https://api.example.com/data");
console.log(res.status);                            // 200
console.log(res.statusText);                        // "OK"
console.log(res.ok);                                // true(2xx 系のみ)
console.log(res.redirected);                        // リダイレクトされたか
console.log(res.url);                               // 最終 URL
console.log(res.type);                              // "basic" / "cors" / "opaque"
console.log(res.headers.get("content-type"));       // ヘッダ単一取得

for (const [k, v] of res.headers) {
  console.log(k, v);                                // ヘッダ全列挙
}

6.3 二重読み出しを避ける clone()

同じレスポンスから 2 種類の解釈をしたい場合は clone() で複製します。コピーは body ストリームを別々に保持します。

// clone でログ用に複製
const res = await fetch("https://api.example.com/data");
const forLog = res.clone();

console.log("[raw]", await forLog.text());          // ログ用に文字列で見る
const json = await res.json();                      // 本処理は JSON

7. AbortController とキャンセル

fetch のキャンセル機構AbortController です。React の useEffect クリーンアップ・タブ切替時のキャンセル・タイムアウト実装まで、現代の fetch では避けて通れません。

7.1 AbortController の基本

AbortController を作り、その signal を fetch に渡します。controller.abort() を呼ぶと fetch が AbortError で reject します。

// 手動キャンセル
const controller = new AbortController();

setTimeout(() => controller.abort(), 3000); // 3 秒で打ち切り

try {
  const res = await fetch("https://api.example.com/slow", {
    signal: controller.signal,
  });
  const data = await res.json();
} catch (e) {
  if (e.name === "AbortError") {
    console.log("キャンセルされました");
  } else {
    throw e;
  }
}

7.2 AbortSignal.timeout で 1 行タイムアウト

Node 17+ / モダンブラウザでは AbortSignal.timeout(ms)タイムアウト専用 Signalを 1 行で作れます。AbortController を自前で組まなくて済みます。

// 5 秒で自動タイムアウト
try {
  const res = await fetch("https://api.example.com/slow", {
    signal: AbortSignal.timeout(5000),
  });
  console.log(await res.json());
} catch (e) {
  if (e.name === "TimeoutError") {
    console.log("タイムアウト");
  }
}

7.3 AbortSignal.any で複合シグナル

「ユーザーキャンセル」と「タイムアウト」を両立したい場合、AbortSignal.any([sig1, sig2]) でどちらかが発火したらキャンセルするOR 結合シグナルを作れます。

// ユーザーキャンセル + 10 秒タイムアウト
const userController = new AbortController();
button.addEventListener("click", () => userController.abort());

const combined = AbortSignal.any([
  userController.signal,
  AbortSignal.timeout(10_000),
]);

const res = await fetch("https://api.example.com/heavy", {
  signal: combined,
});

7.4 React useEffect でのキャンセル

コンポーネントアンマウント時に fetch を打ち切るのは現代 React の必須作法です。Strict Mode で 2 回マウントされる挙動にも耐えられます。

// React useEffect でのキャンセル
useEffect(() => {
  const ac = new AbortController();

  (async () => {
    try {
      const res = await fetch("/api/users", { signal: ac.signal });
      const users = await res.json();
      setUsers(users);
    } catch (e) {
      if (e.name !== "AbortError") console.error(e);
    }
  })();

  return () => ac.abort();
}, []);

8. ReadableStream とストリーミング

fetch の res.bodyReadableStream です。大きな JSON Lines・ログ・LLM のトークンストリーム・進捗付きダウンロードなどを逐次処理できます。

8.1 ReadableStream を逐次読む

for await...of でストリームから Uint8Array のチャンクを順次取り出せます。TextDecoder で文字列に変換するのが定番です。

// ストリーミングで逐次読み出し
const res = await fetch("https://api.example.com/large.txt");
const decoder = new TextDecoder();

for await (const chunk of res.body) {
  const text = decoder.decode(chunk, { stream: true });
  process.stdout.write(text);
}

8.2 進捗付きダウンロード

Content-Length ヘッダと累積バイト数を比較すれば進捗率が出せます。XHR の onprogress に相当する処理を fetch でも書けます。

// 進捗付きダウンロード
const res = await fetch("https://example.com/large.zip");
const total = Number(res.headers.get("content-length")) || 0;
const reader = res.body.getReader();
const chunks = [];
let received = 0;

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  chunks.push(value);
  received += value.length;
  if (total) console.log(`${((received / total) * 100).toFixed(1)} %`);
}

const blob = new Blob(chunks);

8.3 JSON Lines / NDJSON のストリーム処理

1 行 1 JSON の NDJSON 形式を逐次処理する例です。バッファリングして n で分割し、1 行ずつ JSON.parse します。

// NDJSON のストリーム処理
const res = await fetch("https://api.example.com/events.ndjson");
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = "";

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buf += value;
  const lines = buf.split("n");
  buf = lines.pop(); // 未完了行は次回へ
  for (const line of lines) {
    if (!line) continue;
    const event = JSON.parse(line);
    console.log(event);
  }
}

8.4 OpenAI 風 SSE ストリーム処理

LLM API でよく使われる Server-Sent Events 風のフォーマット(data: {...}nn)を逐次パースする実装です。EventSource は POST 不可なので fetch で実装するのが現代の定番です。

// OpenAI 互換 SSE ストリーム処理
const res = await fetch("https://api.example.com/v1/chat/completions", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
  body: JSON.stringify({
    model: "gpt-4o",
    stream: true,
    messages: [{ role: "user", content: "Hello" }],
  }),
});

const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += value;
  const parts = buffer.split("nn");
  buffer = parts.pop();
  for (const part of parts) {
    const line = part.replace(/^data:s*/, "");
    if (line === "[DONE]") return;
    const json = JSON.parse(line);
    process.stdout.write(json.choices[0].delta.content ?? "");
  }
}

9. リトライ・並列・スロットリング

本番運用で必須なのが「失敗時のリトライ」と「並列実行の制御」です。標準 fetch には組み込みがないので自前で実装します。

9.1 指数バックオフリトライ

5xx と 429(Too Many Requests)だけ再試行し、4xx の他のエラーは即時失敗とするのが鉄則です。間隔は 2^n * base で延ばします。

// 指数バックオフリトライ
async function fetchWithRetry(url, init = {}, maxAttempts = 4) {
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const res = await fetch(url, init);
      if (res.ok) return res;
      if (res.status < 500 && res.status !== 429) {
        throw new Error(`HTTP ${res.status}`); // クライアントエラーは即失敗
      }
    } catch (e) {
      if (attempt === maxAttempts) throw e;
    }
    const wait = 2 ** (attempt - 1) * 300 + Math.random() * 200;
    await sleep(wait); // 300ms, 600ms, 1.2s, 2.4s + ジッタ
  }
}

9.2 Retry-After ヘッダ対応

429 / 503 では Retry-After ヘッダで「何秒待つべきか」をサーバが指示してくることがあります。指示に従うのが行儀の良いクライアントです。

// Retry-After ヘッダ対応
async function fetchWithRetryAfter(url, init = {}, max = 3) {
  for (let i = 0; i < max; i++) {
    const res = await fetch(url, init);
    if (res.status !== 429 && res.status !== 503) return res;
    const retryAfter = Number(res.headers.get("retry-after")) || 1;
    await new Promise((r) => setTimeout(r, retryAfter * 1000));
  }
  throw new Error("retry exhausted");
}

9.3 並列 fetch with Promise.all

無関係な複数 API を同時に叩くなら Promise.all です。1 つでも失敗すると全体が reject する点に注意。

// 並列 fetch
const [u, p, c] = await Promise.all([
  fetch("/api/users").then((r) => r.json()),
  fetch("/api/posts").then((r) => r.json()),
  fetch("/api/comments").then((r) => r.json()),
]);

9.4 部分失敗を許容する Promise.allSettled

「失敗しても他の結果は欲しい」という現実的な要件には Promise.allSettled です。status: "fulfilled" | "rejected" で分岐します。

// 部分失敗を許容
const results = await Promise.allSettled([
  fetch("/api/a").then((r) => r.json()),
  fetch("/api/b").then((r) => r.json()),
  fetch("/api/c").then((r) => r.json()),
]);

for (const r of results) {
  if (r.status === "fulfilled") console.log("OK", r.value);
  else console.error("NG", r.reason);
}

9.5 同時実行数の制限(セマフォ)

API のレートリミット対策に、同時に走る fetch を N 本までに絞ります。自前セマフォを組まずに済む軽量実装です。

// 同時 N 本までで並列実行
async function pMap(items, concurrency, mapper) {
  const results = [];
  const executing = new Set();
  for (const item of items) {
    const p = Promise.resolve(mapper(item)).then((r) => {
      executing.delete(p);
      return r;
    });
    results.push(p);
    executing.add(p);
    if (executing.size >= concurrency) await Promise.race(executing);
  }
  return Promise.all(results);
}

const urls = [...Array(100)].map((_, i) => `/api/items/${i}`);
const data = await pMap(urls, 5, (u) => fetch(u).then((r) => r.json()));

10. fetch ラッパーと型安全な設計

実プロジェクトでは生 fetch を毎回呼ぶのではなく、共通のラッパー関数を作って認証・エラー処理・ロギングを統一します。

10.1 TypeScript ジェネリック fetch

レスポンスの型を呼び出し側で指定できる、最小のジェネリックラッパーです。これだけでも型補完が劇的に良くなります。

// ジェネリック fetch
export async function apiFetch<T>(
  path: string,
  init?: RequestInit
): Promise<T> {
  const res = await fetch(`https://api.example.com${path}`, init);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<T>;
}

interface User { id: number; name: string }
const user = await apiFetch<User>("/users/1");
console.log(user.name); // 型補完が効く

10.2 Zod でレスポンス検証

「型はあるけど実体は信用していない」のが外部 API です。Zod で実行時バリデーションをかませると、API 仕様変更を早期検出できます。

// Zod スキーマ検証
import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;

export async function fetchUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return UserSchema.parse(await res.json()); // ← 実行時検証
}

10.3 Result 型を返す safeFetch

throw を避けて Result 型(成功か失敗かの判別共用体)を返すスタイルです。Rust や Go に親しいエンジニアに好まれ、呼び出し側でのエラー漏れを防げます。

// Result 型を返す safeFetch
type Ok<T>   = { ok: true; data: T };
type Err     = { ok: false; error: Error };
type Result<T> = Ok<T> | Err;

export async function safeFetch<T>(
  url: string,
  init?: RequestInit
): Promise<Result<T>> {
  try {
    const res = await fetch(url, init);
    if (!res.ok) return { ok: false, error: new Error(`HTTP ${res.status}`) };
    return { ok: true, data: (await res.json()) as T };
  } catch (e) {
    return { ok: false, error: e instanceof Error ? e : new Error(String(e)) };
  }
}

const r = await safeFetch<User>("/api/users/1");
if (r.ok) console.log(r.data.name);
else console.error(r.error.message);

10.4 共通ベース URL とデフォルトヘッダ

軽量な「クライアントファクトリ」を作っておくと、認証・トレース ID・共通ヘッダを 1 箇所で管理できます。axios の create 相当の自前実装です。

// クライアントファクトリ
export function createClient(baseUrl: string, defaultInit: RequestInit = {}) {
  return async (path: string, init: RequestInit = {}) => {
    const headers = new Headers(defaultInit.headers);
    new Headers(init.headers).forEach((v, k) => headers.set(k, v));
    return fetch(baseUrl + path, { ...defaultInit, ...init, headers });
  };
}

const api = createClient("https://api.example.com", {
  headers: { Accept: "application/json" },
  credentials: "include",
});

const res = await api("/users/1");

10.5 JWT 自動更新インターセプタ風

401 を検知したら自動でリフレッシュトークンを叩き、元のリクエストを再実行するパターンです。axios のインターセプタ相当を fetch で書きます。

// JWT 自動更新ラッパー
let accessToken = localStorage.getItem("access_token") ?? "";
let refreshing: Promise<string> | null = null;

async function refresh(): Promise<string> {
  refreshing ??= (async () => {
    const res = await fetch("/api/refresh", { method: "POST", credentials: "include" });
    const { access_token } = await res.json();
    accessToken = access_token;
    localStorage.setItem("access_token", access_token);
    refreshing = null;
    return access_token;
  })();
  return refreshing;
}

export async function authedFetch(input: string, init: RequestInit = {}) {
  const doFetch = (token: string) =>
    fetch(input, {
      ...init,
      headers: { ...init.headers, Authorization: `Bearer ${token}` },
    });

  let res = await doFetch(accessToken);
  if (res.status === 401) {
    const newToken = await refresh();
    res = await doFetch(newToken);
  }
  return res;
}

11. Node.js / Edge / Service Worker 環境別の注意点

fetch は標準化されたとはいえ、ランタイムごとに微妙な挙動差があります。実務でハマりやすい箇所を環境別に押さえます。

11.1 Node.js 22+ の fetch と undici

Node の fetch は内部実装が undici です。コネクションプール・HTTP/2・Keep-Alive の調整は undici の Agent 経由で行います。

// Node.js: undici Agent でコネクションプール設定
import { Agent, setGlobalDispatcher } from "undici";

setGlobalDispatcher(
  new Agent({
    keepAliveTimeout: 10_000,
    keepAliveMaxTimeout: 60_000,
    connections: 50, // 接続プール上限
  })
);

const res = await fetch("https://api.example.com/data");

11.2 SSR で fetch する場合の絶対 URL

Next.js などの SSR では相対 URL は使えません。サーバー側では 絶対 URL(process.env.API_BASE_URL など)を必ず指定します。

// Next.js Server Component
const base = process.env.API_BASE_URL!;     // https://api.example.com
const res = await fetch(`${base}/users`, {
  // Next.js 拡張: キャッシュ戦略
  next: { revalidate: 60 },
  // または: cache: "no-store" / "force-cache"
});
const users = await res.json();

11.3 Cloudflare Workers / Edge Runtime

Cloudflare Workers / Vercel Edge は fetch だけが HTTP クライアントです。Node 専用 API(http, fs)は使えません。fetch ベースで設計するのが必須です。

// Cloudflare Workers
export default {
  async fetch(request: Request): Promise<Response> {
    const upstream = await fetch("https://api.example.com/data", {
      cf: { cacheTtl: 300, cacheEverything: true }, // Cloudflare 拡張
    });
    return new Response(upstream.body, upstream);
  },
};

11.4 Service Worker での fetch インターセプト

Service Worker の fetch イベントを使うと、ページからのすべての HTTP リクエストを横取りしてキャッシュ応答・モック化できます。PWA の根幹技術です。

// service-worker.js
self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);

  // 画像はキャッシュ優先
  if (url.pathname.startsWith("/images/")) {
    event.respondWith(
      caches.match(event.request).then((cached) => cached || fetch(event.request))
    );
  }
});

11.5 fetch + キャッシュ API

ブラウザ側で能動的にキャッシュへ書き込み、次回からネット非依存でレスポンスを返す仕組みです。オフラインファースト設計に必須です。

// Cache Storage に書き込み
const cache = await caches.open("api-v1");
const res = await fetch("/api/users");
await cache.put("/api/users", res.clone());

// 次回はキャッシュから即時応答
const cached = await caches.match("/api/users");
if (cached) console.log(await cached.json());

12. 周辺ライブラリ比較と移行ガイド

fetch だけで完結する場面が増えた今でも、ライブラリには独自の便利機能があります。axios / ky / ofetch / wretch の特徴を押さえておきます。

12.1 axios との比較

axios の機能のうち fetch にないのは、自動 JSON シリアライズ・タイムアウトオプション・インターセプタ・進捗イベントです。これらは自前ラッパーで埋められます。

// axios
const { data } = await axios.post("/api/users", { name: "Taro" }, {
  headers: { Authorization: `Bearer ${token}` },
  timeout: 5000,
});

// 同等の fetch
const res = await fetch("/api/users", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${token}`,
  },
  body: JSON.stringify({ name: "Taro" }),
  signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();

12.2 ky の薄さと使い勝手

ky は fetch をベースにリトライ・タイムアウト・JSON 自動化を載せた極薄ラッパーです。Node・ブラウザ両対応で ESM 専用です。

// ky
import ky from "ky";

const data = await ky
  .post("https://api.example.com/users", {
    json: { name: "Taro" },
    retry: { limit: 3 },
    timeout: 5000,
  })
  .json();

12.3 ofetch(Nuxt エコシステム)

ofetch は Nuxt / Nitro が採用する fetch ラッパーで、自動 JSON パース・自動エラー化・Node/ブラウザ統一を実現します。

// ofetch
import { ofetch } from "ofetch";

const user = await ofetch("/api/users/1", {
  baseURL: "https://api.example.com",
  retry: 2,
});
// 自動で JSON パース・エラーは throw

12.4 wretch の流麗 API

メソッドチェインで読みやすい wretch です。リクエストエラーをコードごとにハンドラ登録できる発想がユニークです。

// wretch
import wretch from "wretch";

const user = await wretch("https://api.example.com")
  .url("/users/1")
  .auth(`Bearer ${token}`)
  .get()
  .notFound(() => null)
  .unauthorized(() => { throw new Error("login"); })
  .json();

13. SSE・WebSocket・GraphQL との位置づけ

fetch は HTTP リクエスト単発の道具で、サーバープッシュには不向きです。SSE・WebSocket と適切に使い分けるための地図を持っておきましょう。

13.1 EventSource(SSE)との比較

純粋なサーバープッシュなら EventSource が便利ですが、POST 不可・ヘッダ指定不可・自動再接続だけはあるという制約付きです。fetch でストリームを読む方が柔軟です。

// EventSource(POST 不可)
const es = new EventSource("/api/events");
es.onmessage = (e) => console.log(e.data);
es.onerror = () => es.close();

// fetch でも同等のことができ、POST + Authorization が使える
const res = await fetch("/api/events", {
  headers: { Authorization: `Bearer ${token}` },
});
// あとは 8.4 の SSE ストリーム処理と同じ

13.2 WebSocket との使い分け

双方向リアルタイム通信は WebSocket、片方向ストリームは SSE、リクエスト/レスポンスは fetch という三層構造で覚えるのが分かりやすいです。

// WebSocket: 双方向
const ws = new WebSocket("wss://api.example.com/ws");
ws.onopen    = () => ws.send(JSON.stringify({ type: "hello" }));
ws.onmessage = (e) => console.log(JSON.parse(e.data));
ws.onclose   = () => console.log("closed");

13.3 GraphQL を fetch だけで叩く

GraphQL もただの HTTP POST なので、Apollo Client なしに fetch だけで叩けます。シンプルな読み取り用途なら 1 行で済みます。

// GraphQL を fetch だけで
const res = await fetch("https://api.example.com/graphql", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    query: `query($id: ID!) { user(id: $id) { id name } }`,
    variables: { id: "1" },
  }),
});
const { data, errors } = await res.json();
if (errors) throw new Error(errors[0].message);
console.log(data.user);

14. 落とし穴・メモリリーク・パフォーマンス

最後に、本番運用で実際に踏みやすい地雷をまとめます。これを知っているか否かで「動くだけのコード」と「壊れにくいコード」が分かれます。

14.1 ReadableStream を読まない場合のリーク

レスポンスを読まずに捨てると、特に Node.js ではコネクションが解放されません。不要なら明示的に cancel() するか arrayBuffer() で読み切ります。

// NG: body を読まずに捨てるとコネクションが滞留
const res = await fetch("https://api.example.com/heavy");
if (res.status !== 200) return; // ← body 未読のまま離脱

// OK: 明示的にキャンセル
const res2 = await fetch("https://api.example.com/heavy");
if (res2.status !== 200) {
  await res2.body?.cancel();
  return;
}

14.2 巨大レスポンスを json() で食わない

数百 MB の JSON を res.json() で読むと一気にメモリへ展開され、OOM の原因になります。NDJSON / ページネーション / ストリーミングパースに切り替えるのが鉄則です。

// NG: 巨大レスポンス全量展開
const all = await res.json(); // 500MB をメモリへ……

// OK: ページネーション
let page = 1;
while (true) {
  const res = await fetch(`/api/items?page=${page}&limit=1000`);
  const { items, hasNext } = await res.json();
  for (const item of items) process(item);
  if (!hasNext) break;
  page++;
}

14.3 default キャッシュ挙動とブラウザ差分

fetch の cache オプションはブラウザでは効きますが、Node では現状無視されます。意図しないキャッシュ事故を防ぐために、明示的に指定する習慣をつけましょう。

// cache オプション
await fetch("/api/data", { cache: "no-store" });      // 必ず取りに行く
await fetch("/api/data", { cache: "force-cache" });   // できる限りキャッシュ
await fetch("/api/data", { cache: "no-cache" });      // 検証付きキャッシュ
await fetch("/api/data", { cache: "reload" });        // キャッシュ無視で取得→キャッシュ更新

14.4 リダイレクトの追跡を止めたいとき

OAuth コールバックの検証など、リダイレクトを追わずに Location ヘッダだけ見たい場合は redirect: "manual" です。

// リダイレクト追跡を止める
const res = await fetch("https://example.com/login-callback", {
  redirect: "manual", // 追跡しない
});
console.log(res.status);                            // 302
console.log(res.headers.get("location"));           // /dashboard

14.5 keepalive で離脱時送信

ユーザーがページを閉じる瞬間に計測ビーコンを飛ばしたいとき、keepalive: true を指定するとブラウザがバックグラウンドで送信完了させます(64KB 上限)。

// ページ離脱時の計測送信
window.addEventListener("beforeunload", () => {
  fetch("/api/beacon", {
    method: "POST",
    body: JSON.stringify({ event: "leave", at: Date.now() }),
    headers: { "Content-Type": "application/json" },
    keepalive: true,
  });
});

// navigator.sendBeacon でも同等(同期 API)
navigator.sendBeacon("/api/beacon", JSON.stringify({ event: "leave" }));

14.6 fetch を握る独自エラー型

throw 文字列やプレーン Error を放り投げるのは観測性が悪いです。HTTP ステータス・URL・レスポンス本文を持つ独自エラーで例外を統一しましょう。

// 独自 HttpError
export class HttpError extends Error {
  constructor(
    public status: number,
    public url: string,
    public bodyText: string
  ) {
    super(`HTTP ${status} ${url}`);
    this.name = "HttpError";
  }
}

export async function jsonFetch<T>(url: string, init?: RequestInit): Promise<T> {
  const res = await fetch(url, init);
  if (!res.ok) {
    const body = await res.text().catch(() => "");
    throw new HttpError(res.status, url, body);
  }
  return res.json() as Promise<T>;
}

14.7 まとめ:fetch を選ぶ判断軸

「ライブラリを足すか・標準 fetch で済ますか」を以下の軸で判断してください。逆に言えば、これら以外の理由で外部 SDK を入れるのは過剰です。

// fetch だけで十分なケース
// - 単発 REST 呼び出し
// - SSR / Edge / Worker 環境(Node 専用 lib が使えない)
// - バンドルサイズを最優先する PWA / Web Components
// - LLM の SSE ストリーム処理

// ライブラリを検討すべきケース
// - 多数のキャッシュ・楽観的更新 → TanStack Query / SWR
// - GraphQL の正規化キャッシュ → Apollo / urql
// - 多重インターセプタ・進捗・cancel token → axios
// - 多数のレートリミット制御・並列制御 → got / undici 直接

fetch は「ただの HTTP クライアント」を超えて、JS ランタイム共通の標準 I/Oと化しました。GET/POST の素朴な使い方から、AbortSignal による高度なキャンセル、ReadableStream を活かしたリアルタイム処理、Zod 検証や Result 型による堅牢な設計まで、fetch を素手で使いこなせるエンジニアは、どんなライブラリやフレームワークに乗り換えても土台が揺らぎません。本記事の 40 以上のサンプルを 1 つでも多く実プロジェクトに移植して試すことが、fetch を本当に身につける一番の近道です。

本記事と相互補完で読むと効果が高い記事:Promise 完全実践ガイド(Promise の観点)、async/await 完全実践ガイド(async 構文の観点)、エラー処理完全実践ガイド(エラー設計の観点)、Iterator/Generator 完全実践ガイド(Async Iterator 観点)。

コメント

タイトルとURLをコピーしました