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 URL is a small regex-based class. Run next to Node’s WHATWG URL, it returned https://a.example/b/c/d/../x for new URL('../x', 'https://a.example/b/c/d'), where the web returns https://a.example/b/x. It also left //c.example/x unresolved, kept host case and the explicit :443, and did not percent-encode a space. In a project that loads Expo’s runtime, the global URL is replaced by a whatwg-url-minimum implementation, and those cases behave like the web.
  • In the React Native 0.87.1 source, fetch comes from whatwg-fetch 3.6.20, and I found nothing in it that provides a response body stream. Expo’s replacement fetch builds one. So streaming code needs a fallback or a stated requirement.
  • AbortSignal.timeout and AbortSignal.any exist 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.

GlobalReact Native 0.87.1Added or replaced by Expo’s runtime
fetchwhatwg-fetch 3.6.20; I found no response body stream in itExpo’s fetch, with a stream (unless EXPO_PUBLIC_USE_RN_FETCH)
URL, URLSearchParamsRN’s own Libraries/Blob/URL.jswhatwg-url-minimum
AbortController, AbortSignalRN’s own, with abort, timeout, anypatch adds timeout/any if missing
TextDecodernot defined by setUpXHR.js (I did not check whether the engine adds one)UTF-8 fallback
ReadableStreamnot covered hereinjected 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.

CaseNode URLReact Native 0.87.1 URL.jswhatwg-url-minimum
new URL('../x', 'https://a.example/b/c/d')https://a.example/b/xhttps://a.example/b/c/d/../xhttps://a.example/b/x
new URL('//c.example/x', 'https://a.example')https://c.example/x//c.example/xhttps://c.example/x
HTTPS://A.EXAMPLE/x as hreflowercasedunchangedhttps://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'worksno effectworks
URL.canParsefunctionundefinedfunction
searchParams.append, then href?k=v?k=vnot tested
search setterworksworksnot 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

  1. 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.
  2. Check the behaviors your code depends on, not the name: resolution, encoding, case, setters.
  3. Prefer plain strings and arrays at the library boundary; keep rich objects (URL, Request, streams) inside the platform adapters.
  4. Add the capability check to startup, and one test per runtime profile.
  5. 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.body without a fallback. Symptom: Cannot read property 'getReader' of undefined on one runtime only. Fix: test for the stream; document that the fallback buffers the whole body.
  • Using TextDecoder without checking. Symptom: ReferenceError on 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.timeout that 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.