Fetch APIの内部(リクエスト/レスポンスとボディストリーム)
fetchが「2回読めない」「CORSで詰まる」理由が腑に落ちる。Request/Responseの不変設計、modeとcredentials、ボディの一度きり消費、AbortControllerの中断を内部動作から正確に解きます。
- 1.Request/Response はボディ以外のメタデータが実質イミュータブルで、改変したいときは新インスタンスを作る。ボディは ReadableStream の一度きりのソースで、読むと bodyUsed が立ち二度目は失敗する。
- 2.mode は same-origin / cors / no-cors / navigate で同一オリジン制約と読めるレスポンス種別を決め、credentials は omit / same-origin / include で Cookie 等の資格情報を送るかを決める。両者は独立した別の軸。
- 3.AbortController の signal を fetch に渡すと、abort() で signal が aborted になり、進行中の取得が AbortError で reject され、ボディストリームも中断される。signal は使い捨てで、リトライごとに作り直す。
fetch() は「URL を渡すと Promise が返る便利関数」に見えますが、その下には Fetch 標準が定義する厳密なオブジェクトモデルとアルゴリズムがあります。実務でぶつかる「レスポンスを 2 回読もうとして落ちる」「mode と credentials を混同して CORS で詰まる」「中断したつもりが通信が走り続ける」といった事故は、すべて内部設計を知れば予防できます。ここでは Request/Response の不変性、mode/credentials、ボディの一度きり消費、そして AbortController による中断を原理から見ていきます。
Request と Response は何が不変か
fetch(input, init) を呼ぶと、ブラウザはまず引数から Request オブジェクトを構築します。new Request(url, init) を自分で作って渡すこともでき、fetch は内部的に常にこの Request を経由します。Request と Response の重要な性質は、ボディ以外のメタデータが実質イミュータブルである点です。method・url・mode・credentials・headers などは、構築時に確定したら後から書き換えるための setter を持ちません。
const req = new Request('/api/items', { method: 'POST', mode: 'cors' });
req.method = 'GET'; // 黙って無視される(書き換わらない)
console.log(req.method); // "POST"
// 変更したいときは「元を種にして新インスタンス」を作る
const req2 = new Request(req, { method: 'GET' });
この不変設計には理由があります。fetch は内部でリダイレクト追跡やリトライ、Service Worker による横取りなどで Request を複数回使い回す可能性があり、途中で属性が書き換わると取得アルゴリズムの一貫性が崩れます。だから「改変は新インスタンス」という関数型的な作法が標準で強制されています。例外は Headers オブジェクトで、これは append/set/delete を持つ可変コンテナですが、Request/Response にいったん紐づいた後はガードがかかり、種別によっては変更が拒否されます。
Headers には guard という内部状態があり、immutable/request/request-no-cors/response/none のいずれかを取ります。Response から得た res.headers は response ガードで、禁止レスポンスヘッダの書き換えが弾かれます。no-cors モードの Request では、安全な少数のヘッダ(いわゆる CORS-safelisted)以外を set しても黙って無視されます。ヘッダが「付けたのに送られない」ときは、このガードを疑うと早いです。
mode:同一オリジン制約と読めるレスポンス
mode は「このリクエストがクロスオリジン制約とどう向き合うか」を決める軸です。値は same-origin/cors/no-cors/navigate の 4 つ(navigate はブラウザのページ遷移用で、スクリプトからは通常指定しません)。前提として 同一オリジンポリシーとサイト分離の信頼境界 と CORS の仕組み を押さえておくと、各モードの意味がつながります。
| mode | クロスオリジンの扱い | 得られる Response |
|---|---|---|
| same-origin | 別オリジンはネットワークエラー(拒否) | 通常の読めるレスポンス |
| cors | CORS ヘッダが揃えば許可 | basic(同一) or cors(許可ヘッダ範囲で読める) |
| no-cors | 別オリジンへ送れるが結果を読めない | opaque(status 0、本文・ヘッダ読めず) |
| navigate | ページ遷移用(手動指定しない) | — |
fetch の既定 mode は cors です。クロスオリジンに cors で投げると、ブラウザはサーバーの Access-Control-Allow-Origin 等を検査し、許可されればレスポンスを読めます。一方 no-cors は「送るが読まない」ための特殊モードで、返るのは opaque レスポンスです。status は 0、ヘッダは空に見え、本文も読めません。<img> や <script> 相当の「副作用だけ欲しい」用途や、Service Worker が中身を見ずにキャッシュへ放り込む場合に使います。no-cors を「CORS エラーを消す呪文」と誤解して使うと、読めないレスポンスが返るだけで何も解決しません。
no-cors の戻り値は opaque で、res.ok は常に false、res.status は 0、res.json() も実質使えません。「CORS でブロックされる」を回避する正しい手段はサーバー側で適切な CORS ヘッダを返すことであり、no-cors ではありません。opaque を選ぶのは中身が不要なときだけです。
credentials:資格情報を送るかは別の軸
credentials は mode とは独立した別の軸で、Cookie・TLS クライアント証明書・HTTP 認証といった資格情報をリクエストに乗せ、レスポンスの Set-Cookie を反映するかを制御します。値は omit/same-origin/include の 3 つです。Cookie の挙動そのものは別記事に譲りますが、ここで重要なのは「mode: cors だから Cookie が飛ぶ」わけではない、という点です。
| credentials | Cookie 等を送る | 用途 |
|---|---|---|
| omit | 送らない | 完全に匿名の取得 |
| same-origin(既定) | 同一オリジンのみ送る | 通常の自サイト API |
| include | クロスオリジンにも送る | 別オリジンの認証付き API |
fetch の既定は same-origin です。したがってクロスオリジンの認証付き API を叩くときは、明示的に credentials: 'include' を指定しないと Cookie が送られません。さらにサーバー側も Access-Control-Allow-Credentials: true を返し、かつ Access-Control-Allow-Origin を * ではなく具体的なオリジンにする必要があります。クライアントの include とサーバーの 2 条件が揃って初めて、クロスオリジンで資格情報付きリクエストが成立します。
頻出の引っかけは「mode を cors にすれば Cookie も送られる」という誤り。mode は読めるレスポンス種別を、credentials は資格情報の送出を、それぞれ別個に決めます。クロスオリジンで Cookie を送るには mode: 'cors'(既定)に加えて credentials: 'include' が必要、と覚えます。
ボディは一度きり:bodyUsed とストリーム
Request/Response のボディは、内部的に ReadableStream です。ストリームは「一度流れたら戻れない」一方向のデータソースで、ここから Fetch API のもっとも有名な制約が生まれます。ボディは一度しか読めない――これがネットワークのバックプレッシャと省メモリを両立させる設計です。本文全体をメモリに溜め込まず、到着したチャンクから順に消費できる代わりに、巻き戻しはできません。
res.json()/res.text()/res.arrayBuffer()/res.blob()/res.formData() はいずれもストリームを最後まで読み切ってロックを掛ける操作です。一度どれかを呼ぶと内部フラグ bodyUsed が true になり、二度目の読み取りは TypeError で失敗します。
const res = await fetch('/api/data');
const a = await res.json(); // ストリームを読み切る → bodyUsed = true
const b = await res.json(); // TypeError: body stream already read
同じレスポンスを 2 通りに解釈したい、あるいは読みつつキャッシュにも保存したい場合は、読む前に clone() します。clone() はストリームを 2 本に分岐(tee)し、それぞれ独立に一度ずつ読めるようにします。ただし分岐した 2 本の消費速度が大きくずれると、速い側のために遅い側の未読チャンクをバッファし続けるため、メモリを食う点に注意します。
const res = await fetch('/api/data');
const forCache = res.clone(); // 読む前に分岐(tee)
await cache.put('/api/data', forCache); // 片方はキャッシュへ
const json = await res.json(); // もう片方はアプリで利用
低レベルに res.body(ReadableStream)へ直接アクセスし、getReader() で read() ループを回せば、チャンク到着のたびに処理できます。巨大ファイルの進捗表示や、サーバー送信のストリーミング応答(行区切り JSON など)を全体待ちせずに逐次処理する用途で効きます。
const res = await fetch('/large');
const reader = res.body.getReader(); // ストリームをロック
let received = 0;
while (true) {
const { done, value } = await reader.read(); // value は Uint8Array チャンク
if (done) break;
received += value.length; // 進捗を逐次更新できる
}
res.body.getReader() はストリームをロックするため、その後に res.json() 等を呼ぶと失敗します。逆も同様です。1 つのレスポンスに対して「高レベル抽出メソッド」か「手動リーダ」のどちらか一方を選びます。両方使いたいなら先に clone() しておきます。
AbortController:中断の内部動作
fetch は途中でキャンセルする手段を引数に持たないため、中断は AbortController 経由で行います。仕組みはシグナル伝播です。コントローラの signal(AbortSignal)を fetch(url, { signal }) に渡しておき、controller.abort() を呼ぶと次の連鎖が起きます。
signal.abortedが true になり、signal上でabortイベントが発火する。fetchはこのイベントを内部で監視しており、進行中の取得処理を停止し、返した Promise をAbortError(DOMException) で reject する。- レスポンスボディの読み取り中であれば、その
ReadableStreamもエラーで閉じられ、read()ループは中断される。
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 5000); // 5秒でタイムアウト中断
try {
const res = await fetch('/slow', { signal: controller.signal });
const data = await res.json();
clearTimeout(t);
} catch (e) {
if (e.name === 'AbortError') {
// 中断された(タイムアウト or ユーザー操作)。通信は実際に止まっている
} else {
throw e; // それ以外は本物のネットワーク/パースエラー
}
}
ここで本質的なのは、abort() が単に Promise を捨てるのではなく、進行中のネットワーク取得そのものを止める点です。すでに送出済みのバイトは取り消せませんが、未受信のレスポンス受信は打ち切られ、リソースが解放されます。Promise.race で「遅い fetch を無視する」だけの実装は、裏で通信が走り続けて帯域とコネクションを浪費しますが、AbortController は実際に止めます。
AbortSignal は使い捨てである点も重要です。一度 abort 済みのシグナルは永続的に aborted のままなので、リトライのたびに新しい AbortController を作り直す必要があります。既に aborted なシグナルを fetch に渡すと、取得は始まる前に即 AbortError で reject されます。なお、よくある「N 秒でタイムアウト」は AbortSignal.timeout(ms) で簡潔に書け、複数シグナルの統合は AbortSignal.any([...]) で行えます。
中断時の reject は AbortError(e.name === 'AbortError')で、サーバー応答 4xx/5xx とは別物です。fetch の Promise は HTTP エラーステータスでは reject しません(res.ok/res.status で判定する)。reject されるのはネットワーク到達失敗・CORS 拒否・中断のときだけです。catch で一括りにせず、AbortError とそれ以外を分けて扱わないと、ユーザーのキャンセルを「障害」として誤通知してしまいます。
イベントループとの関係
fetch が返す Promise や、ストリームの read() が返す Promise の解決は、すべて イベントループとマイクロタスク の上で進みます。ネットワーク完了やチャンク到着は内部的にタスクとしてキューに積まれ、その後の .then/await 継続はマイクロタスクとして処理されます。await res.json() が「一見ブロックしている」ように見えても、実体はストリーム読み取りの非同期継続がイベントループに乗っているだけで、その間メインスレッドは他のタスクを処理できます。中断時の AbortError reject も同じ仕組みで、abort イベント発火 → Promise reject → catch 継続というマイクロタスクの連鎖として観測されます。
まとめ
fetch の下では、Request/Response はボディ以外が実質イミュータブルで、改変は新インスタンスで行います。mode(same-origin/cors/no-cors/navigate)は同一オリジン制約と読めるレスポンス種別を、credentials(omit/same-origin/include)は資格情報の送出を、それぞれ独立に決めます。クロスオリジンで Cookie を送るには cors かつ include とサーバー側 2 条件が要ります。ボディは ReadableStream の一度きりのソースで、json() 等で読むと bodyUsed が立ち二度目は失敗するため、2 度使うなら読む前に clone()(tee)します。中断は AbortController の signal 伝播で行い、abort() は Promise を AbortError で reject するだけでなく進行中の取得とストリームを実際に止めます。シグナルは使い捨てなのでリトライごとに作り直します。背景の制約は 同一オリジンポリシー と CORS、非同期の土台は イベントループ と合わせて把握すると、fetch の挙動がすべて一本の線でつながります。
Web/フロントエンド Article
Fetch APIの内部(リクエスト/レスポンスとボディストリーム)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
Fetch API
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
mode は same-origin / cors / no-cors / navigate で同一オリジン制約と読めるレスポンス種別を決め、credentials は omit / same-origin / include で Cookie 等の資格情報を送るかを決める。両者は独立した別の軸。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「Fetch API / Web」に近いか確認する。
- 強みである「Request/Response はボディ以外のメタデータが実質イミュータブルで、改変したいときは新インスタンスを作る。ボディは ReadableStream の一度きりのソースで、読むと bodyUsed が立ち二度目は失敗する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。