iOS シミュレータでは普通に取れているレスポンスが、Android エミュレータに切り替えた瞬間に TypeError: Network request failed で落ちる。Rork でアプリを書いていると、最初の社内 API 連携や、ローカルで立てた Node の開発サーバーに fetch() した直後に、必ず一度は通る道です。
私自身、2014年から個人でアプリを開発してきて、累計5,000万ダウンロード規模の壁紙・癒し・引き寄せ系アプリを運営する中で、何度も同じエラーに当たっています。原因は毎回違うのに、表示されるメッセージは同じです。だからこそ、最初に切り分け順を決めておくと、後で詰まったときに迷いません。同じ落とし穴に当たっている方の時間を少しでも縮められたらと思い、私が普段踏んでいる切り分けの順番を、現場の感覚そのままで残しておきます。
何が起きているのかを正しく言い直す
Network request failed は、ネットワーク層で何かが切れたときに React Native が投げる、かなり粒度の粗いエラーです。中身を読むと、だいたい次のどれかに当たります。
- リクエスト先のホストにそもそも到達できていない(DNS / IP / ポート)
- ホストには届いているが、TLS が成立していない(証明書 / 平文 HTTP の遮断)
- リクエストは届いて返ってきたが、Android の cleartext ポリシーで弾かれた
- プロキシ・VPN・社内ファイアウォール・Charles 等の中継で落ちた
iOS シミュレータと違ってつまずきやすいのは、Android エミュレータが「自分自身を Mac とは別の Linux 端末として動いている」という点です。ここを忘れると、対症療法を 30 分繰り返してしまいます。
まずやるべき切り分け:到達できているかどうか
エラーが起きた fetch のターゲットを、Android エミュレータ内のブラウザでまず開きます。Chrome を起動して URL を直接入力するだけです。
- 開ければ「アプリ側の問題」(fetch のヘッダ・body・タイムアウト・モジュールの読み込み順)
- 開けなければ「ネットワーク到達性の問題」(次節の localhost 周り)
この一手間で、調査時間がだいたい半分になります。私はチーム外の方のサポートに入ったときも、最初にこの確認を一緒にやることをお願いしています。
原因1:localhost を直接叩いている
Android エミュレータから見た localhost や 127.0.0.1 は「エミュレータ自身」を指します。Mac で動いている開発サーバー(Node, Rails, FastAPI, Supabase ローカル等)には届きません。これが、Rork でローカル開発するときの定番の落とし穴です。
Android Emulator から Mac 側のホストに到達するには、以下のいずれかを使います。
// utils/apiBase.ts
import { Platform } from 'react-native';
const LOCAL_PORT = 8787;
export const getApiBase = () => {
// 本番
if (!__DEV__) return 'https://api.example.com';
// 開発: プラットフォーム別に切り替える
if (Platform.OS === 'android') {
// Android Emulator が Mac の localhost を指す特別な IP
return `http://10.0.2.2:${LOCAL_PORT}`;
}
// iOS シミュレータは Mac とネットワーク空間を共有するのでそのままでよい
return `http://localhost:${LOCAL_PORT}`;
};実機の Android で動かす場合は 10.0.2.2 も効きません。Mac の LAN 内 IP(例:192.168.1.21)を環境変数から渡して、Wi-Fi 経由で接続するか、adb reverse tcp:8787 tcp:8787 で USB ポートフォワードを張ります。
# 実機 Android を USB 接続したまま、Mac 側のポートをエミュレータ的に見せる
adb reverse tcp:8787 tcp:8787adb reverse は USB を抜くと切れるので、CI で常用するのは避けて、ローカル検証専用に留めるのがお勧めです。
原因2:HTTP(平文)通信が Android で遮断されている
Android 9 以降、デフォルトで平文 HTTP は遮断されます。http://10.0.2.2:8787 のような開発用 URL でも例外ではなく、Network request failed で落ちます。
Rork(Expo)の場合は、app.json で開発時のみ cleartext を許可するのが楽です。
{
"expo": {
"android": {
"usesCleartextTraffic": true
}
}
}ただし、これを本番ビルドにそのまま入れて出すのは避けるべきです。AdMob・Firebase・自社 API が混在している環境だと、攻撃面が広がります。私のアプリでは、開発用の app config (app.dev.json) と本番用 (app.json) を分けて、cleartext は開発限定にしています。
より制御を効かせたい場合は、network security config を書き、許可するホストだけ列挙します。
<!-- res/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">192.168.1.21</domain>
</domain-config>
</network-security-config>「とりあえず usesCleartextTraffic: true にする → リリース時に外し忘れる」は実際に踏みやすい事故なので、開発用フラグから外したら App Bundle で必ず確認します。
原因3:自己署名 / 期限切れの証明書
社内 API や、mkcert で立てた開発用 HTTPS を叩いている場合に多い症状です。iOS シミュレータでは Mac のキーチェーンを信頼してくれることがあり、http→https で落ち始めて気づきます。
Android エミュレータのトラストストアは Mac とは独立しているため、自己署名は基本的に信頼されません。短期的には次のどちらかになります。
- 開発期間中は
usesCleartextTrafficで HTTP にフォールバック - Android 側に CA を読ませる(
adb push+network_security_config.xmlでユーザー CA を許可)
長期的には、Cloudflare Tunnel や ngrok のような正規証明書を吐く中継を一枚噛ませて、Android からも素直に HTTPS で取れる状態にしておくと、後から本番 API に差し替えるときに揉めません。
原因4:プロキシ・VPN・Charles の中継で落ちている
Charles Proxy を起動したまま、CA をインストールしていない状態で fetch() すると、Android では即 Network request failed です。原因として一番見落としやすいのがこれで、画面共有しながら一緒に調べると「Charles 立てっぱなしでした…」というケースに毎回出会います。
確認の順番は次の通りです。
- システム環境設定の Wi-Fi → プロキシ設定を確認
- Charles / Proxyman を一度終了して再試行
- 会社 VPN を切って再試行
- それでも落ちるなら Chrome で直接開く(前述)
私は Crashlytics + Remote Config で AdMob の頻度を絞る検証中、自分の Charles 経由で AdMob のリクエストが落ちて「夜だけ広告が出ない」と勘違いした経験があります。原因がアプリ内ではなく中継だと気づくまでに半日溶かしたので、ここはまず外して試すことを徹底しています。
アプリ側で見るべき2つの実装ミス
到達性が問題ない場合は、最後にコード側を見ます。Android で落ちて iOS で通る、というケースで頻出するのが次の2つです。
Content-Type を抜いて FormData を送っている
// ❌ Android だと境界文字列が付かず Network request failed になる
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'multipart/form-data' }, // ← 自分で指定してはいけない
body: formData,
});
// ✅ Content-Type は React Native に任せる
await fetch(url, {
method: 'POST',
body: formData,
});multipart/form-data の boundary はランタイムが自動付与します。iOS は寛容に処理してくれることがあるのに対して、Android は boundary が欠けた瞬間に切ります。
タイムアウトを設けていない fetch
Android エミュレータが裏でディスクスワップしている、Wi-Fi が一瞬切れた、といった状況で、fetch() は無限に待ち続けることがあります。AbortController で 10〜15 秒を上限に切るだけで、ユーザー体験も調査もぐっと楽になります。
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 12_000);
try {
const res = await fetch(url, { signal: ctrl.signal });
return await res.json();
} catch (e: any) {
if (e.name === 'AbortError') throw new Error('timeout');
throw e;
} finally {
clearTimeout(timer);
}次のアクション
エラーが出たときの順番として、まずは「Android エミュレータの Chrome で開けるか」を 30 秒で確認するところから始めてください。そこから、localhost / cleartext / 証明書 / プロキシ の4つを順番に潰していけば、ほとんどのケースで原因まで辿り着けます。
私自身まだ学びの途中ですが、同じ場面に当たっている方の参考になれば嬉しいです。お読みいただき、ありがとうございました。