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. fetch API の全体像と最小コード
- 2. GET リクエストとクエリパラメータ
- 3. POST リクエストとボディの作り方
- 4. Headers と認証
- 5. FormData と File アップロード
- 6. Response の読み方と検査
- 7. AbortController とキャンセル
- 8. ReadableStream とストリーミング
- 9. リトライ・並列・スロットリング
- 10. fetch ラッパーと型安全な設計
- 11. Node.js / Edge / Service Worker 環境別の注意点
- 12. 周辺ライブラリ比較と移行ガイド
- 13. SSE・WebSocket・GraphQL との位置づけ
- 14. 落とし穴・メモリリーク・パフォーマンス
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/promises の openAsBlob でファイルを 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.body は ReadableStream です。大きな 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 観点)。

コメント