The response that comes back cleanly in the iOS simulator collapses into TypeError: Network request failed the moment you switch to the Android emulator. If you build with Rork for a while — wiring up an internal API, hitting a Node dev server you just spun up — you will meet this error at least once.
I have been shipping mobile apps as a solo developer since 2014, and across a portfolio of wallpaper, healing, and law-of-attraction apps that has accumulated around fifty million downloads, I have run into this exact message more times than I can count. The cause is different each time, but the message is identical. That is why having a fixed diagnosis order saves real hours later on. What follows is the order I actually walk through when only Android falls over.
Restating what the error really means
Network request failed is the coarse-grained error React Native throws when something dies at the network layer. In practice it boils down to one of these four:
- The host is unreachable (DNS / IP / port).
- The host is reachable, but TLS never completes (cert chain, plain HTTP being blocked).
- The request comes back, but Android's cleartext policy rejects it.
- A proxy, VPN, corporate firewall, or Charles in the middle eats the connection.
The reason this trips you up more than on iOS is that the Android emulator runs as its own Linux device, separate from your Mac. Forget that for a minute and you can lose half an hour to symptom-level fixes.
First check: is the host even reachable?
Before touching code, open Chrome inside the Android emulator and paste the fetch target URL into the address bar.
- If it opens, the problem is on the app side (headers, body, timeouts, module load order).
- If it does not open, the problem is reachability (the next section).
This one step cuts investigation time roughly in half. Whenever I help someone outside my team, I ask them to do this before we look at code.
Cause 1: Hitting localhost directly
For the Android emulator, localhost and 127.0.0.1 point at the emulator itself, not at your Mac. Your Node, Rails, FastAPI, or Supabase dev server is invisible from there. This is the most common pitfall when starting local development in Rork.
To reach the host machine from the Android emulator, use one of these.
// utils/apiBase.ts
import { Platform } from 'react-native';
const LOCAL_PORT = 8787;
export const getApiBase = () => {
// Production
if (!__DEV__) return 'https://api.example.com';
// Development: pick per-platform
if (Platform.OS === 'android') {
// Special IP that the Android emulator routes to the host's localhost
return `http://10.0.2.2:${LOCAL_PORT}`;
}
// iOS simulator shares the Mac's network namespace, so this works as-is
return `http://localhost:${LOCAL_PORT}`;
};On a physical Android device, 10.0.2.2 does not work either. Either expose the Mac's LAN IP (for example 192.168.1.21) via an env variable and connect over Wi-Fi, or use adb reverse to forward over USB.
# With the Android device plugged in via USB,
# make the host's port look like a local port on the device.
adb reverse tcp:8787 tcp:8787adb reverse drops when you unplug, so it is safest to keep this for local debugging instead of baking it into a CI flow.
Cause 2: Cleartext HTTP is blocked on Android
Since Android 9, plain HTTP is blocked by default. Even a development URL like http://10.0.2.2:8787 falls into this bucket and gets reported as Network request failed.
In Rork (Expo), the quickest fix during development is to enable cleartext in app.json.
{
"expo": {
"android": {
"usesCleartextTraffic": true
}
}
}I would caution against shipping this flag in production builds. With AdMob, Firebase, and your own API in the same binary, you widen the attack surface a lot. My personal setup splits dev and prod configs (app.dev.json vs app.json) so cleartext stays scoped to development.
If you want fine-grained control, write a network security config and whitelist only the hosts you trust.
<!-- 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>"Just flip usesCleartextTraffic: true and forget to remove it before release" is a very real footgun. Once it leaves the dev branch, double-check the App Bundle.
Cause 3: Self-signed or expired certificates
This one tends to bite when you hit an internal API or a local HTTPS endpoint created with mkcert. The iOS simulator sometimes inherits trust from the Mac keychain, which masks the problem until you migrate http→https.
The Android emulator has its own trust store, so a self-signed certificate is rejected. Short-term, you have two pragmatic options:
- Fall back to plain HTTP during development with
usesCleartextTraffic. - Push the CA into Android (use
adb pushplusnetwork_security_config.xmlto trust a user CA).
Long-term, putting a Cloudflare Tunnel or ngrok in front of your dev server so Android can pull a real certificate makes the eventual switch to a production API painless.
Cause 4: A proxy, VPN, or Charles eats the request
Firing fetch() while Charles Proxy is running without the CA installed gives you an instant Network request failed on Android. This is by far the easiest cause to overlook. Every time I screen-share through a session like this, the "oh, I left Charles running" moment shows up.
A clean order to verify:
- Check System Settings → Wi-Fi → Proxies.
- Quit Charles or Proxyman and retry.
- Disconnect the corporate VPN and retry.
- If it still fails, open the URL in Chrome (see above).
While I was tuning AdMob frequency caps through Crashlytics + Remote Config, my own Charles instance was dropping the AdMob requests and I convinced myself ads were broken "only in the evening." It cost me half a day before I noticed the middleman. Now ruling out the proxy is the first thing I do.
Two app-side bugs that explain Android-only failures
When reachability is fine, look at the code. Two patterns commonly fail on Android while iOS happily forgives them.
Setting Content-Type yourself on FormData
// ❌ The boundary string is missing — Android rejects, iOS often forgives
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'multipart/form-data' }, // ← do not set this
body: formData,
});
// ✅ Let React Native compute the boundary
await fetch(url, {
method: 'POST',
body: formData,
});The multipart/form-data boundary is filled in by the runtime. iOS tends to tolerate the missing piece, while Android cuts the connection the moment it is absent.
fetch() without a timeout
If the Android emulator is swapping memory or the Wi-Fi flickers, fetch() can wait forever. Capping the request with an AbortController at 10–15 seconds improves both UX and your ability to debug.
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);
}Where to go from here
Start with the thirty-second check: can the Android emulator's Chrome reach the URL? From there, walk localhost → cleartext → certificate → proxy in order, and you will land on the cause for almost every case.
I am still learning along the way, but I hope this saves you some of the hours I have already spent. Thank you for reading.