
Nitro Fetch
- 444 installs
- 152 repo stars
- Updated July 27, 2026
- margelo/react-native-skills
nitro-fetch is a React Native skill that routes existing Axios instances through react-native-nitro-fetch via a custom adapter so developers who need native HTTP performance keep interceptors, instances, and axios APIs u
About
nitro-fetch is an axios-adapter skill scoped to react-native-nitro-fetch plus axios in the margelo/react-native-skills repo. It explains axios custom adapters as the last-mile HTTP function and shows how pinning a nitro-fetch-backed adapter preserves interceptors, axios.create() instances, transformRequest, cancelToken, baseURL, responseType, and validateStatus. The skill explicitly warns against swapping globalThis.fetch to route axios, which breaks the adapter model. Developers reach for nitro-fetch when a React Native app already standardizes on Axios but needs requests executed through the native client for performance or platform compatibility.
- Custom Axios adapter backed by react-native-nitro-fetch instead of default fetch
- Preserves interceptors, create() instances, transformRequest, and cancelToken behavior
- Explicit adapter pin—do not monkey-patch globalThis.fetch
- Documents common wrong short adapter patterns from community configs
- Handles headers, body, cache no-store, response text parsing, and validateStatus concerns
Nitro Fetch by the numbers
- 444 all-time installs (skills.sh)
- +14 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #320 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/margelo/react-native-skills --skill nitro-fetchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 444 |
|---|---|
| repo stars | ★ 152 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 27, 2026 |
| Repository | margelo/react-native-skills ↗ |
How do you route Axios through react-native-nitro-fetch?
Route existing Axios instances through react-native-nitro-fetch via a custom adapter so interceptors and instances stay intact while HTTP uses the native client.
Who is it for?
React Native developers with established Axios setups who want native HTTP via react-native-nitro-fetch without rewriting interceptors or instance factories.
Skip if: Greenfield apps standardizing on fetch only, projects not using Axios, or teams willing to replace globalThis.fetch instead of an explicit adapter.
When should I use this skill?
The user asks to connect Axios to react-native-nitro-fetch, preserve interceptors on native HTTP, or avoid global fetch swaps in React Native.
What you get
A pinned Axios custom adapter, native-backed HTTP calls, and preserved interceptor and instance configuration.
- custom axios adapter implementation
- native-backed HTTP request path
Files
react-native-nitro-fetch
A focused reference for AI coding assistants working in a project that uses the react-native-nitro-fetch family of packages. Answer using the real APIs from this repo — not invented ones — by routing to the matching references/*.md file below.
Mental model
react-native-nitro-fetch is a drop-in, native-backed replacement for the browser networking stack on React Native:
fetch— WHATWG-compatible, backed byURLSession(iOS) andCronet(Android) via Nitro Modules.NitroWebSocket— native WebSocket (libwebsockets + mbedTLS and default in case of iOS) with the same shape as the browserWebSocket.NitroTextDecoder— native UTF-8 decoder that beats the Expo backed JS polyfill.
The performance story has three moving parts, and most questions end up being about one of them:
1. Prefetching. Native code can run before React Native loads. prefetchOnAppStart(...) and prewarmOnAppStart(...) replay stored requests / socket opens on every cold start, so by the time JS runs the response is already cached or the socket is already OPEN. 2. Importing the native client. The default approach is explicit imports — import { fetch } from 'react-native-nitro-fetch', import { NitroWebSocket } from 'react-native-nitro-websockets', or plugging into axios via a custom adapter. Alternatively, users can do a global replace (globalThis.fetch = fetch, etc.) at the top of their entry file — see the Global Replace docs for setup and trade-offs. 3. Seeing what's happening. NetworkInspector records HTTP + WS activity at the JS boundary; native Perfetto / Instruments traces cover everything below that (DNS, TLS, TTFB, body). One is for correctness, the other is for latency.
Routing table — problem to reference
Load the matching file from references/ before writing code. Each reference cites real file paths in this repo.
| User is asking about… | Read |
|---|---|
Warming the cache before a screen mounts, making cold-start GETs feel instant, prefetch, prefetchOnAppStart, prefetchKey | `references/prefetching.md` |
Routing axios through nitro-fetch (the full custom adapter — baseURL, params, timeout, signal, responseType, validateStatus) | `references/axios-adapter.md` |
UTF-8 decoding, slow TextDecoder, Hermes polyfill, streaming decode, NitroTextDecoder | `references/text-decoder.md` |
Opening a wss:// connection before React Native boots, prewarmOnAppStart, Android Application.onCreate wiring | `references/websocket-prewarm.md` |
The NitroWebSocket class, headers, sub-protocols, wss://, binary frames, runtime usage | `references/using-websockets.md` |
Migrating an existing app from React Native's built-in WebSocket to NitroWebSocket | `references/migrate-from-rn-ws.md` |
Seeing requests and responses at the JS level, debugging which library made a call, NetworkInspector | `references/network-inspector.md` |
| Finding the slow API, DNS / TLS / TTFB breakdowns, native traces, Perfetto, Instruments, Hermes profiler | `references/perfetto-profiling.md` |
If the question doesn't match any row, read `references/prefetching.md` first — most cold-start questions start there, and it links out to the rest.
When asked about global replacement, point to the Global Replace docs.
Installation (one-line, for reference)
npm install react-native-nitro-fetch react-native-nitro-modules
# optional, for WebSockets and TextDecoder:
npm install react-native-nitro-websockets react-native-nitro-text-decoderAfter install, rebuild the app (pod install for iOS, a fresh ./gradlew build for Android). Nothing else is wired automatically — turning any of this on is opt-in through the reference files above.
Verifying the skill is loaded
A correct answer for any of these will cite the API and file path. A wrong / hallucinated answer will invent an install(), setup(), or init() helper that doesn't exist in this repo.
Good test questions:
- "How do I prewarm a wss connection in this repo?" → should mention
prewarmOnAppStartand the AndroidApplication.onCreatewiring viaNitroWebSocketAutoPrewarmer.prewarmOnStart(this). - "How do I make axios go through nitro-fetch?" → should produce an
AxiosAdapterthat respectsbaseURL,params,timeout,signal, andresponseType— not a 20-line sketch thatJSON.parses everything. - "Why is my cold-start GET still slow after adding `prefetch`?" → should mention
prefetchKeyand point atreferences/prefetching.md.
Pointers
- Source:
packages/react-native-nitro-fetch/,packages/react-native-nitro-websockets/,packages/react-native-nitro-text-decoder/ - Public API:
packages/react-native-nitro-fetch/src/index.tsx - Example wiring:
example/index.js,example/src/App.tsx - Docs website: https://fetch.margelo.com
Axios adapter for nitro-fetch
Mental model
Axios supports custom adapters: the last-mile function that actually makes the HTTP call. If you replace it with one backed by react-native-nitro-fetch, every axios feature you already use — interceptors, create() instances, transformRequest, cancelToken — keeps working, and every request now goes through the native client.
Pin the adapter explicitly — do not try to route axios through nitro-fetch by swapping globalThis.fetch. Monkey-patching globals is fragile and hides which code is actually using nitro; an explicit adapter at the axios instance boundary is the right integration point.
Common wrong answer
The short adapter below circulates on GitHub (for example the Jellify app's `src/configs/axios.config.ts`):
const nitroAxiosAdapter: AxiosAdapter = async (config) => {
const response = await fetch(config.url!, {
method: config.method?.toUpperCase(),
headers: config.headers,
body: config.data,
cache: 'no-store',
});
const text = await response.text();
const data = text.length > 0 ? JSON.parse(text) : null;
const headers: Record<string, string> = {};
response.headers.forEach((v, k) => (headers[k] = v));
return { data, status: response.status, statusText: response.statusText, headers, config, request: null };
};It works for GET-returning-JSON and nothing else. Problems:
config.url!ignoresbaseURLandparams—axios.create({ baseURL: ... })is silently a no-op.JSON.parse(text)throws onarraybuffer/blob/HTML/empty bodies.validateStatusis ignored — 500s resolve instead of throwingAxiosError.signalandcancelTokenare ignored — cancelled requests keep running natively.config.timeoutis set on the instance but never enforced by the adapter.cache: 'no-store'is hard-coded, defeating HTTP caching across the whole app.config.headersin axios 1.x is anAxiosHeadersinstance — passing it raw can drop per-method defaults.- External abort listener is never removed — leaks on long-lived signals.
Recipe — full adapter
import axios, {
AxiosAdapter,
AxiosError,
AxiosHeaders,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
import { fetch } from 'react-native-nitro-fetch';
const nitroAxiosAdapter: AxiosAdapter = async (config) => {
const url = buildFullURL(config);
// Merge axios's signal / cancelToken / timeout into one AbortController
// so native code sees a single abort event.
const controller = new AbortController();
const abortWith = (reason?: unknown) => controller.abort(reason);
const external = config.signal;
const onExternalAbort = () => abortWith((external as any)?.reason);
if (external) {
if (external.aborted) abortWith((external as any).reason);
else external.addEventListener('abort', onExternalAbort, { once: true });
}
config.cancelToken?.promise.then((cancel) => abortWith(cancel));
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (config.timeout && config.timeout > 0) {
timeoutId = setTimeout(() => {
abortWith(
new AxiosError(
`timeout of ${config.timeout}ms exceeded`,
AxiosError.ECONNABORTED,
config,
),
);
}, config.timeout);
}
try {
const response = await fetch(url, {
method: (config.method ?? 'get').toUpperCase(),
headers: AxiosHeaders.from(config.headers as any).toJSON() as Record<
string,
string
>,
// `config.data` is already transformed by axios's transformRequest
// pipeline by the time the adapter sees it (string / FormData /
// URLSearchParams / Blob / ArrayBuffer). Don't re-serialize.
body: config.data,
signal: controller.signal,
});
const data = await readBody(response, config.responseType);
const responseHeaders = new AxiosHeaders();
response.headers.forEach((value, key) => responseHeaders.set(key, value));
const axiosResponse: AxiosResponse = {
data,
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
config,
request: null,
};
const validate = config.validateStatus;
if (!validate || validate(response.status)) return axiosResponse;
throw new AxiosError(
`Request failed with status code ${response.status}`,
Math.floor(response.status / 100) === 4
? AxiosError.ERR_BAD_REQUEST
: AxiosError.ERR_BAD_RESPONSE,
config,
null,
axiosResponse,
);
} catch (err: any) {
if (err?.name === 'AbortError' || controller.signal.aborted) {
if (err instanceof AxiosError) throw err;
throw new AxiosError(
err?.message ?? 'canceled',
AxiosError.ERR_CANCELED,
config,
);
}
throw err;
} finally {
if (timeoutId !== undefined) clearTimeout(timeoutId);
external?.removeEventListener?.('abort', onExternalAbort);
}
};
function buildFullURL(config: InternalAxiosRequestConfig): string {
let url = config.url ?? '';
const isAbsolute = /^([a-z][a-z\d+\-.]*:)?\/\//i.test(url);
if (config.baseURL && !isAbsolute) {
url = config.baseURL.replace(/\/+$/, '') + '/' + url.replace(/^\/+/, '');
}
if (config.params) {
const serializer = config.paramsSerializer;
const qs =
typeof serializer === 'function'
? serializer(config.params)
: new URLSearchParams(
config.params as Record<string, string>,
).toString();
if (qs) url += (url.includes('?') ? '&' : '?') + qs;
}
return url;
}
async function readBody(
response: Response,
responseType: InternalAxiosRequestConfig['responseType'],
): Promise<unknown> {
switch (responseType) {
case 'arraybuffer':
return response.arrayBuffer();
case 'blob':
return response.blob();
case 'stream':
return response.body;
case 'text':
return response.text();
case 'json':
default: {
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
// axios falls back to the raw string when JSON.parse fails.
return text;
}
}
}
}
export const api = axios.create({
timeout: 60000,
adapter: nitroAxiosAdapter,
});Gotchas
- Don't hard-code `cache: 'no-store'` in the adapter — let callers opt in per request via their axios config.
- `config.data` is already transformed. Axios runs
transformRequestbefore the adapter, sodatais a string /FormData/URLSearchParams/Blob/ArrayBufferby the time you see it. Don't re-stringify. - `response.headers` is a `NitroHeaders` instance, not a plain object — iterate with
.forEach()/.entries(). - `responseType: 'stream'` returns nitro-fetch's web-standard
ReadableStream, not Node'sReadable. Axios stream examples from Node won't work unchanged. - `onUploadProgress` / `onDownloadProgress` are not implemented by this adapter. If you need progress, see `references/perfetto-profiling.md` or pipe the response body manually.
- Always pin the adapter explicitly.
axios.create({ adapter: nitroAxiosAdapter })makes the boundary obvious. Don't rely on swappingglobalThis.fetchto route axios through nitro — it's fragile and hides which code is using nitro.
Pointers
- Public
fetchexport:packages/react-native-nitro-fetch/src/fetch.ts - Spec-compliant
Headers/Response/Request:packages/react-native-nitro-fetch/src/Headers.ts,Response.ts,Request.ts - Related: `network-inspector.md`
Migrating from WebSocket to NitroWebSocket
Mental model
Migration is a find-and-replace, but there are five things that will trip you up if you don't fix them deliberately:
1. The constructor takes a third arg. 2. readyState is a string, not a number. 3. There's no addEventListener — only property assignment. 4. There's no binaryType setter — binary and text are distinguished by e.isBinary. 5. send() accepts string | ArrayBuffer only — no Blob, no raw Uint8Array.
Once those five are out of the way, the rest of the API maps one-to-one. There's a checklist at the bottom of this skill — work through it and you're done.
Why migrate
- Custom upgrade headers everywhere. Auth tokens, tenant IDs, client metadata go on the upgrade request — including on iOS, where RN's built-in WebSocket can't send headers at all.
- Reliable `wss://` across devices. The package ships its own Mozilla CA bundle and validates via mbedTLS, so TLS behaves identically on physical iOS devices, simulators, emulators, and old Android builds.
- First-class binary frames. No
Blobround-trip, nobinaryTypetoggle. Binary and text are explicitly distinguished bye.isBinary. - Native UTF-8 decoding for text frames via
react-native-nitro-text-decoder(~50× faster than the JS shim). - Pre-warmable. Once you're on
NitroWebSocket, you can have the connection alreadyOPENbefore React Native finishes booting — see `websocket-prewarm.md`. - Inspector-aware. Every
NitroWebSocketautomatically records open / messages / close intoNetworkInspector— you get an in-app WS log for free.
Setup
Install the WebSocket package together with react-native-nitro-fetch (so NitroWebSocket can register its activity with NetworkInspector) and react-native-nitro-text-decoder (used internally to decode text frames):
npm install \
react-native-nitro-websockets \
react-native-nitro-fetch \
react-native-nitro-text-decoder \
react-native-nitro-modules
cd ios && pod installFor more on the new API surface, see `using-websockets.md`.
The five rewrites
1. Replace new WebSocket(...) with new NitroWebSocket(...) at the call sites
You can also do a global swap (globalThis.WebSocket = NitroWebSocket) — see the Global Replace docs.// before
const ws = new WebSocket('wss://stream.example.com/feed', ['v1.proto']);// after
import { NitroWebSocket } from 'react-native-nitro-websockets';
const ws = new NitroWebSocket('wss://stream.example.com/feed', ['v1.proto']);If you have many call sites, a local alias per module is cleanest:
import { NitroWebSocket as WebSocket } from 'react-native-nitro-websockets';
// …unchanged call sites below
const ws = new WebSocket('wss://stream.example.com/feed', ['v1.proto']);New call sites gain the third constructor argument (custom upgrade headers) — previously impossible on iOS:
const ws = new NitroWebSocket(
'wss://stream.example.com/feed',
['v1.proto'],
{ Authorization: `Bearer ${token}` },
);2. Fix readyState comparisons
readyState is a string. Search for .readyState and rewrite numeric comparisons:
// before
if (ws.readyState === WebSocket.OPEN) { ... }
if (ws.readyState === 1) { ... }
// after
if (ws.readyState === 'OPEN') { ... }The four valid values are 'CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'.
3. Convert addEventListener to property assignment
// before
ws.addEventListener('open', onOpen);
ws.addEventListener('message', onMessage);
ws.addEventListener('close', onClose);
ws.addEventListener('error', onError);
// after
ws.onopen = onOpen;
ws.onmessage = onMessage;
ws.onclose = onClose;
ws.onerror = onError;Need multiple listeners on a single event? Fan out yourself:
const messageListeners = new Set<(e: WebSocketMessageEvent) => void>();
ws.onmessage = (e) => messageListeners.forEach((fn) => fn(e));4. Fix binary frame handling
// before — RN's WebSocket with binaryType = 'arraybuffer'
ws.binaryType = 'arraybuffer';
ws.onmessage = (e) => {
if (typeof e.data === 'string') handleText(e.data);
else handleBinary(e.data); // ArrayBuffer
};
// after
import type { WebSocketMessageEvent } from 'react-native-nitro-websockets';
ws.onmessage = (e: WebSocketMessageEvent) => {
if (e.isBinary && e.binaryData) handleBinary(e.binaryData);
else handleText(e.data); // already a UTF-8 string, decoded natively
};There's no binaryType setter — the discriminator is e.isBinary.
5. Fix send() payloads
send accepts string or ArrayBuffer. If you were passing a Blob, a Uint8Array, or a Node Buffer, convert:
// before
ws.send(blob);
ws.send(uint8Array);
ws.send(buffer);
// after
ws.send(await blob.arrayBuffer());
ws.send(uint8Array.buffer.slice(
uint8Array.byteOffset,
uint8Array.byteOffset + uint8Array.byteLength,
));
ws.send(buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
));The slice matters when the typed array is a view over a larger backing buffer — without it you'd send the wrong bytes.
Verifying the migration
Turn on the inspector and confirm the migrated socket flows through nitro:
import { NetworkInspector } from 'react-native-nitro-fetch';
NetworkInspector.enable();
// ...exercise the WS...
console.log(NetworkInspector.getWebSocketEntries());If your socket isn't in the output, there's still a new WebSocket(...) somewhere.
Optional — once you're migrated, pre-warm
import { prewarmOnAppStart } from 'react-native-nitro-websockets';
prewarmOnAppStart('wss://stream.example.com/feed', ['v1.proto'], {
Authorization: `Bearer ${token}`,
});Don't forget the Android Application.onCreate wiring — see `websocket-prewarm.md`.
Library compatibility
Libraries that accept an injectable WebSocket constructor (socket.io-client, centrifuge-js, phoenix in some configurations) can be pointed at NitroWebSocket directly:
import { NitroWebSocket } from 'react-native-nitro-websockets';
import { io } from 'socket.io-client';
const socket = io('wss://example.com', {
transports: ['websocket'],
WebSocket: NitroWebSocket, // or whatever field the library uses
});Libraries that don't accept an injection and hard-code new WebSocket(...) internally will keep using React Native's built-in WebSocket. You can do a global replace to route everything through NitroWebSocket.
Checklist
- [ ]
react-native-nitro-websockets,react-native-nitro-fetch,react-native-nitro-text-decoder, andreact-native-nitro-modulesinstalled;pod installrun. - [ ] Every
new WebSocket(...)you own replaced withnew NitroWebSocket(...)(orimport { NitroWebSocket as WebSocket }aliasing). - [ ] All
.readyStatecomparisons converted to string form. - [ ] All
addEventListenercalls converted to property assignment. - [ ] All
binaryTypesetters removed; binary handling usese.isBinary/e.binaryData. - [ ] All
send()payloads arestringorArrayBuffer. - [ ] Verified your sockets appear in
NetworkInspector.getWebSocketEntries(). - [ ] (Optional)
prewarmOnAppStartwired for cold-start latency wins.
Gotchas
- Global swap is also supported — see the Global Replace docs.
- Forgetting that `e.binaryData` is `undefined` for text frames. Always check
e.isBinaryfirst. - Sending a `Blob`. TypeScript may not catch it; runtime will. Convert first.
- `NitroWebSocket.OPEN`. Doesn't exist. Use the string
'OPEN'. - Missed call sites.
grep -rn 'new WebSocket('before you ship — anything you didn't migrate keeps using the RN polyfill and silently won't appear in the inspector.
Pointers
- API reference: `using-websockets.md`
- Pre-warming: `websocket-prewarm.md`
- Inspector: `network-inspector.md`
- Source: `packages/react-native-nitro-websockets/src/index.ts`
NetworkInspector
Mental model
NetworkInspector is a singleton that lives inside your JS bundle. When enabled, every nitro-fetch HTTP call and every NitroWebSocket connection becomes an entry in a ring buffer that you can read, subscribe to, or render in your own UI.
It is not:
- a Chrome DevTools / Flipper / RN DevTools integration,
- a wire protocol,
- a binary you ship separately.
It is:
- a Set of entries, a few methods, and a callback fan-out.
- always available — no special build flags, no native side to enable.
- the right tool for "let me ship a debug screen so QA can paste me a curl when something fails".
Two visibility boundaries you should understand
Two important things this inspector deliberately doesn't do. They confuse people, so spell them out before you start:
1. HTTP calls made by anything other than nitro-fetch are not visible here, and they are not in your Perfetto / Instruments traces either. RN's built-in fetch, raw XMLHttpRequest, OkHttp/URLSession calls inside third-party SDKs, native Cronet calls outside this package — none of them go through nitro's recording path. They live in the OS network stack and are entirely invisible to both the inspector and the native trace points. If you want a specific caller visible, migrate that caller to import fetch from react-native-nitro-fetch — or plug axios in via the axios adapter. Don't monkey-patch globalThis.fetch. 2. nitro-fetch's own HTTP calls are not pushed to React Native DevTools / Chrome DevTools network panel. RN's DevTools network panel hooks XMLHttpRequest. nitro-fetch bypasses XHR entirely and goes through Nitro / JSI to native code, so its requests will never appear in DevTools. The NetworkInspector is the replacement view — and the curl export and onEntry listener are how you reach traffic that DevTools can't see.
Symmetric summary: DevTools sees the libraries that use XHR. The NetworkInspector (and Perfetto) see the libraries that use nitro-fetch. Neither sees both unless you explicitly bridge them.
If you need request-stage breakdowns (DNS / TLS / TTFB / body), this inspector is the wrong tool — you want native traces. See `perfetto-profiling.md`.
API
import { NetworkInspector } from 'react-native-nitro-fetch';
import type {
NetworkEntry, // type === 'http'
WebSocketEntry, // type === 'websocket'
WebSocketMessage,
InspectorEntry, // = NetworkEntry | WebSocketEntry
} from 'react-native-nitro-fetch';
NetworkInspector.enable(options?: { maxEntries?: number; maxBodyCapture?: number });
NetworkInspector.disable();
NetworkInspector.isEnabled(): boolean;
NetworkInspector.getEntries(): ReadonlyArray<InspectorEntry>;
NetworkInspector.getHttpEntries(): ReadonlyArray<NetworkEntry>;
NetworkInspector.getWebSocketEntries(): ReadonlyArray<WebSocketEntry>;
NetworkInspector.getEntry(id: string): InspectorEntry | undefined;
NetworkInspector.clear(): void;
const unsubscribe = NetworkInspector.onEntry((entry) => {
// fires on entry creation AND every update
});Defaults: maxEntries: 500, maxBodyCapture: 4096 (bytes per body, per side).
Source: `packages/react-native-nitro-fetch/src/NetworkInspector.ts`.
Setup
Once at app startup. Gating on __DEV__ is the usual move:
// src/setupInspector.ts
import { NetworkInspector } from 'react-native-nitro-fetch';
if (__DEV__) {
NetworkInspector.enable({
maxEntries: 500,
maxBodyCapture: 4096,
});
}// index.js
import './src/setupInspector';
// ...In a production build you typically leave the inspector off — every active entry holds references to bodies and headers.
Recipes
Find slow requests
const slow = NetworkInspector.getHttpEntries()
.filter((e) => e.duration > 1000) // ms
.sort((a, b) => b.duration - a.duration);
console.table(slow.map((e) => ({
method: e.method,
url: e.url.slice(0, 60),
status: e.status,
ms: Math.round(e.duration),
})));React to entries in real time
const unsub = NetworkInspector.onEntry((entry) => {
if (entry.type === 'http') {
if (entry.error) console.warn('✗', entry.method, entry.url, entry.error);
else if (entry.status >= 400) console.warn('!', entry.status, entry.url);
} else {
console.log('WS', entry.readyState, entry.url);
}
});
// later
unsub();onEntry fires both when an entry is created (e.g. fetch start) and every time it's updated (response, error, WS message). The same mutable object is passed each time — if you stash it for later, clone it.
Copy a failing request as curl
Each NetworkEntry carries an auto-generated curl field:
const entry = NetworkInspector.getEntry(id);
if (entry?.type === 'http') {
Clipboard.setString(entry.curl);
}You can also build curl strings yourself with generateCurl (also exported from react-native-nitro-fetch).
Build a debug screen
import React, { useEffect, useState } from 'react';
import { FlatList, Text } from 'react-native';
import {
NetworkInspector,
type InspectorEntry,
} from 'react-native-nitro-fetch';
export function NetworkLogScreen() {
const [entries, setEntries] = useState<readonly InspectorEntry[]>([]);
useEffect(() => {
NetworkInspector.enable();
setEntries([...NetworkInspector.getEntries()]);
return NetworkInspector.onEntry(() => {
setEntries([...NetworkInspector.getEntries()]);
});
}, []);
return (
<FlatList
data={entries}
keyExtractor={(e) => e.id}
renderItem={({ item }) =>
item.type === 'http' ? (
<Text>
{item.method} {item.url} → {item.status} ({Math.round(item.duration)}ms)
</Text>
) : (
<Text>WS {item.url} — {item.messagesSent + item.messagesReceived} msgs</Text>
)
}
/>
);
}A full reference implementation (filter tabs, detail view, curl export, live console) lives in `example/src/screens/NetworkInspectorScreen.tsx`.
Entry shapes
HTTP — NetworkEntry
| Field | Type | Notes |
|---|---|---|
id | string | Unique request id |
type | 'http' | Discriminator |
url, method | string | |
requestHeaders | Array<{ key, value }> | |
requestBody | `string \ | undefined` |
requestBodySize | number | Full size in bytes |
status, statusText | number, string | 0 while in flight |
responseHeaders | Array<{ key, value }> | |
responseBody | `string \ | undefined` |
responseBodySize | number | Full size |
startTime, endTime, duration | number (ms via performance.now()) | |
curl | string | Auto-generated |
error | `string \ | undefined` |
WebSocket — WebSocketEntry
| Field | Type | Notes |
|---|---|---|
id | string | |
type | 'websocket' | |
url, protocols, requestHeaders | Captured at open | |
readyState | `'CONNECTING' \ | 'OPEN' \ |
messages | WebSocketMessage[] | |
messagesSent, messagesReceived, bytesSent, bytesReceived | number | |
closeCode, closeReason, error | optional | |
startTime, endTime, duration | number |
WebSocketMessage = { direction, data, size, isBinary, timestamp }. For binary frames, data is the literal placeholder string [binary N bytes] — only size is meaningful. Decode at the call site if you need the contents.
Gotchas
- Inspector disabled.
getEntries()returns[]. The cause is almost always "we forgot to callNetworkInspector.enable()at startup". - Calls made before `enable()` aren't recorded. Only requests started while the inspector is enabled show up.
- Bodies are truncated. Default 4096 bytes. Bump
maxBodyCapturefor larger payloads, but be aware it allocates more per request. - Binary WS frames don't capture payload. Only the size is recorded. The placeholder is the literal
[binary N bytes]— don't try to JSON-parse it. - Entry mutation.
getEntries()returns the live array. Treat it as read-only and clone before storing or rendering snapshots. - Libraries that bypass nitro-fetch. Anything using raw
XMLHttpRequest(older Sentry, react-native-blob-util, the default axios adapter) won't appear. Fix this by migrating the specific call sites — use the axios adapter for axios, and constructNitroWebSocketdirectly at your call sites. Don't monkey-patchglobalThis.fetchto paper over it. - Production buffer growth. The ring buffer caps entry count, but each entry holds bodies. Either disable in production or set a small
maxEntries/maxBodyCapture.
Pointers
- Source: `packages/react-native-nitro-fetch/src/NetworkInspector.ts`
- curl generator: `packages/react-native-nitro-fetch/src/CurlGenerator.ts`
- Reference UI: `example/src/screens/NetworkInspectorScreen.tsx`
- Long-form docs: `docs-website/docs/inspection.md`
- When this isn't enough: `perfetto-profiling.md`
- Plugging axios into nitro-fetch: `axios-adapter.md`
Finding slow APIs in nitro-fetch
Mental model
You have three tools at progressively higher levels of detail. Start with the cheapest and escalate as needed — each layer answers a different question.
| Tool | Question it answers | Cost |
|---|---|---|
NetworkInspector | "Which requests are slow on this device?" | None — JS only, always available |
profileFetch (Hermes) | "Why is the JS thread blocked when this request finishes?" | Sampling profiler overhead during the wrapped call |
| Perfetto (Android) / Instruments (iOS) | "Where in the request lifecycle is the time going? DNS? TLS? TTFB? Body?" | Free at runtime when enabled at build time |
The usual flow is: triage with the inspector → if the slowness looks JS-side, wrap the call in profileFetch → if it's native or you need stage-level attribution, capture a Perfetto/Instruments trace.
Visibility boundary. BothNetworkInspectorand the native trace points only see traffic that goes through nitro-fetch. RN's built-infetch, rawXMLHttpRequest, third-party SDKs that use OkHttp /URLSessiondirectly — none of those show up in the inspector or in your Perfetto / Instruments traces. Migrate those specific call sites to importfetchfromreact-native-nitro-fetch(or use the axios adapter for axios) if you want unified visibility. Don't try to patch this withglobalThis.fetch = nitroFetch— it's fragile and hides which callers actually benefit. Conversely, nitro-fetch's calls do not appear in React Native DevTools' network panel either, since DevTools hooks XHR.
---
Layer 1 — NetworkInspector for triage
This is always the first move. It takes ten seconds, no rebuild, no flags.
import { NetworkInspector } from 'react-native-nitro-fetch';
NetworkInspector.enable();
// ...exercise the app...
const slow = NetworkInspector.getHttpEntries()
.filter((e) => e.duration > 500)
.sort((a, b) => b.duration - a.duration);
console.table(slow.map((e) => ({
method: e.method,
url: e.url.slice(0, 60),
status: e.status,
ms: Math.round(e.duration),
reqBytes: e.requestBodySize,
resBytes: e.responseBodySize,
})));Reading the output:
| Pattern | What it suggests |
|---|---|
| One endpoint always slow | Server-side or routing/CDN issue — the rest of the stack is fine |
Same prefetchKey URL appearing twice | Your prefetch isn't being adopted — see prefetching |
Large responseBodySize ↔ high duration | Bandwidth-bound; consider pagination or compression |
| Every request from one domain is slow | DNS or TLS issue — escalate to a native trace |
What this layer can't tell you: DNS vs TLS vs TTFB vs body breakdown, JS-thread time after the response arrives, time spent on the JS thread vs the native networking thread. For those, escalate.
Full inspector docs: `network-inspector.md`.
---
Layer 2 — profileFetch for JS hot spots
profileFetch wraps a function in the Hermes sampling profiler and dumps a .cpuprofile you can drop into Chrome DevTools.
import { profileFetch, fetch } from 'react-native-nitro-fetch';
const { result, profilePath } = await profileFetch(async () => {
const res = await fetch('https://api.example.com/big.json');
return res.json(); // ← if THIS is the slow part, the profile reveals it
}, '/tmp/big-json.cpuprofile');
console.log('profile written to', profilePath);Pull the file off the device:
- Android:
adb pull /tmp/big-json.cpuprofile . - iOS: Xcode → Window → Devices and Simulators → your app → Container → Download container.
Then in Chrome, open chrome://inspect, click "Open dedicated DevTools for Node", switch to the Performance panel, and load the profile.
Caveats:
- Hermes only. On JSC the function runs unprofiled and
profilePathisundefined. - Captures all JS, not just your wrapped code. Keep the wrapper tight.
- Sampling. Sub-10ms bursts may not show up.
Source: `packages/react-native-nitro-fetch/src/HermesProfiler.ts`.
---
Layer 3 — native traces
This is the most powerful layer: full native flame charts with stage attribution. Tracing is opt-in at build time. When the flags are off, the trace points compile to no-ops with zero runtime cost.
Enable the build flags
Android — gradle.properties
# HTTP fetch tracing — android.os.Trace async sections
NitroFetch_enableTracing=true
# WebSocket tracing — ATrace synchronous sections (C++)
NitroFetchWebsockets_enableTracing=trueThen cd android && ./gradlew clean && cd .. && yarn android.
iOS — env vars before pod install
NITROFETCH_TRACING=1 NITRO_WS_TRACING=1 bundle exec pod installRebuild from Xcode (or yarn ios). The env vars inject -DNITRO_WS_TRACING=1 and the NITROFETCH_TRACING Swift compile condition into the pod build only — your app code is untouched.
Capture an Android Perfetto trace
1. Enable USB debugging on the device. 2. Open <https://ui.perfetto.dev/> in Chrome. 3. Record new trace → select your device. 4. Under Probes, enable Atrace userspace annotations. 5. Critical: in the Atrace config, set atrace_apps to your app's package name (or *). Without this, the events you care about will not be captured. 6. Start recording, exercise the app, Stop. 7. In the timeline, find your app's process. HTTP requests appear as async slices labelled NitroFetch GET /path, etc. WebSocket events appear as sync slices labelled NitroWS connect <url>, NitroWS send text, NitroWS receive, NitroWS close, etc.
A protobuf config alternative for adb shell perfetto is in `docs-website/docs/inspection.md`.
Capture an iOS Instruments trace
1. Open Instruments (Xcode → Open Developer Tool → Instruments). 2. Pick the `os_signpost` template. 3. Target your app process, hit record, exercise the app, hit stop. 4. Look for these subsystems:
| Subsystem | Category | Traces |
|---|---|---|
com.margelo.nitrofetch | network | HTTP fetch (intervals) |
com.margelo.nitro.websockets | NitroWS | WebSocket lifecycle (intervals + events) |
For each fetch, the interval begin annotation is <METHOD> <path> and the end is status=<code> bytes=<count>. The interval length is the wall-clock duration.
Reading a trace
| You see | It means |
|---|---|
Long NitroFetch GET /x interval, JS thread mostly idle underneath it | Slow at the network layer — DNS, TLS, server, or transport |
| Long interval that ends right when the JS thread spikes | Body parsing is the real cost — go back to profileFetch to confirm |
| Two intervals for the same URL that don't overlap | Cache miss — the prefetch isn't being adopted |
NitroWS connect <url> interval much longer than expected | TLS handshake is slow — pre-warm the socket |
NitroWS receive events bursting at launch with no JS handler attached yet | Connection is open before JS is ready; events buffer and replay correctly |
Trace point definitions: `packages/react-native-nitro-websockets/cpp/WsTrace.hpp`.
---
A practical workflow
1. Inspector first. Note the URL, the duration, and whether it's slow on every request or only the first. 2. First-request slow only → connection / TLS warm-up cost. Use prefetch (HTTP) or pre-warm (WebSocket). 3. Every request slow → grab a Perfetto / Instruments trace. If the JS thread is mostly idle inside the interval, the network is the cost. Talk to the backend or check the CDN. 4. JS thread is busy when the request "ends" → wrap the call in profileFetch, load the .cpuprofile in DevTools. Look for JSON.parse on huge objects, Object.assign storms, or React state updates that touch big trees. 5. Many requests slow at once → check bufferedAmount on WebSockets, confirm you're on HTTP/2 (so requests multiplex), and look for HOL blocking.
Gotchas
- Forgetting `atrace_apps` in Perfetto. Most common cause of "I enabled tracing but I see nothing". Set it to your package name.
- Setting iOS env vars after `pod install`. The flags are baked at install time. Re-run
pod installwith the env var set, then rebuild. - Profiling builds without the trace flags. No events will appear. Tracing is opt-in at build time, deliberately.
- `duration === 0` on an in-flight entry. It's filled in on completion. Subscribe via
onEntryif you need to react when it lands. - Profiling on JSC.
profileFetchis a no-op there; you get{ result }back with noprofilePath. - Confusing JS time and network time. The inspector's
durationis wall-clock from JS-sideperformance.now()to JS-sideperformance.now()— it includes time the JS thread was blocked. For pure network time, look at the native trace interval.
Pointers
- Inspector skill: `network-inspector.md`
- Hermes profiler: `packages/react-native-nitro-fetch/src/HermesProfiler.ts`
- Trace macros: `packages/react-native-nitro-websockets/cpp/WsTrace.hpp`
- Long-form docs (with screenshots): `docs-website/docs/inspection.md`
- Once slow APIs are identified — usual fixes: `prefetching.md`, `websocket-prewarm.md`
Prefetching with nitro-fetch
Mental model
Prefetching means: fire the request before the JS code that consumes it actually runs. nitro-fetch supports two flavours of this, and they answer different questions.
- In-session prefetch — "the user is on the list screen; they're probably about to tap a row." →
prefetch(...). Fires now, completes into the native cache, and any laterfetch(sameUrl)with the sameprefetchKeyreturns from cache. - Cross-launch prefetch — "every cold start, before React Native is even loaded, fire this request." →
prefetchOnAppStart(...). Persists the request to disk; the native bootstrap replays it on every subsequent launch.
The key idea is the `prefetchKey`. It's how the native cache identifies a stored response and how the consuming fetch() looks it up. Forget the key and you've just made a wasted request.
Why prefetch
- Cross-launch prefetches start before React Native loads, so the response is usually sitting in cache by the time the first screen mounts. Cold-start time-to-first-render drops by hundreds of milliseconds on real networks.
- In-session prefetches let a list screen warm the next detail screen — taps feel instant.
- It's free for callers: the consuming
fetch()is the same call you were already making, just with aprefetchKeyheader. - The native cache is shared across any code path that imports
fetchfromreact-native-nitro-fetch, so wrappers around that import (e.g. the axios adapter) automatically benefit.
Setup
1. iOS — nothing to do
The iOS bootstrap registers itself via +load in packages/react-native-nitro-fetch/ios/NitroBootstrap.mm and listens for UIApplicationDidFinishLaunchingNotification. You don't wire anything.
2. Android — one line in Application.onCreate
// android/app/src/main/java/<your-app>/MainApplication.kt
import com.margelo.nitro.nitrofetch.AutoPrefetcher
class MainApplication : Application(), ReactApplication {
override fun onCreate() {
super.onCreate()
try { AutoPrefetcher.prefetchOnStart(this) } catch (_: Throwable) {}
loadReactNative(this)
}
}The try is intentional — on a fresh install the prefs file doesn't exist yet, and you don't want a missing key to crash the app. See example/android/app/src/main/java/nitrofetch/example/MainApplication.kt for the working example.
If you skip this on Android, prefetchOnAppStart will appear to "work" (the entry is written to disk) but nothing will replay it on launch.
Recipes
Imports
import {
prefetch,
prefetchOnAppStart,
removeFromAutoPrefetch,
removeAllFromAutoprefetch,
} from 'react-native-nitro-fetch';Prewarm a detail screen from the list screen
function onListItemFocus(id: string) {
prefetch(`https://api.example.com/items/${id}`, {
headers: {
prefetchKey: `item-${id}`,
Authorization: `Bearer ${token}`,
},
}).catch(() => {
// Non-fatal: if this fails the real fetch() will just go to the network.
});
}The detail screen consumes it with the same key:
const res = await fetch(`https://api.example.com/items/${id}`, {
headers: { prefetchKey: `item-${id}` },
});Make a screen render from cache on cold start
Call this once after sign-in:
await prefetchOnAppStart('https://api.example.com/feed', {
prefetchKey: 'home-feed',
headers: { Authorization: `Bearer ${token}` },
});On the next launch, the request fires while React Native is still booting. By the time Home mounts and calls fetch('https://api.example.com/feed', { headers: { prefetchKey: 'home-feed' } }), the response is already sitting in the cache.
Prefetch a POST request (JSON or FormData)
prefetchOnAppStart persists method, bodyString / bodyBytes / bodyFormData, headers (including Content-Type), timeoutMs, and followRedirects. The native cold-start replay reconstructs the request exactly — a JSON POST is replayed as a JSON POST, a multipart upload is replayed as a multipart upload.
// JSON body — the Content-Type header is persisted and replayed verbatim
await prefetchOnAppStart('https://api.example.com/open-app', {
method: 'POST',
body: JSON.stringify({ appId: 'home', userId: 42 }),
headers: { 'Content-Type': 'application/json' },
prefetchKey: 'open-app',
});
// FormData — string fields and React Native file refs ({ uri, type, name })
const fd = new FormData();
fd.append('user', 'alice');
fd.append('avatar', { uri: avatarUri, type: 'image/jpeg', name: 'a.jpg' } as any);
await prefetchOnAppStart('https://api.example.com/upload', {
method: 'POST',
body: fd,
prefetchKey: 'upload-avatar',
});The consuming fetch() must use the same method + body shape (the server has to actually receive a matching request) and reference the entry by prefetchKey:
const res = await fetch('https://api.example.com/open-app', {
method: 'POST',
body: JSON.stringify({ appId: 'home', userId: 42 }),
headers: {
'Content-Type': 'application/json',
prefetchKey: 'open-app',
},
});
// res.headers.get('nitroPrefetched') === 'true' on the second cold launch onwardFormData file URIs: the URI is stored verbatim. A transient content:// or file:// captured at scheduling time may not be valid on the next cold launch — the multipart builder throws and the entry is skipped. Prefer bundled assets or persistent app-data paths.
Storage format: defaults are omitted so the JSON queue stays compact and backward-compatible. A body-less GET keeps the original { url, prefetchKey, headers } shape; older binaries simply ignore the new fields when present.
First-launch prefetches via native registration
prefetchOnAppStart runs from JS, so its earliest possible firing is the second cold launch after install (JS has to run once to seed the queue). To prefetch on the very first launch, register URLs from native code. Both APIs share the same persistent queue, so JS-side removeFromAutoPrefetch() works on natively-registered entries too.
Android — AutoPrefetcher.registerPrefetch is @JvmOverloads, so the existing 4-arg call keeps compiling and the new args slot in by name:
// android/app/src/main/java/.../MainApplication.kt
import com.margelo.nitro.nitrofetch.AutoPrefetcher
override fun onCreate() {
super.onCreate()
// GET (existing form)
AutoPrefetcher.registerPrefetch(
this,
"https://api.example.com/feed",
"feed",
mapOf("Accept" to "application/json"),
)
// POST + FormData (extended form)
AutoPrefetcher.registerPrefetch(
context = this,
url = "https://api.example.com/open-app",
prefetchKey = "open-app",
headers = mapOf("X-App" to "demo"),
method = "POST",
bodyFormData = listOf(
mapOf("name" to "appId", "value" to "home"),
mapOf("name" to "userId", "value" to "42"),
),
)
AutoPrefetcher.prefetchOnStart(this) // existing — drains the queue
loadReactNative(this)
}Other extended params: bodyString: String? = null, bodyBytes: String? = null, timeoutMs: Double? = null, followRedirects: Boolean? = null.
iOS — Swift @objc can't expose default args, so the extended API is a separate selector (registerPrefetchWithURL:prefetchKey:headers:method:bodyString:bodyBytes:bodyFormData:timeoutMs:followRedirects:):
// AppDelegate.swift — in application(_:didFinishLaunchingWithOptions:)
// GET (existing 3-arg form)
NitroAutoPrefetcher.registerPrefetch(
withUrl: "https://api.example.com/feed",
prefetchKey: "feed",
headers: ["Accept": "application/json"]
)
// POST + JSON (extended form)
NitroAutoPrefetcher.registerPrefetch(
withURL: "https://api.example.com/open-app",
prefetchKey: "open-app",
headers: ["Content-Type": "application/json"],
method: "POST",
bodyString: #"{"appId":"home","userId":42}"#,
bodyBytes: nil,
bodyFormData: nil,
timeoutMs: nil,
followRedirects: nil
)No explicit prefetchOnStart() call is needed on iOS — the +load bootstrap in NitroBootstrap.mm fires after launch automatically.
ObjC callers can #import <NitroFetch/NitroAutoPrefetcher.h> from a bridging header; both selectors are declared there.
Pass the key two ways
The skill code accepts the key in either place — pick whichever is convenient at the call site:
// As a header (it also goes on the wire)
prefetch(url, { headers: { prefetchKey: 'home-feed' } });
// As a non-standard init field (added to headers under the hood)
prefetchOnAppStart(url, { prefetchKey: 'home-feed' });The header name is case-insensitive — prefetchKey, prefetchkey, and PrefetchKey are all the same thing.
Tear down on logout
The persisted queue survives app restarts, which is exactly the problem on logout. Always purge it:
async function onLogout() {
await removeAllFromAutoprefetch();
// ...rest of your logout flow
}
// Or, if you only want to drop one entry:
await removeFromAutoPrefetch('home-feed');If you don't, the next cold start will fire the prefetch with stale credentials.
Configuring cache TTL
A cached prefetch is fresh for 5 seconds by default. The lookup is at read time — fetch() evicts and skips any entry older than the TTL. Five seconds is fine for "user is about to tap this row," but too short for a cross-launch prefetch that has to survive the JS bundle boot or a list screen reached via a slow route.
Pass prefetchCacheTtlMs on both the prefetch and the consuming fetch() (each call brings its own TTL — there is no global default to set):
// In-session: warm a screen up to 60s before it's mounted.
await prefetch('https://api.example.com/items/42', {
headers: { prefetchKey: 'item-42' },
prefetchCacheTtlMs: 60_000,
});
// Consume — same TTL so the cache hit doesn't get skipped.
const res = await fetch('https://api.example.com/items/42', {
headers: { prefetchKey: 'item-42' },
prefetchCacheTtlMs: 60_000,
});For cross-launch prefetches, the TTL is persisted alongside the request in the MMKV queue, so the next cold start honors it:
await prefetchOnAppStart('https://api.example.com/feed', {
prefetchKey: 'home-feed',
prefetchCacheTtlMs: 5 * 60_000, // 5 minutes
});For native-side registration, both platforms accept the TTL as a named arg:
// Android — registerPrefetch is @JvmOverloads
AutoPrefetcher.registerPrefetch(
context = this,
url = "https://api.example.com/feed",
prefetchKey = "feed",
headers = mapOf("Accept" to "application/json"),
prefetchCacheTtlMs = 300_000.0, // Double, in ms
)// iOS — use the extended @objc selector with the trailing prefetchCacheTtlMs:
NitroAutoPrefetcher.registerPrefetch(
withURL: "https://api.example.com/feed",
prefetchKey: "feed",
headers: ["Accept": "application/json"],
method: nil, bodyString: nil, bodyBytes: nil, bodyFormData: nil,
timeoutMs: nil, followRedirects: nil,
prefetchCacheTtlMs: NSNumber(value: 300_000)
)Notes:
- Omitting the option keeps the historical 5-second behavior. There's no backward-incompat.
- A long TTL amplifies the "Stale prefetches after deploys" risk below — bump
prefetchKeyon schema changes regardless of TTL. - A value
<= 0disables cache hits for that key —getResultIfFresh/hasFreshResultcheckage <= maxAgeMs, so any positive age fails.
Fetching tokens for cross-start prefetches
A prefetchOnAppStart entry is replayed by native code, before JS runs. That's the whole point — but it means there's no JS runtime around to mint a fresh access token. The package solves this with registerTokenRefresh: you describe a refresh endpoint and how to map its response into headers, and the native bootstrap calls it on cold start before replaying the queue.
import {
registerTokenRefresh,
prefetchOnAppStart,
} from 'react-native-nitro-fetch';
// 1. Tell nitro how to mint a fresh token at cold start.
registerTokenRefresh({
target: 'fetch', // or 'all' to also cover WebSockets
url: 'https://api.example.com/oauth/token',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: longLived }),
responseType: 'json',
mappings: [
{ jsonPath: 'access_token', header: 'Authorization', valueTemplate: 'Bearer {{value}}' },
],
onFailure: 'useStoredHeaders', // fall back to last-known good headers if refresh fails
});
// 2. Schedule the prefetches that need that header.
await prefetchOnAppStart('https://api.example.com/feed', {
prefetchKey: 'home-feed',
// No Authorization here — it'll be injected by the token refresh response.
});On every subsequent cold start, the native bootstrap will:
1. Read the refresh config from encrypted prefs (NitroFetchSecureAtRest). 2. Call the refresh URL on a background thread. 3. Map the JSON response into headers (Authorization: Bearer ey...) and, if configured, into the request body / form-data. 4. Merge those values into every queued prefetch and fire them.
Inject the token into the JSON body or form-data
Headers are the default destination, but the same refresh response can also be written into a prefetch's JSON body or a multipart form-data field. Add bodyMappings / formDataMappings alongside mappings — each is independent, so one refreshed value can land in a header, the body, and a form field at once. This is the `fetch` prefetch path only.
registerTokenRefresh({
target: 'fetch',
url: 'https://api.example.com/oauth/token',
method: 'POST',
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: longLived }),
responseType: 'json',
mappings: [
{ jsonPath: 'access_token', header: 'Authorization', valueTemplate: 'Bearer {{value}}' },
],
// JSON body: sets a (possibly nested) dot-path key in the prefetch's bodyString
bodyMappings: [
{ jsonPath: 'access_token', bodyPath: 'auth.token' },
],
// form-data: replaces (or appends) a part by name
formDataMappings: [
{ jsonPath: 'access_token', field: 'token' },
],
});A JSON-body prefetch of { "deviceId": "d-1" } is then replayed as { "deviceId": "d-1", "auth": { "token": "ey..." } }, and a form-data prefetch gains/overwrites a token part. Caveats:
bodyMappingsonly rewrites a prefetch that already has a JSON-object body — it won't synthesize a body on a GET or form-data request, and a non-JSON body is left untouched.formDataMappingsonly applies to prefetches that already send form-data — it won't turn a JSON/GET request into a multipart one.- Mappings are matched per-prefetch by body shape, so a single shared config fans out across a JSON prefetch and a form-data prefetch without cross-contaminating them.
- For
responseType: 'text', the body/form equivalents oftextHeaderarebodyTextPath/formDataTextField.
Putting the token in the URL
The native auto-prefetcher merges refreshed values into headers (and, via bodyMappings / formDataMappings, into the JSON body or form-data — see above), but never into the URL. The stored URL is replayed verbatim. If your backend requires the token in the URL (e.g. a signed query string), you have two options:
Option A — store the URL with the token already in it. The token will be the one captured at scheduling time. Re-call prefetchOnAppStart whenever it rotates:
function refreshHomeFeedPrefetch(token: string) {
return prefetchOnAppStart(
`https://api.example.com/feed?token=${encodeURIComponent(token)}`,
{ prefetchKey: 'home-feed' },
);
}This is fine for tokens with multi-day TTLs. It's the wrong fit for short-lived ones because the next cold start will replay the previous token.
Option B — use a `compositeHeaders` mapping and have the backend accept the header form. If you can change the server to read the token from a header instead of the query string, you keep the cold-start refresh path working without any JS code on launch.
registerTokenRefresh({
target: 'fetch',
url: 'https://api.example.com/oauth/token',
method: 'POST',
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: longLived }),
mappings: [
{ jsonPath: 'access_token', header: 'X-Token', valueTemplate: '{{value}}' },
],
});If neither option works for you (the URL itself must contain a fresh value, e.g. an HMAC of a fresh nonce), then the request fundamentally cannot be prefetched on cold start — there's no JS runtime to compute the new URL. Use an in-session prefetch from your splash screen instead.
Without token refresh
If you don't register a refresh config, the bootstrap reuses whatever headers were stored at prefetchOnAppStart time. That works fine for tokens that outlive the app's typical kill/restart cycle. See `docs-website/docs/token-refresh.md` for the full reference (composite headers, plain-text response bodies, onFailure modes).
Gotchas
- No `prefetchKey` → throws. The error is literal:
prefetch requires a "prefetchKey" header. BothprefetchandprefetchOnAppStartenforce this. - Mismatched keys → cold cache. A prefetch with
prefetchKey: 'home'and a fetch withprefetchKey: 'home-feed'are unrelated. The URL alone is not the cache key. - Android wiring missing.
prefetchOnAppStartwrites silently; only the missingApplication.onCreateline gives it away. Double-check it. - Prefetch loops. The native side doesn't throttle. Don't call
prefetchOnAppStartfor fifty endpoints — you're just slowing down boot. - POST/PUT prefetches. Fully supported —
methodand body (string,bodyBytes, FormData) are persisted alongside the URL and replayed exactly. The cache lookup is still byprefetchKey, not by request body, so only schedule POST prefetches for endpoints where replaying the same payload returns the same response (idempotent or read-modeled-as-write). - Stale prefetches after deploys. If your backend changes shape, old cached responses can hit the new client. Bump the
prefetchKey(e.g. include a schema version). The default 5-second TTL limits the blast radius; raising it viaprefetchCacheTtlMs(see "Configuring cache TTL" above) widens the window and makes aprefetchKeybump more important.
Pointers
- Source: `packages/react-native-nitro-fetch/src/fetch.ts`, search for
prefetch - Android bootstrap: `packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt`
- iOS bootstrap: `packages/react-native-nitro-fetch/ios/NitroBootstrap.mm`
- End-to-end example: `example/src/screens/PrefetchScreen.tsx`
- Long-form docs: `docs-website/docs/prefetch.md`
- Related: `axios-adapter.md`, `network-inspector.md`
react-native-nitro-text-decoder
Mental model
A small package with one job: provide a JSI-backed TextDecoder for React Native that's roughly 50× faster than the built-in JS shim expo ships with. The decode work happens in C++ on the JS thread with no bridge round-trip.
It's deliberately a thin wrapper:
- One class —
TextDecoder. - UTF-8 only (no
'utf-16le', no'iso-8859-1', etc.). - No
TextEncoder(useBuffer.from(str, 'utf8')if you need encoding). - No
install()polyfill. If you want a global, see the recipe below.
This package is also a peer dep of react-native-nitro-websockets, which uses it internally to decode incoming text frames. So if you already have nitro WebSockets installed, the package is on your device — just import the class.
Why use this
- ~50× faster than the JS shim. On large WebSocket frames or
arrayBuffer()bodies the difference shows up in profiles immediately — the JS shim allocates and walks UTF-8 byte by byte; this one calls into C++. - JSI-backed, no bridge. The decode call is a direct JSI invocation; there's no async hop or serialisation cost.
- Available everywhere. The shim Hermes ships is incomplete on older RN versions. This is the same class on every device, every OS, every engine.
- Reused across packages.
react-native-nitro-websocketsalready uses this internally. Installing it for your own decode work doesn't add new native code beyond what you're already shipping.
Setup
# pick your manager
npm install react-native-nitro-text-decoder react-native-nitro-modules
yarn add react-native-nitro-text-decoder react-native-nitro-modules
bun add react-native-nitro-text-decoder react-native-nitro-modules
cd ios && pod installreact-native-nitro-modules is a peer dep — install it once for the whole app.
API
import { TextDecoder } from 'react-native-nitro-text-decoder';
class TextDecoder {
constructor(label?: string, options?: { fatal?: boolean; ignoreBOM?: boolean });
readonly encoding: string;
readonly fatal: boolean;
readonly ignoreBOM: boolean;
decode(
input?: ArrayBuffer | ArrayBufferView,
options?: { stream?: boolean }
): string;
}What's actually supported:
| Knob | Behaviour |
|---|---|
label | 'utf-8' (default) and aliases ('utf8', 'unicode-1-1-utf-8'). Anything else throws. |
fatal: true | Invalid UTF-8 throws instead of yielding U+FFFD. |
ignoreBOM: true | A leading byte-order mark is dropped instead of being included. |
decode(buf, { stream: true }) | Holds incomplete code points until the next call so you can chunk binary input. |
Source: `packages/react-native-nitro-text-decoder/src/TextDecoder.ts`.
Recipes
Decode a binary fetch body
import { fetch } from 'react-native-nitro-fetch';
import { TextDecoder } from 'react-native-nitro-text-decoder';
const decoder = new TextDecoder('utf-8'); // create once, reuse
const res = await fetch('https://api.example.com/snapshot.bin');
const buf = await res.arrayBuffer();
const data = JSON.parse(decoder.decode(buf));Decode WebSocket binary frames
For text frames react-native-nitro-websockets already decodes for you (e.data is a string). You only need a decoder for binary frames:
import { NitroWebSocket } from 'react-native-nitro-websockets';
import { TextDecoder } from 'react-native-nitro-text-decoder';
const decoder = new TextDecoder('utf-8', { fatal: false, ignoreBOM: true });
const ws = new NitroWebSocket('wss://stream.example.com/ticks');
ws.onmessage = (e) => {
if (e.isBinary && e.binaryData) {
const text = decoder.decode(e.binaryData);
handleTick(JSON.parse(text));
} else {
handleTick(JSON.parse(e.data));
}
};Streaming decode across chunks
Pass { stream: true } for every chunk except the final one. The trailing call (with no argument) flushes any incomplete sequence:
const decoder = new TextDecoder('utf-8');
let acc = '';
for await (const chunk of someAsyncByteIterator) {
acc += decoder.decode(chunk, { stream: true });
}
acc += decoder.decode(); // flushLibraries that use globalThis.TextDecoder
Some libraries (protobufjs, msgpack-lite, certain WASM glue) reach for globalThis.TextDecoder. Don't swap it with the nitro implementation — nitro-text-decoder is UTF-8 only, so a library that constructs new TextDecoder('utf-16le') will throw under the polyfill, and monkey-patching a web-standard global hides the failure until production.
Prefer one of:
- Use the library's native output and decode the bytes yourself with the explicit nitro decoder import.
- Pass a decoder to the library if it accepts one as an option.
- Leave the library on Hermes' built-in
TextDecoder— it's slower, but only for that one library.
Gotchas
- Subarrays of larger buffers.
decoder.decode(uint8.subarray(0, 16))works, but if you pass the backingArrayBufferdirectly you'll decode from offset 0 of the parent. Pass the typed-array view, not its.buffer. - Forgetting `pod install`. The iOS build will fail to find the module. Always run
pod installafter adding the package. - Allocating a decoder per call. The constructor goes through JSI, the decode call is the cheap one. Hold a module-level instance and reuse it.
- Reaching for `TextEncoder`. Not exported here. Use
Buffer.from(str, 'utf8')frombuffer, or another package. - Non-UTF-8 encodings. Don't exist in this package. If you need them, decode manually or pull in
text-encoding.
Pointers
- Source: `packages/react-native-nitro-text-decoder/src`
- Used internally by: `packages/react-native-nitro-websockets/src/index.ts` (look for
utf8Decoder) - Pairs with: `using-websockets.md`
Using NitroWebSocket
Mental model
NitroWebSocket is a browser-shaped WebSocket class backed by libwebsockets + mbedTLS under the hood. It looks and feels like the standard WebSocket you're used to, with three concrete differences that exist for good reasons:
| Difference | Why |
|---|---|
Constructor takes a third headers argument | RN's built-in WebSocket can't send custom upgrade headers on iOS. This one can. |
readyState is a string ('OPEN', etc.) instead of a number | Easier to read, no constants to remember. |
Binary frames come back as e.binaryData: ArrayBuffer (with e.isBinary flag) | No binaryType setter; binary and text are always distinguishable. |
For TLS, the package ships its own Mozilla CA bundle and validates against it via mbedTLS. That means wss:// works the same on physical iOS devices, simulators, emulators, and old Android builds — you don't depend on the system trust store.
For migrating from React Native's built-in WebSocket, see the migration skill.
Why use NitroWebSocket
- Custom upgrade headers on every platform. Auth tokens, tenant IDs, client metadata — all on the upgrade request, including on iOS where RN's built-in WebSocket doesn't support headers at all.
- First-class binary frames. No
Blobround-trip, nobinaryTypetoggle — binary and text are distinguished bye.isBinary. - Native UTF-8 decoding for text frames via
react-native-nitro-text-decoder— text payloads arrive as JS strings without you doing any decoding. - Bundled CA trust store. Mozilla's
cacert.pemis compiled in via mbedTLS, sowss://behaves identically on physical devices, simulators, and old Android builds. - Pre-warmable. Pair with
prewarmOnAppStartto have the connection alreadyOPENbefore React Native boots. - Inspector-aware. When
NetworkInspectoris enabled, everyNitroWebSocketautomatically records its open / messages / close into the inspector log.
Setup
Install three packages — the WebSocket class, the fetch package (the inspector lives there and NitroWebSocket autoregisters with it when present), and the text decoder (it's used internally to decode incoming text frames):
npm install \
react-native-nitro-websockets \
react-native-nitro-fetch \
react-native-nitro-text-decoder \
react-native-nitro-modules
cd ios && pod installreact-native-nitro-modules is the shared Nitro runtime; the other three are independent packages you'll use throughout the app. react-native-nitro-fetch and react-native-nitro-text-decoder are technically declared as peer deps of react-native-nitro-websockets, but installing them explicitly makes the dependency obvious in your package.json and prevents version drift on bun / yarn workspaces.
Why nitro-fetch even if you only use WebSockets?NitroWebSocketdoes atry { require('react-native-nitro-fetch').NetworkInspector } catch {}at load time and silently no-ops if it's missing. Installing the fetch package gives you in-app WebSocket recording for free; skipping it just means you won't see WS entries in the inspector.
API at a glance
import { NitroWebSocket } from 'react-native-nitro-websockets';
import type {
WebSocketMessageEvent,
WebSocketCloseEvent,
} from 'react-native-nitro-websockets';
class NitroWebSocket {
constructor(
url: string,
protocols?: string | string[],
headers?: Record<string, string>,
);
// State (all read-only)
readonly readyState: 'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED';
readonly url: string;
readonly protocol: string;
readonly bufferedAmount: number;
readonly extensions: string;
// Handlers — assignment, not addEventListener
onopen: (() => void) | null;
onmessage: ((e: WebSocketMessageEvent) => void) | null;
onclose: ((e: WebSocketCloseEvent) => void) | null;
onerror: ((error: string) => void) | null;
// Methods
send(data: string | ArrayBuffer): void;
close(code?: number, reason?: string): void;
}
type WebSocketMessageEvent = {
data: string; // decoded UTF-8 (empty when binary)
isBinary: boolean;
binaryData?: ArrayBuffer; // present iff isBinary
};Source: `packages/react-native-nitro-websockets/src/index.ts`.
Recipes
Do not swap `globalThis.WebSocket`. Monkey-patching globals breaks devtools, hot reload, and third-party libraries that reach into the runtime assuming the spec shape. Always import NitroWebSocket explicitly at the call sites where you want the native implementation — this also keeps your grep history honest about where the nitro socket is used.ws:// echo client
import { NitroWebSocket } from 'react-native-nitro-websockets';
const ws = new NitroWebSocket('ws://localhost:8080/echo');
ws.onopen = () => ws.send('hello');
ws.onmessage = (e) => console.log('text:', e.data);
ws.onclose = (e) => console.log('closed', e.code, e.reason);
ws.onerror = (err) => console.warn('ws error', err);wss:// with auth headers
The constructor's third argument is the reason most teams reach for this package:
const ws = new NitroWebSocket(
'wss://stream.example.com/feed',
['v1.feed.proto'], // optional subprotocols
{
Authorization: `Bearer ${token}`,
'X-Tenant': 'acme',
},
);The headers go on the upgrade request via libwebsockets' LWS_CALLBACK_CLIENT_APPEND_HANDSHAKE_HEADER hook.
Binary frames in and out
ws.onmessage = (e) => {
if (e.isBinary && e.binaryData) {
const view = new Uint8Array(e.binaryData);
handleBinary(view);
} else {
handleText(e.data);
}
};
// Sending: ArrayBuffer → binary frame, string → text frame
ws.send('hello');
const buf = new Uint8Array([0x01, 0x02, 0x03, 0x04]).buffer;
ws.send(buf);Lifecycle in a React screen
NitroWebSocket instances aren't garbage-collected when a component unmounts. Always close in cleanup:
function FeedScreen() {
useEffect(() => {
const ws = new NitroWebSocket('wss://stream.example.com/feed');
ws.onmessage = (e) => /* ... */;
return () => ws.close();
}, []);
return <View />;
}For long-lived sockets that should outlive a single screen, hoist the instance to a module variable or context.
Differences from WebSocket (cheat sheet)
Standard WebSocket | NitroWebSocket | |
|---|---|---|
readyState | numeric (0–3) | string ('OPEN' etc.) |
| Custom upgrade headers | not supported on iOS | ✅ third constructor arg |
| Binary frames | binaryType toggle | e.isBinary + e.binaryData |
addEventListener | yes | no — use property assignment |
Blob payloads | yes | no — pass an ArrayBuffer |
| TLS roots | OS trust store | bundled Mozilla CA via mbedTLS |
| Pre-warming | n/a | yes (prewarm skill) |
For migrating an existing app, see `migrate-from-rn-ws.md`.
Gotchas
- Numeric `readyState` checks.
if (ws.readyState === 1)is always false here. Use'OPEN'. - `addEventListener` not implemented. Property assignment only.
- Reading `e.data` for binary frames. It's the empty string. Always check
e.isBinaryfirst. - Sending a `Blob` or `Buffer`. Throws. Convert:
send(uint8.buffer.slice(uint8.byteOffset, uint8.byteOffset + uint8.byteLength)). - Forgetting `ws.close()` in `useEffect` cleanup. The native socket stays alive, the JS object stays referenced, and you slowly leak.
- Multiple sockets to the same URL. Allowed. But pre-warming only adopts the first one — subsequent constructors open fresh connections.
Pointers
- Source: `packages/react-native-nitro-websockets/src/index.ts`
- C++ side: `packages/react-native-nitro-websockets/cpp/HybridWebSocket.cpp`
- Bundled CA: `packages/react-native-nitro-websockets/android/src/main/cpp/cacert.pem`
- Working example: `example/src/screens/WebSocketScreen.tsx`
- Long-form docs: `docs-website/docs/websockets.md`
- Related: `websocket-prewarm.md`, `migrate-from-rn-ws.md`, `network-inspector.md`
Pre-warming WebSockets
Mental model
A WebSocket handshake on a cold start has to wait for: TCP connect, TLS handshake, HTTP upgrade, server-side auth. On a slow network and a TLS endpoint, that's easily 500–1500 ms — and it can't even begin until React Native has loaded enough to run your new WebSocket() line.
The pre-warmer cuts that delay to roughly zero by doing two things:
1. Persist intent. You tell it once (after login, say) that "this URL should be open on every cold start". It writes the URL + headers to native storage. 2. Native bootstrap. On the next launch, before the JS engine even runs, native code reads the queue and opens the connection on a background C++ service thread. Anything the server pushes during boot is buffered. 3. JS adoption. Later, when your screen runs new NitroWebSocket(sameUrl, ...), the constructor finds the warm wsi, takes ownership (lws_set_wsi_user), and replays buffered messages onto your onmessage handler.
The user code is ordinary — no await, no special "warm socket" type. The pre-warmer is a side-channel optimisation; if it doesn't kick in (first install, no queue entry, URL mismatch), construction falls back to opening fresh.
This is for the next launch, not the current one. Pre-warming and immediately constructing a NitroWebSocket does nothing useful.
Why pre-warm
- The TLS handshake is the slowest part of opening `wss://` — pre-warming runs it on a background thread before React Native finishes booting, so the JS code that constructs the socket gets an already-
OPENconnection. - Server messages received during boot are buffered. If your backend pushes a snapshot the moment the socket connects, you don't lose it — the JS handler replays it after adoption.
- Zero JS-side complexity. Your screen still calls
new NitroWebSocket(url, ...); the adoption is invisible to your code. - Survives app restarts. Schedule the pre-warm once after sign-in and every subsequent cold start gets the win.
- Combines with token refresh so pre-warmed sockets still authenticate correctly after long idle periods.
Setup
iOS — automatic
iOS auto-bootstraps via +load in `packages/react-native-nitro-websockets/ios/NitroWSAutoBootstrap.mm`. Nothing to wire.
Android — one line in Application.onCreate
import com.margelo.nitro.nitrofetchwebsockets.NitroWebSocketAutoPrewarmer
class MainApplication : Application(), ReactApplication {
override fun onCreate() {
super.onCreate()
NitroWebSocketAutoPrewarmer.prewarmOnStart(this)
loadReactNative(this)
}
}Reference: `example/android/app/src/main/java/nitrofetch/example/MainApplication.kt`.
If you skip this on Android, the JS API silently writes to disk and nothing on the native side ever reads it back.
API
import {
prewarmOnAppStart,
removeFromPrewarmQueue,
clearPrewarmQueue,
} from 'react-native-nitro-websockets';| Function | Behaviour |
|---|---|
prewarmOnAppStart(url, protocols?, headers?) | Persist this entry. Synchronous; no return value. Replaces an existing entry with the same URL. |
removeFromPrewarmQueue(url) | Drop one entry. No-op if it isn't there. |
clearPrewarmQueue() | Wipe the queue. |
Source: `packages/react-native-nitro-websockets/src/prewarm.ts`.
Recipes
Schedule a pre-warm after sign-in
import { prewarmOnAppStart } from 'react-native-nitro-websockets';
function onLoginSuccess(token: string) {
prewarmOnAppStart(
'wss://stream.example.com/feed',
['v1.feed.proto'],
{
Authorization: `Bearer ${token}`,
'X-Client': 'mobile',
},
);
}Adopt the warm connection
The screen that opens the WebSocket doesn't need to know whether the connection is warm or cold — it constructs NitroWebSocket the same way. The native layer hands over the warm wsi if the URL matches.
import { NitroWebSocket } from 'react-native-nitro-websockets';
const ws = new NitroWebSocket(
'wss://stream.example.com/feed', // ← must match exactly
['v1.feed.proto'],
{ Authorization: `Bearer ${token}` },
);
ws.onopen = () => {
// May fire immediately on adoption (the connection is already open).
};
ws.onmessage = (e) => {
// Includes buffered messages from before JS was ready.
if (e.isBinary) handleBinary(e.binaryData!);
else handleText(e.data);
};Adoption via explicit NitroWebSocket call sites
Pre-warmed connections are adopted the moment your code calls new NitroWebSocket(url, ...) with a matching URL. The adoption happens on the native service thread — you don't have to wait for "open" yourself.
import { NitroWebSocket } from 'react-native-nitro-websockets';
const ws = new NitroWebSocket('wss://stream.example.com/feed', ['v1.feed.proto']);
// ↑ adopts the warm connection if the URL matches the prewarm queueLibraries that accept an injectable constructor (socket.io-client, centrifuge-js, and similar) can be pointed at NitroWebSocket the same way — pass it as the library's WebSocket option so its internal new WebSocket(...) becomes new NitroWebSocket(...). Don't swap globalThis.WebSocket; it breaks devtools and hot reload and hides which code paths actually use the native socket.
Tear down on logout
import { clearPrewarmQueue, removeFromPrewarmQueue } from 'react-native-nitro-websockets';
function onLogout() {
clearPrewarmQueue();
}
// Or selectively:
removeFromPrewarmQueue('wss://stream.example.com/feed');If you forget, the next cold start tries to reconnect with a stale auth header.
Refresh tokens before replay
If your stored auth header expires, register a token-refresh config (see docs-website/docs/token-refresh.md). The native bootstrap calls the refresh endpoint first, then replays the queue with the fresh headers.
Gotchas
- URL mismatch kills adoption.
wss://example.com/feedandwss://example.com/feed/(trailing slash) are different. Match them character-for-character. - Wiring missed on Android. No crash, no log — just silently no pre-warm. The single line in
Application.onCreateis the difference between "works" and "did nothing". - Pre-warming the current launch. It only helps the next cold start. There's nothing to do for the launch you're currently in.
- Stale headers. Whatever you stored is what gets used. Either rotate quickly enough that they stay fresh, or wire token refresh.
- Too many entries. Each entry opens a real socket on cold start. Stick to one or two streams that actually matter.
- `onopen` race. If the warm socket is already
OPENby the time you assignonopen, the JS class still fires it for you (it buffers internally). Just assign your handlers synchronously after construction and don't worry about it.
Pointers
- JS: `packages/react-native-nitro-websockets/src/prewarm.ts`
- Android bootstrap: `packages/react-native-nitro-websockets/android/src/main/java/com/margelo/nitro/nitrofetchwebsockets/NitroWebSocketAutoPrewarmer.kt`
- iOS bootstrap: `packages/react-native-nitro-websockets/ios/NitroWSAutoBootstrap.mm`
- C++ singleton: `packages/react-native-nitro-websockets/cpp/WebSocketPrewarmer.hpp`
- Working example: `example/src/screens/WebSocketScreen.tsx`
- Related: `using-websockets.md`, `migrate-from-rn-ws.md`
Related skills
FAQ
Should nitro-fetch replace globalThis.fetch for Axios?
nitro-fetch instructs developers to pin a custom Axios adapter explicitly and not route Axios by swapping globalThis.fetch. The adapter is the supported last-mile hook that keeps axios instance features intact.
Which Axios features survive the nitro-fetch adapter?
nitro-fetch preserves interceptors, axios.create() instances, transformRequest, cancelToken, baseURL, responseType, and validateStatus when the custom adapter delegates to react-native-nitro-fetch.
Is Nitro Fetch safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.