In a browser, new URL('../x', 'https://a.example/b/c/d') is https://a.example/b/x. In React Native 0.87.1’s built-in URL class it is https://a.example/b/c/d/../x. A client library shared between a website and a React Native app can pass every test on the web side and send a request to the wrong path on the phone.
URL is one of three places where shared code meets a different platform. The other two are response.body, which is undefined unless Expo replaces fetch, and TextDecoder, which React Native’s setup files do not define. This is a short audit of what React Native and Expo actually provide, written from their sources because the documentation says little about it, plus a small client that checks for what it needs and avoids what it cannot trust.
TL;DR
- A shared client sees up to three layers: the JavaScript engine, React Native’s own polyfills, and, in an Expo app, Expo’s “winter” runtime on top. The Web API you get depends on which layers are present.
- React Native 0.87.1’s built-in
URLis a small regex-based class. Run next to Node’s WHATWGURL, it returnedhttps://a.example/b/c/d/../xfornew URL('../x', 'https://a.example/b/c/d'), where the web returnshttps://a.example/b/x. It also left//c.example/xunresolved, kept host case and the explicit:443, and did not percent-encode a space. In a project that loads Expo’s runtime, the globalURLis replaced by awhatwg-url-minimumimplementation, and those cases behave like the web. - In the React Native 0.87.1 source,
fetchcomes fromwhatwg-fetch3.6.20, and I found nothing in it that provides a responsebodystream. Expo’s replacementfetchbuilds one. So streaming code needs a fallback or a stated requirement. AbortSignal.timeoutandAbortSignal.anyexist in 0.87.1. That is a point to check, not remember, because older advice says otherwise.- The practical rule: run a capability check once at startup that lists what is missing; build URLs with string joining you control; and test the shared code against the runtime combinations you ship.
- I did not run Hermes or a device. The “runtimes” in the tests are simulated.
What the docs say, and what they leave out
The React Native networking page says React Native provides the Fetch API, the XMLHttpRequest API and WebSocket support, and notes that “there is no concept of CORS in native apps”. It does not say which other web globals exist, or how closely they follow the standards. That information is in the source, so I installed react-native 0.87.1 and expo 57.0.26 and read how the globals get installed.
React Native’s setUpXHR.js defines these lazily as globals: XMLHttpRequest, FormData, fetch with Headers, Request and Response, WebSocket, Blob, File, FileReader, URL and URLSearchParams, and AbortController and AbortSignal. It does not define TextDecoder.
Expo’s runtime file (src/winter/runtime.native.ts) then installs more: TextDecoder (a UTF-8 fallback, with a comment saying it is for runtimes that do not provide one), TextDecoderStream, TextEncoderStream, URL and URLSearchParams from the whatwg-url-minimum package, DOMException, structuredClone, patches for FormData and AbortSignal, and a replacement fetch unless EXPO_PUBLIC_USE_RN_FETCH is set. ReadableStream is described there as injected by Metro.
| Global | React Native 0.87.1 | Added or replaced by Expo’s runtime |
|---|---|---|
fetch | whatwg-fetch 3.6.20; I found no response body stream in it | Expo’s fetch, with a stream (unless EXPO_PUBLIC_USE_RN_FETCH) |
URL, URLSearchParams | RN’s own Libraries/Blob/URL.js | whatwg-url-minimum |
AbortController, AbortSignal | RN’s own, with abort, timeout, any | patch adds timeout/any if missing |
TextDecoder | not defined by setUpXHR.js (I did not check whether the engine adds one) | UTF-8 fallback |
ReadableStream | not covered here | injected by Metro (per a comment) |
An Expo app that does not load that runtime, or a bare React Native app, sees only the middle column. A shared library cannot know which one it is in, which is why it should ask.
The URL differences, measured
React Native’s URL.js is plain JavaScript with Flow types. I stripped the types with flow-remove-types and ran it in Node.js 22.23.3 next to Node’s own URL and to whatwg-url-minimum. This is the code from React Native’s package, running in Node, not in Hermes. Where Hermes might differ is outside my test.
| Case | Node URL | React Native 0.87.1 URL.js | whatwg-url-minimum |
|---|---|---|---|
new URL('../x', 'https://a.example/b/c/d') | https://a.example/b/x | https://a.example/b/c/d/../x | https://a.example/b/x |
new URL('//c.example/x', 'https://a.example') | https://c.example/x | //c.example/x | https://c.example/x |
HTTPS://A.EXAMPLE/x as href | lowercased | unchanged | https://a.example/x |
port of https://a.example:443/x | "" | "443" | "" |
pathname of https://a.example/a b | /a%20b | /a b | /a%20b |
u.pathname = '/y' | works | no effect | works |
URL.canParse | function | undefined | function |
searchParams.append, then href | ?k=v | ?k=v | not tested |
search setter | works | works | not tested |
I ran the Node and React Native columns for all rows; the whatwg-url-minimum column for all but the last two. The pathname assignment was silently ignored in my script, which ran in sloppy mode; in strict-mode code I would expect a TypeError, but I did not test that.
The URL specification defines these behaviors, including resolving dot segments against a base. Code written against the web will assume them.
What I would put in a shared client
Three decisions follow.
Say what you need, and check it once.
export const REQUIRED = ["fetch", "AbortController", "TextDecoder"];
export function missingCapabilities(g = globalThis) {
return REQUIRED.filter((name) => typeof g[name] !== "function");
}
export function assertRuntime(g = globalThis) {
const missing = missingCapabilities(g);
if (missing.length) throw new Error(`shared client needs globals this runtime lacks: ${missing.join(", ")}`);
}
One error at startup naming everything missing is easier to act on than three different failures later.
Build URLs with strings. The client only joins a configured base with a path and a query. It refuses paths that need resolution, instead of resolving them differently on each runtime.
const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
export function joinUrl(base, path, query) {
if (/(^|\/)\.\.?(\/|$)/.test(path) || /^\/\//.test(path)) throw new Error(`refusing a path that needs URL resolution: ${path}`);
const qs = query ? Object.entries(query).filter(([, v]) => v !== undefined)
.map(([k, v]) => `${enc(k)}=${enc(String(v))}`).join("&") : "";
return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}${qs ? "?" + qs : ""}`;
}
Do not depend on AbortSignal.timeout or .any. A timer and an AbortController do the job in every profile I tested, including one without AbortSignal.timeout.
export function timeoutSignal(ms, outer) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(new Error(`timeout after ${ms}ms`)), ms);
const onOuter = () => ctl.abort(outer.reason);
if (outer) outer.aborted ? onOuter() : outer.addEventListener("abort", onOuter, { once: true });
return { signal: ctl.signal, cancel() { clearTimeout(timer); outer?.removeEventListener("abort", onOuter); } };
}
Stream when you can; say so when you cannot. For line-delimited responses the reader uses the stream if the response has one, and otherwise reads the whole body. The second path does not deliver lines early, so the function exposes canStream for callers who care.
export async function* readLines(response) {
if (!canStream(response)) { for (const line of (await response.text()).split("\n")) if (line) yield line; return; }
const decoder = new TextDecoder();
const reader = response.body.getReader();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true }); // keeps a half-received multi-byte character
let i; while ((i = buf.indexOf("\n")) >= 0) { yield buf.slice(0, i); buf = buf.slice(i + 1); }
}
buf += decoder.decode();
if (buf) yield buf;
}
{ stream: true } matters. With it, a Japanese character split across two network chunks decodes correctly; the lab also decodes the same bytes without it, and the result is corrupted.
Testing against the runtimes you ship
The tests run the client source inside separate node:vm realms whose globals I set up to match each combination. They are simulations of the layers, not the engines.
profile missing for the shared client naive new URL('../x', 'https://a.example/b/c/d') joinUrl('https://a.example/b/c/d','x')
web-like - https://a.example/b/x https://a.example/b/c/d/x
rn-0.87.1 TextDecoder https://a.example/b/c/d/../x https://a.example/b/c/d/x
rn+expo - https://a.example/b/x https://a.example/b/c/d/x
bare fetch, TextDecoder throws: URL is not defined https://a.example/b/c/d/x
The rn-0.87.1 profile uses React Native’s own URL.js and no TextDecoder; rn+expo uses whatwg-url-minimum and adds one; bare has no URL, fetch or TextDecoder. The contract tests (13, all passing) check that joinUrl gives the same string in all four, that timeoutSignal works in all four, that assertRuntime names exactly the missing globals, that readLines works with and without a stream, and that the naive new URL result differs between runtimes as shown.
A checklist for any Web API you want to share
- Find where the global is defined for each runtime you target (engine, framework polyfill, your own runtime layer). If you cannot find it, treat it as absent.
- Check the behaviors your code depends on, not the name: resolution, encoding, case, setters.
- Prefer plain strings and arrays at the library boundary; keep rich objects (
URL,Request, streams) inside the platform adapters. - Add the capability check to startup, and one test per runtime profile.
- Re-check after upgrading React Native or Expo. Their runtime files change between versions.
Run the profiles
mkdir shared-client-lab && cd shared-client-lab
# put client.js, profiles.mjs, rn-url.cjs, contract.test.mjs, run-lab.mjs from the lab here
npm init -y && npm i -D [email protected] [email protected] [email protected] --ignore-scripts --legacy-peer-deps
node run-lab.mjs
node --test contract.test.mjs
Ways shared code breaks on the phone
new URL(relative, base)in shared code. Symptom: paths with../or protocol-relative references resolve to the wrong URL on the phone, and a request goes to a path you did not intend. Fix: join strings, and reject inputs that need resolution.- Reading
response.bodywithout a fallback. Symptom:Cannot read property 'getReader' of undefinedon one runtime only. Fix: test for the stream; document that the fallback buffers the whole body. - Using
TextDecoderwithout checking. Symptom:ReferenceErroron an engine or setup that lacks it. Fix:assertRuntime()at startup. - Decoding chunks one at a time. Symptom: garbled characters at chunk boundaries, only with multi-byte text. Fix: one decoder per stream, with
{ stream: true }. - Remembering old version trivia. Symptom: a workaround for a missing
AbortSignal.timeoutthat you no longer need, or a reliance on a feature that is not there. Fix: read the source of the version you ship; I found both in 0.87.1 and I did not trace in which release they first appeared. - Testing only in Node or a browser. Symptom: everything passes in CI and fails in the app. Fix: run the contract tests per profile, and run at least a smoke test in a real runtime before release.
When to share a client
Share a client when the logic is the point: request signing, retry policy, pagination, parsing. Keep the platform edges thin and replaceable.
Do not share code that leans on rich Web objects (Request cloning, URL mutation, ReadableStream piping) unless you can pin the runtime. If the app and the site evolve separately, two small adapters are cheaper than one clever abstraction.
Checked in Node, unchecked in Hermes
Verified: the contents of react-native 0.87.1 and expo 57.0.26 as installed (which globals the setup files define, whatwg-fetch 3.6.20 having no response body, the AbortSignal implementation having timeout and any); the URL table, by running React Native’s URL.js after type stripping next to Node 22.23.3’s URL and whatwg-url-minimum 0.1.2; and the 13 contract tests over four simulated profiles.
Not verified: Hermes. I did not run any code in it, so I cannot say whether the engine supplies TextDecoder or differs in how it runs URL.js. I did not run an app on a phone, or the Expo runtime itself, only the whatwg-url-minimum package that it installs. Which version added each global to React Native is not something I checked. The node:vm profiles are approximations I built from the sources above.
Ask the runtime
The shared client works when it knows what its host runtime is, and says so at startup. The Web platform is a family of standards that each runtime implements to a different degree, and the first place that shows is the part you thought was too boring to test.