skip to content

Why does a React Native app's fetch to a plain http:// URL fail in a release build, and how should you allow it safely?

level: middleimportance: must knowfreq 62%

answer

  1. the OS refuses it, not fetch
  2. iOS: App Transport Security by default
  3. Android: cleartext blocked from API 28
  4. exception for one domain, never global
  5. debug builds allow local Metro traffic

basics

~10 s

Both platforms block cleartext HTTP by default: iOS through App Transport Security, Android from API level 28. The fix is HTTPS; failing that, a narrow per-domain exception, never NSAllowsArbitraryLoads or app-wide cleartext.

solid answer

~40 s

React Native's `fetch` runs on the platform networking stack, `NSURLSession` on iOS and OkHttp on Android, so the operating system's transport policy applies. **iOS App Transport Security** requires HTTPS by default, and **Android blocks cleartext traffic by default from API level 28**; the request fails with a generic network error and no HTTP status. It works in development because the React Native template sets `NSAllowsLocalNetworking` on iOS, and since 0.82 the React Native Gradle plugin sets the `usesCleartextTraffic` manifest placeholder to `true` for debug builds and `false` for release. The right fix is to serve the endpoint over HTTPS. If you truly cannot, add an exception for that one domain (`NSExceptionDomains` on iOS, a `domain-config` with `cleartextTrafficPermitted` on Android) rather than `NSAllowsArbitraryLoads` or `android:usesCleartextTraffic="true"` for the whole app.

code

xml · 8 lines
xml
<?xml version="1.0" encoding="utf-8"?>
<!-- android/app/src/main/res/xml/network_security_config.xml -->
<network-security-config>
  <base-config cleartextTrafficPermitted="false" />
  <domain-config cleartextTrafficPermitted="true">
    <domain includeSubdomains="false">status.partner.example</domain>
  </domain-config>
</network-security-config>

go deeper

for a junior

Remember that both iOS and Android block plain http by default and that the fix is HTTPS, not a global switch.

for a middle

Explain ATS keys and Android's cleartext switches, why debug builds behave differently, and how to write a single-domain exception.

for a senior

Audit a release build's Info.plist and network security config for broad exceptions, and keep them from creeping in through generated native files.

for a principal

Set the policy that no user or payment data may ever travel over a cleartext exception, and decide who approves any remaining legacy host.

## The symptom A payments app calls a partner's status endpoint at `http://status.partner.example`. In development it works; in the release build the call fails with a generic network error and no HTTP status, on both platforms. Nothing is wrong with the JavaScript: the **operating system** refused the connection. React Native does not implement HTTP itself. `fetch` is a polyfill over React Native's networking module, which uses **`NSURLSession`** on iOS and **OkHttp** on Android. Both sit under the platform's transport-security policy. ## iOS: App Transport Security **App Transport Security (ATS)** is on by default and requires HTTPS for connections made through `NSURLSession`. It is configured in `Info.plist` under the `NSAppTransportSecurity` dictionary: | Key | Effect | Use it for | |---|---|---| | `NSAllowsArbitraryLoads` | turns ATS off broadly | almost never; the React Native docs warn App Store review expects a justification | | `NSExceptionDomains` | per-domain exceptions, such as `NSExceptionAllowsInsecureHTTPLoads` for one host | a single legacy host you cannot move to HTTPS | | `NSAllowsLocalNetworking` | relaxes ATS for resources on the local network | development against Metro and local servers | The React Native iOS template ships with `NSAllowsArbitraryLoads` set to `false`, with a comment warning that changing it risks rejection, and `NSAllowsLocalNetworking` set to `true`, which is why Metro on your machine works. ## Android: cleartext traffic From **API level 28**, Android blocks cleartext (non-TLS) traffic by default. Two switches control it: - `android:usesCleartextTraffic` on the `<application>` element: an app-wide yes or no. - A **network security config** file (`res/xml/network_security_config.xml`, referenced by `android:networkSecurityConfig`), whose `cleartextTrafficPermitted` attribute can be set per domain in a `domain-config`. Since **React Native 0.82**, the template uses one manifest with `android:usesCleartextTraffic="${usesCleartextTraffic}"`, and the React Native Gradle plugin fills the placeholder: `true` for `debug` and `debugOptimized`, `false` for `release`. Before 0.82 a separate debug manifest set it to `true`. Either way, cleartext works in debug so the app can reach Metro, and is off in release. ## Fixing it properly 1. **Move the endpoint to HTTPS.** For a payments app this is the only acceptable answer for anything carrying user or payment data. 2. **If a third party cannot**, scope the exception to that host: an `NSExceptionDomains` entry on iOS, a `domain-config` with `cleartextTrafficPermitted="true"` for that domain on Android. 3. **Never** ship `NSAllowsArbitraryLoads: true` or `android:usesCleartextTraffic="true"` to make one call work. 4. In an Expo project, set these through app config and plugins instead of editing the generated native files, which prebuild would overwrite; `expo-build-properties`, for example, has an `android.usesCleartextTraffic` option. ## Checking a release build before it ships Debug builds hide this class of bug, so check the release configuration directly: 1. Build and run a **release** variant on a device and exercise every endpoint, including third-party ones called from JavaScript. 2. Inspect the built app's `Info.plist` for `NSAllowsArbitraryLoads` and for any `NSExceptionDomains` entry nobody can explain. 3. Inspect the merged Android manifest for the resolved `usesCleartextTraffic` value and for the network security config it references. 4. In Expo projects, run prebuild and review the generated native files, because that is what the build actually uses. ## Why the defaults are right Cleartext HTTP can be read and modified by anyone on the network path: a hotel Wi-Fi, a compromised router. A payments app that accepts one cleartext response can be fed a forged status or a redirect. The platform defaults exist so that every app gets this protection without opting in, and every exception you add is a place where it is switched off. ## Common mistakes - Testing only debug builds, where cleartext and local networking are allowed, and discovering the failure in production. - Adding an app-wide flag instead of a domain exception. - Blaming `fetch` or CORS: React Native's `fetch` does not enforce CORS; the block comes from the OS policy.

  • Why does the same http call work against Metro in a debug build?
    The React Native iOS template sets `NSAllowsLocalNetworking` to `true`, which lets local addresses through ATS, and on Android the Gradle plugin sets `usesCleartextTraffic` to `true` for debug builds (since 0.82 through a manifest placeholder). Release builds get `false`, so the same call fails there.
  • Is `NSAllowsArbitraryLoads` ever acceptable?
    Rarely, and it must be justified: the React Native docs point out App Store review expects a reason for disabling ATS, and the template warns against flipping it. For a known host, an `NSExceptionDomains` entry does the job without turning the protection off for every other connection.

saying these in an interview costs you the question

  • The http failure is a CORS error from React Native's fetch.
  • Setting NSAllowsArbitraryLoads is the standard fix for one host.
  • Android allows cleartext by default in release builds.
  • If it works in debug it will work in release.
  • A network security config cannot scope cleartext to one domain.