sue@blog ~ /posts/react-native-fetch-vs-browser-fetch
$ cd ../
$ cat post.metadata

React Nativeのfetchはブラウザのfetchと何が違うのか

date: 2026-03-17 React Native Networking Fetch API
Web開発者がReact Nativeに入門したとき、「同じfetch()なのになぜ挙動が違うのか」と戸惑うことが多い。CORSが存在しない理由、Cookie管理の根本的な違い、ネイティブHTTPクライアントが裏で何をしているのかを、図解を交えて解説する。

はじめに: 同じfetch()、違う世界

$ node --eval "console.log(typeof fetch)"

React Nativeでアプリを作り始めたWeb開発者がまず安心するのが、「fetchがそのまま使える」という事実です。

App.tsx
// ブラウザでもReact Nativeでも、書き方は同じ
const response = await fetch('https://api.example.com/users');
const data = await response.json();

APIは同じです。しかし、裏側で動いている仕組みがまったく違います。ブラウザのfetchはブラウザエンジン(Chromium, WebKit)が実装したWeb標準のFetch APIです。React Nativeのfetchは、JavaScriptのPolyfillを通じてOSのネイティブHTTPクライアントを呼び出しています。

この違いを理解していないと、「ブラウザでは動くのにアプリでは動かない」「なぜCORSエラーが出ないのか」「Cookieが消えた」といった問題にぶつかります。

アーキテクチャ: 何が何を呼んでいるのか

$ diff --color browser.arch native.arch

まず、ブラウザとReact Nativeでfetchが実行される流れを見てみましょう。

flowchart TB subgraph Browser["ブラウザ環境"] JS_B["JavaScript
fetch()"] --> FetchAPI["Fetch API
(ブラウザエンジン実装)"] FetchAPI --> NetStack["ネットワークスタック
(Chromium / WebKit)"] NetStack --> CORS{"CORS
チェック"} CORS -->|"許可"| HTTP_B["HTTP リクエスト"] CORS -->|"拒否"| ERR["エラー
(ブロック)"] HTTP_B --> Server["サーバー"] end subgraph RN["React Native環境"] JS_RN["JavaScript
fetch()"] --> Polyfill["Polyfill
(XMLHttpRequest ラッパー)"] Polyfill --> Bridge["ネイティブブリッジ
(JSI / Bridge)"] Bridge --> Native{"OS別
HTTPクライアント"} Native -->|"iOS"| NSURLSession["NSURLSession"] Native -->|"Android"| OkHttp["OkHttp"] NSURLSession --> Server2["サーバー"] OkHttp --> Server2 end style Browser fill:#161b22,stroke:#58a6ff,color:#c9d1d9 style RN fill:#161b22,stroke:#7ee787,color:#c9d1d9 style CORS fill:#21262d,stroke:#f0883e,color:#f0883e style ERR fill:#21262d,stroke:#ff5f56,color:#ff5f56

この図が示す最大のポイントは2つです:

  1. ブラウザ: JavaScriptからブラウザエンジンのFetch APIが呼ばれ、そこにCORSチェックが組み込まれている
  2. React Native: JavaScriptからPolyfillを経由して、OSのネイティブHTTPクライアント(iOS: NSURLSession, Android: OkHttp)が直接HTTPリクエストを送る。CORSという概念自体が存在しない
Polyfillとは?
React Nativeのfetchは、内部的にXMLHttpRequestのPolyfillを使って実装されています。このXHRもまた、JavaScriptの標準XHRではなく、ネイティブ側に実装されたRCTNetworking(iOS)/NetworkingModule(Android)を呼び出すPolyfillです。つまり、fetch → XHR Polyfill → Native HTTP Client という2段階のラッピングが行われています。

違い1: CORSが存在しない

$ curl -v "https://api.example.com/data"

Web開発者にとって最大の驚きはこれでしょう。React NativeにはCORSがありません

なぜCORSがないのか

CORSを理解するには、なぜブラウザにCORSが必要なのかを知る必要があります。

sequenceDiagram participant User as ユーザー participant Browser as ブラウザ participant Evil as evil-site.com participant Bank as bank.example.com User->>Browser: evil-site.comにアクセス Browser->>Evil: GET / Evil-->>Browser: 悪意のあるJavaScriptを返す Note over Browser: JSが実行される Browser->>Bank: fetch('/api/transfer') + Cookieが自動送信 Note over Browser: CORSがなければ
ブラウザはこのリクエストを許可してしまう Bank-->>Browser: CORSヘッダーなし → ブラウザがブロック

ブラウザには「オリジン」という概念があります。evil-site.comのJavaScriptからbank.example.comにリクエストを送ることは「クロスオリジンリクエスト」であり、ブラウザはこれをデフォルトでブロックします。サーバーがAccess-Control-Allow-Originヘッダーで明示的に許可しない限り、レスポンスはJavaScriptから読めません。

ではReact Nativeではどうでしょうか?

ブラウザ

  • 複数のサイト(オリジン)のコードが同じ環境で動く
  • evil-site.comのJSがbank.comのCookieを使えたら危険
  • CORSは必須のセキュリティ機構

React Native

  • 1つのアプリのコードしか動かない
  • 悪意のある第三者のJSが混入する経路がない
  • CORSは不要(というか概念自体がない)

React Nativeアプリはcurlコマンドと同じ立場です。「どのオリジンから来たか」という概念がそもそもないので、サーバーがCORSヘッダーを返さなくても問題なくレスポンスを受け取れます。

React Nativeではこれが普通に動く
// ブラウザでは CORS エラーになるリクエストも、
// React Native では問題なく成功する
const res = await fetch('https://api.third-party.com/data');
const data = await res.json(); // 普通に読める
実務での注意点
CORSがないからといって、React Nativeのネットワーク通信が安全というわけではありません。アプリのバイナリは逆コンパイル可能であり、通信内容はプロキシツール(Charles, mitmproxy等)で傍受可能です。APIキーのハードコードは絶対に避けてください。サーバーサイドでの認証・認可は引き続き必須です。

違い2: Cookie管理がまったく違う

$ diff cookie-browser.js cookie-native.js

Webの世界では、サーバーがSet-Cookieヘッダーを返せば、ブラウザが自動的にCookieを保存し、次回のリクエストに自動で付与してくれます。認証セッションの維持はこの仕組みに完全に依存しています。

React Nativeでは、この「自動」がプラットフォームによって挙動が異なります

項目 ブラウザ React Native (iOS) React Native (Android)
Set-Cookieの自動保存 常に自動 NSHTTPCookieStorageが管理 OkHttpのCookieJarで管理(デフォルトでは非永続)
リクエストへの自動付与 同一オリジンなら常に自動 動作するが不安定な場合あり 動作するが設定が必要な場合あり
HttpOnly Cookie JSからアクセス不可(安全) ネイティブ層で管理されるためJSから見えない 同上
SameSite属性 ブラウザが強制 概念なし(オリジンがないため) 同上
アプリ再起動後の永続化 ブラウザが管理(期限まで保持) NSHTTPCookieStorageで永続化 デフォルトでは消える場合がある

credentials オプションの違い

ブラウザ
// ブラウザでは credentials の設定がCORS + Cookie挙動を決める
fetch('https://api.example.com/me', {
  credentials: 'include'     // クロスオリジンでもCookie送信
});
fetch('https://api.example.com/me', {
  credentials: 'same-origin'  // 同一オリジンのみCookie送信(デフォルト)
});
fetch('https://api.example.com/me', {
  credentials: 'omit'        // Cookie一切送信しない
});
React Native
// React Native では credentials の挙動がプラットフォーム依存
// 多くの場合、この設定は期待通りに動作しない
fetch('https://api.example.com/me', {
  credentials: 'include'
});
// → iOS: NSURLSessionの設定に依存
// → Android: OkHttpの設定に依存
// → 動いたり動かなかったりする
実務での推奨パターン
React Nativeでは、Cookie認証よりもトークン認証(Bearer Token)を使うのが圧倒的に安全で確実です。

Authorization: Bearer <token> ヘッダーを自前で付与し、トークンはexpo-secure-storereact-native-keychainで安全に保存します。Cookieのプラットフォーム差異に悩むことがなくなります。

違い3: タイムアウトとAbortController

$ timeout --compare

ブラウザのfetchにはタイムアウト機能がありません(AbortControllerで自前実装する必要があります)。React Nativeも同様ですが、裏側のネイティブHTTPクライアントにはOS固有のタイムアウト設定があります。

タイムアウト実装(共通パターン)
// ブラウザ・React Native 共通で動作するタイムアウト実装
async function fetchWithTimeout(url, options = {}, timeoutMs = 10000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal
    });
    return response;
  } finally {
    clearTimeout(timeoutId);
  }
}

このコードはブラウザ・React Native共通で動きます。ただし注意点があります:

項目 ブラウザ React Native
AbortController 標準サポート Polyfillで対応(React Native 0.60+)
abort時の挙動 即座にネットワーク接続を切断 ネイティブ側のキャンセルタイミングにラグがある場合がある
OS側のデフォルトタイムアウト ブラウザ依存(通常300秒程度) iOS: NSURLSession 60秒 / Android: OkHttp 10秒
iOSとAndroidでデフォルトタイムアウトが違う
これは地味に重要な差異です。何もタイムアウトを設定しない場合、iOSでは60秒待ちますが、Androidでは10秒でタイムアウトします。「iOSでは動くのにAndroidでタイムアウトする」というバグの原因はここにあることが多いです。必ず自前でタイムアウトを設定してください

違い4: ストリーミングとReadableStream

$ cat stream-support.txt

最近のWeb開発で頻出するReadableStream。ChatGPTのように文字が流れてくるUIを作るときに使います。

ブラウザ: ReadableStream でストリーミング読み取り
const response = await fetch('https://api.example.com/stream');
const reader = response.body.getReader(); // ReadableStream

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  // value はチャンクごとに届く
  console.log(new TextDecoder().decode(value));
}

React Nativeでは、このresponse.body(ReadableStream)の対応状況がブラウザとは大きく異なります。

機能 ブラウザ React Native
response.body (ReadableStream) 標準サポート React Native 0.71+ で部分対応
Hermes + New Architecture が推奨
TextDecoderStream 標準サポート 非対応(Polyfill必要)
Server-Sent Events (SSE) EventSource API で対応 EventSource非対応、ライブラリ必要
React Native でストリーミングする代替手段
// 方法1: react-native-sse ライブラリを使う
import EventSource from 'react-native-sse';

const es = new EventSource('https://api.example.com/stream');
es.addEventListener('message', (event) => {
  console.log(event.data);
});

// 方法2: XMLHttpRequest の onprogress を使う(低レベル)
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/stream');
xhr.onprogress = () => {
  // xhr.responseText に都度追加される
  console.log(xhr.responseText);
};
xhr.send();

違い5: FormDataとファイルアップロード

$ diff upload-browser.js upload-native.js

ファイルアップロードの実装は、ブラウザとReact Nativeで書き方が大きく異なります。

ブラウザ

// File APIを使う
const input = document
  .querySelector('input[type=file]');
const file = input.files[0];

const form = new FormData();
form.append('avatar', file);

fetch('/upload', {
  method: 'POST',
  body: form
});

React Native

// URIベースのオブジェクトを使う
const form = new FormData();
form.append('avatar', {
  uri: 'file:///path/to/photo.jpg',
  type: 'image/jpeg',
  name: 'photo.jpg',
});

fetch('/upload', {
  method: 'POST',
  body: form
});

ブラウザではFileオブジェクト(<input type="file">から取得)をそのままFormDataに追加します。React NativeにはFile APIがないので、代わりにuri, type, nameを持つオブジェクトを渡します。

React NativeのFormData実装はこのオブジェクトを認識して、uriで指定されたファイルをネイティブ層で読み取り、multipart/form-dataとして送信してくれます。

よくあるハマりポイント

違い6: キャッシュの制御

$ cat cache-behavior.conf

ブラウザのfetchにはcacheオプションがあり、HTTPキャッシュの挙動を細かく制御できます。

ブラウザの cache オプション
fetch(url, { cache: 'no-store' });   // キャッシュを一切使わない
fetch(url, { cache: 'no-cache' });   // 毎回サーバーに再検証
fetch(url, { cache: 'force-cache' }); // 期限切れでもキャッシュ優先
fetch(url, { cache: 'default' });     // 標準的なHTTPキャッシュ動作

React Nativeでは、cacheオプションの対応状況がプラットフォームによって異なります。

cache 値 ブラウザ React Native (iOS) React Native (Android)
default HTTPキャッシュ準拠 NSURLRequestのcachePolicyに変換 OkHttpのキャッシュに依存
no-store 対応 ReloadIgnoringLocalCacheDataに変換 Cache-Control ヘッダーで代替
no-cache 対応 部分対応 非対応の場合あり
force-cache 対応 ReturnCacheDataElseLoadに変換 非対応の場合あり
安全な回避策
キャッシュを確実に無効化したい場合は、cacheオプションに頼らずHTTPヘッダーで明示するのが確実です。

fetch(url, { headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' } })

違い7: HTTPS/TLSと証明書ピニング

$ openssl s_client -connect api.example.com:443

ブラウザではHTTPSの証明書検証はブラウザエンジンが透過的に行います。開発者が意識することはほとんどありません。

React Nativeでは、ネイティブのHTTPクライアントがTLS検証を担当するため、いくつかの違いが生じます。

flowchart LR subgraph Browser["ブラウザのTLS"] B_Req["fetch()"] --> B_TLS["ブラウザが
TLSハンドシェイク"] B_TLS --> B_CA["OS + ブラウザの
CA証明書ストア"] B_CA --> B_OK["接続確立"] end subgraph RN["React NativeのTLS"] RN_Req["fetch()"] --> RN_Bridge["Native Bridge"] RN_Bridge --> RN_TLS["OS HTTPクライアントが
TLSハンドシェイク"] RN_TLS --> RN_CA["OSのCA証明書ストア"] RN_CA --> RN_PIN{"証明書ピニング
(設定時)"} RN_PIN -->|"一致"| RN_OK["接続確立"] RN_PIN -->|"不一致"| RN_NG["接続拒否"] end style Browser fill:#161b22,stroke:#58a6ff,color:#c9d1d9 style RN fill:#161b22,stroke:#7ee787,color:#c9d1d9 style RN_PIN fill:#21262d,stroke:#f0883e,color:#f0883e

証明書ピニング(Certificate Pinning)は、React Nativeアプリで使える強力なセキュリティ機能です。サーバー証明書のフィンガープリントをアプリに埋め込み、中間者攻撃(MITM)を防ぎます。ブラウザのfetchにはこの機能はありません。

開発時のHTTP通信
React Nativeの開発時、ローカルサーバー(http://localhost:3000等)にHTTPでアクセスしたい場面があります。iOSはデフォルトでHTTPを拒否するため(App Transport Security)、Info.plistで例外を追加する必要があります。AndroidではAndroidManifest.xmlandroid:usesCleartextTraffic="true"を追加します。ブラウザにはこのような制約がありません。

違い8: ネットワーク状態の検知

$ ifconfig -a

ブラウザとReact Nativeでは、ネットワーク状態にアクセスできる情報量が大きく異なります。

ブラウザ

  • navigator.onLine で接続有無のみ
  • navigator.connection で接続タイプ(限定的)
  • Wi-Fiかモバイルかの判別が不正確
  • プライバシー保護のため意図的に制限

React Native

  • 接続タイプ(Wi-Fi / Cellular / None)
  • セルラーの世代(3G / 4G / 5G)
  • 接続品質の詳細
  • VPN接続の検知
  • @react-native-community/netinfo
React Native: ネットワーク状態に応じたfetch戦略
import NetInfo from '@react-native-community/netinfo';

async function smartFetch(url, options) {
  const state = await NetInfo.fetch();

  if (!state.isConnected) {
    // オフライン → キャッシュから返す or エラーUI
    throw new Error('No network connection');
  }

  if (state.type === 'cellular' && state.details?.cellularGeneration === '3g') {
    // 3G回線 → 画像の読み込みを低画質に切り替え
    url = url.replace('/full/', '/thumb/');
  }

  return fetch(url, options);
}

これはネイティブアプリならではの最適化です。ブラウザでは接続品質の正確な判定が難しいため、この種の実装は一般的ではありません。

まとめ: 全体比較表

$ diff --side-by-side browser-fetch.spec native-fetch.spec
観点 ブラウザ fetch React Native fetch
実装 ブラウザエンジンのネイティブ実装 Polyfill → ネイティブHTTPクライアント
CORS ブラウザが強制 存在しない
Cookie 自動管理、SameSite対応 プラットフォーム依存、不安定
認証推奨 Cookie or Bearer Token Bearer Token 一択
ストリーミング ReadableStream 完全対応 部分対応、ライブラリ推奨
ファイルアップロード File API + FormData URI + {uri, type, name} オブジェクト
キャッシュ制御 cache オプション完全対応 iOS部分対応、Androidほぼ非対応
TLS/証明書 ブラウザが管理 OS管理 + 証明書ピニング可能
タイムアウト AbortControllerで自前実装 同左 + OS側デフォルト値あり
ネットワーク状態 限定的(navigator.onLine) 詳細(接続タイプ、世代、品質)
Service Worker リクエストのインターセプト可能 存在しない
HTTP/2,3 ブラウザが透過的に対応 OS HTTPクライアントの対応次第
mindmap root(("fetch()の違い")) セキュリティモデル CORS: ブラウザのみ 証明書ピニング: Nativeのみ ATS: iOSのHTTP制限 認証 Cookie: ブラウザ向き Bearer Token: Native推奨 Secure Store: Native固有 データ転送 ReadableStream: ブラウザ強い FormData: 書き方が異なる キャッシュ: OS依存 環境情報 ネットワーク品質: Native詳細 オフライン検知: Native有利 Service Worker: ブラウザのみ

実務でのベストプラクティス

$ cat best-practices.md

ここまでの違いを踏まえて、WebとReact Nativeの両方で動くコードを書く際のベストプラクティスをまとめます。

1. API クライアントを抽象化する

api-client.ts
// Platform-agnostic な API クライアント
type ApiClientConfig = {
  baseUrl: string;
  getToken: () => Promise<string | null>;
  timeoutMs?: number;
};

export function createApiClient(config: ApiClientConfig) {
  const { baseUrl, getToken, timeoutMs = 15000 } = config;

  return {
    async request<T>(path: string, options: RequestInit = {}): Promise<T> {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
      const token = await getToken();

      try {
        const res = await fetch(`${baseUrl}${path}`, {
          ...options,
          signal: controller.signal,
          headers: {
            'Content-Type': 'application/json',
            ...(token ? { 'Authorization': `Bearer ${token}` } : {}),
            ...options.headers,
          },
        });

        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json() as Promise<T>;
      } finally {
        clearTimeout(timeoutId);
      }
    },
  };
}
Web側の使い方
const api = createApiClient({
  baseUrl: '/api',  // 相対パス(同一オリジン)
  getToken: async () => localStorage.getItem('token'),
});
React Native側の使い方
import * as SecureStore from 'expo-secure-store';

const api = createApiClient({
  baseUrl: 'https://api.example.com', // 絶対URL(CORSなし)
  getToken: async () => SecureStore.getItemAsync('token'),
});

2. ファイルアップロードを統一する

upload.ts
import { Platform } from 'react-native';

type FileInput =
  | File                                          // Web
  | { uri: string; type: string; name: string }; // Native

export function createUploadForm(fieldName: string, file: FileInput) {
  const form = new FormData();
  form.append(fieldName, file as any);
  return form;
}

3. ネットワーク状態を考慮する

React Nativeでは@react-native-community/netinfoを使い、ネットワーク状態に応じた最適化を入れましょう。Webではnavigator.onLinewindow.addEventListener('online'/'offline')で基本的な接続監視を行います。

4. 忘れがちなチェックリスト

React Native fetch 移行時チェックリスト

おわりに

$ echo "同じAPIでも、動く場所が違えば世界が変わる"

React Nativeのfetchはブラウザのfetchと同じAPIを持っていますが、中身はまったくの別物です。これは意図的な設計で、Web開発者の学習コストを下げつつ、ネイティブの機能を活用できるようにするためのものです。

ただし、その「同じに見える」ことが逆に罠になることがあります。CORSがなくてラッキーと思う一方で、Cookieが勝手に消えたり、ストリーミングが動かなかったり。これらはすべて、裏側のアーキテクチャが違うことに起因しています。

大事なのは「ブラウザの常識をリセットする」ことです。fetchのAPIは同じでも、セキュリティモデル、認証方式、キャッシュ戦略、すべてを一度ゼロベースで考え直す。その上でコードを書けば、WebでもNativeでも安定したネットワーク通信が実現できます。

$ _