How to Inspect React Native Network Requests Without a Laptop
If you've debugged network traffic in a React Native app, you know the setup tax. Tether the device, run a desktop debugger. Or install a CA certificate, trust it, route through a proxy, and hope nothing is certificate-pinned.
Both work at your desk. Neither works when the person who found the bug is holding their own phone and isn't a developer.
This guide covers a different approach: put the DevTools inside the app. We'll wire up @axonpack/expo-devtools, catch requests from fetch, axios and an in-app browser, and then look at how the interception actually works — because that part is more interesting than it sounds.
Install
npx expo install @axonpack/expo-devtools react-native-safe-area-context react-native-webview
Both of those are peer dependencies: the overlay uses safe-area insets, and response previews render HTML and images in a WebView.
There's no native code in the package — it's pure JS/TSX, with no config plugin and no prebuild. It works in Expo Go.
Wire it up
Three steps. First, one shared client instance:
// devtools.ts
import { createDevtoolsClient } from '@axonpack/expo-devtools';
export const devtools = createDevtoolsClient();
Then start it at launch — before anything makes a request:
// index.ts
import { registerRootComponent } from 'expo';
import App from './App';
import { devtools } from './devtools';
devtools.init();
registerRootComponent(App);
The ordering matters and it's the one easy mistake here. init() installs the patches; anything that fires before it runs is simply not recorded. This is also why the API is a plain factory rather than a React provider — a provider would invite mounting it in the component tree, which is already too late.
Finally, mount the floating button anywhere in your tree:
// App.tsx
import { DevtoolsOverlay } from '@axonpack/expo-devtools';
export default function App() {
return (
<>
<YourApp />
<DevtoolsOverlay />
</>
);
}
That's the whole integration. Drag the button wherever you like, tap it, and both the Network and Console tabs are already recording.
What you get in the Network tab
Each request is a row with method, status, duration, timestamp, response type, source and size. In-flight requests show an amber PENDING, so "still waiting" is visually distinct from "finished" — which matters more than you'd think when you're chasing a hang.
Tap a row and a detail panel slides up:
| Tab | What's in it |
|---|---|
| Headers | Request and response headers, each value with its own copy button |
| Payload | What you sent, as an explorable tree rather than a wall of text |
| Preview | Response pretty-printed and colour-coded; images and HTML render for real |
| Response | The raw body, in full, never truncated |
| Timing | When it started and how long it took |
[IMAGE: detail panel on the Headers tab, copy buttons visible]
The two features that change your workflow
Copy as cURL. The ⋮ menu exports any captured request as a ready-to-paste cURL command or fetch snippet. This sounds like a convenience and is actually the most useful thing in the package, because it changes what a bug report is. Instead of prose describing a failure, the report arrives as the exact failing request, runnable on the backend engineer's machine. Nobody reproduces anything.
The sandbox. Reopen any captured request as an editable one — change the method, URL, query params, headers, cookies, auth or body, hit Send, see the real response.
This is how you answer "is it the API or the app?" in about twenty seconds. Does it still 401 without the auth header? Does it work with the body you meant to send? Three requests, no code change, no rebuild.
[IMAGE: the sandbox with an edited request and its response]
Throttling
Slow 3G, Fast 3G, Fast 4G, Offline, or a custom throughput and latency — applied on the device without touching Wi-Fi. There's a user-agent override too (iPhone, Android, desktop, Googlebot).
Every captured request records the conditions it ran under, which sounds like a detail until you change the throttle mid-session and your log becomes a mix of two different worlds with no way to tell the rows apart.
[VIDEO: switching to Slow 3G and watching durations change]
Catching WebView traffic
A <WebView> runs its own JavaScript engine — WKWebView on iOS, Android WebView on Android — so nothing in your app's JS context can see into it. It needs two props:
import { WebView } from 'react-native-webview';
import { devtools } from './devtools';
<WebView
source={{ uri: 'https://example.com' }}
injectedJavaScriptBeforeContentLoaded={devtools.getWebViewInjectedJavaScriptBeforeContentLoaded(
'checkout-webview'
)}
onMessage={(event) => devtools.handleWebViewMessage(event)}
/>;
Use injectedJavaScriptBeforeContentLoaded, not injectedJavaScript. The latter runs after the page's own scripts have already fired, so their requests escape. This is the single most common way to wire this up and see nothing.
Declare the source name up front:
export const devtools = createDevtoolsClient({
network: { webviewSources: ['checkout-webview'] },
});
Now here's a nice bit of type design. That array is captured with a TypeScript 5 const type parameter, so the literal strings flow into the parameter types of the WebView helpers:
devtools.getWebViewInjectedJavaScriptBeforeContentLoaded('checkout-webview'); // ✅
devtools.getWebViewInjectedJavaScriptBeforeContentLoaded('unknown-webview'); // ❌ compile error
The same list is a runtime allowlist, so an undeclared source is dropped. Without the type-level check, a typo presents as "WebView logging just doesn't work" with nothing anywhere to explain why. Worth stealing as a pattern for any string-keyed registry.
Rows then show up tagged WebView::[checkout-webview] in both tabs, and the Source filter chips separate them from your app's own traffic.
Three optional props extend throttling into the page:
ref={devtools.getWebViewRef('checkout-webview')} // speed changes reach an open page
userAgent={devtools.getWebViewUserAgent()} // applies the UA override for real
onShouldStartLoadWithRequest={devtools.shouldAllowWebViewRequest} // blocks navigation when Offline
The Console tab
Everything the app logs, on the device. Warnings on yellow rows, errors on red, with live counts in the toolbar so you can see whether anything went wrong while you weren't looking.
- Each logged argument gets its own cell, so a message and the object beside it don't run together.
- Objects and arrays arrive collapsed — tap to expand, level by level.
- Errors show the message on the row and the full stack when tapped.
- The same message repeated becomes one row with a count.
- Filter by level or source, or search the text.
[IMAGE: Console tab with an expanded object and an expanded error stack]
The REPL
There's a > prompt that evaluates JavaScript on the device. Names are suggested as you type, results come back as the same explorable tree, and promises show as pending then fill in — so fetch('/api/me').then((r) => r.json()) works as you'd expect.
One thing to understand, because it surprises people: your imported names aren't reachable by default. A browser console can see a page's scope; a Metro bundle compiles your modules into private closures, so there's nothing for the prompt to resolve authStore against. In a dev build you get $modules('auth') to list loaded modules and $m('src/stores/auth') to grab one. For short stable names, hand them over explicitly:
createDevtoolsClient({
console: { context: { store, queryClient } },
});
The prompt defaults to __DEV__ — it compiles and runs whatever is typed, so it stays out of release builds unless you pass console: { repl: true } deliberately.
How the interception works
This is the part worth understanding even if you never use the package, because "capture every request" is not one hook. It's three, and each covers a case the others are blind to.
1. fetch. Expo installs its own native fetch (expo/winter/fetch) by default. The old whatwg-fetch polyfill was built on XMLHttpRequest, so patching XHR used to catch fetch traffic for free. The native implementation doesn't route through XHR at all. If you only patch XHR on a modern Expo app, every fetch call is invisible. This trips up a lot of homegrown loggers.
2. XMLHttpRequest. Patching open and send on the prototype. This is what catches axios and most HTTP client libraries, whose React Native adapters are built on XHR rather than fetch.
3. WebView injection. The injected script patches fetch and XHR inside the page and relays each request over postMessage. Relative URLs get resolved against location.href, since real pages request plenty of relative paths.
All three write into one store: an in-memory ring buffer capped at 200 entries, published over Expo's EventEmitter and read by the UI through useSyncExternalStore.
Configuration
Every option is optional and the defaults are what most apps want.
| Option | Type | Default | What it does |
|---|---|---|---|
enabled | boolean | true | Master switch; false makes init() a no-op |
network.includeFetch | boolean | true | Capture fetch |
network.includeXmlHttpRequest | boolean | true | Capture XHR — this is what catches axios |
network.webviewSources | string[] | undefined | Named WebViews allowed to report in |
console.capture | boolean | true | Mirror console.* into the Console tab |
console.repl | boolean | __DEV__ | Show the > prompt |
console.context | Record<string, unknown> | undefined | Extra names the prompt can resolve |
Is it safe to ship?
Nothing is patched and nothing is recorded until init() runs, so leaving the code in a production build costs you nothing. The decision is a runtime one:
devtools.init(); // or: if (__DEV__) devtools.init();
That property is the reason it's useful to QA at all. A tool that only works in development builds isn't in the build QA is actually testing.
What it can't do
- WebView pages can't be fully throttled. Images, stylesheets and scripts the browser fetches on its own bypass the injected patches and go out at full speed.
- No DNS/TCP/TLS waterfall. Those numbers aren't available to JavaScript on-device, so the Timing tab says so rather than rendering a plausible-looking breakdown. Invented numbers are worse than an admitted gap — you'd spend an afternoon optimising against fiction.
- The REPL can't reach bundled names on its own, for the closure reason above.
- 200 entries. Older requests age out of the buffer.
Try it
The repo has a runnable example/ app that fires fetch, XHR, axios, uploads and a real WebView page, plus a console screen with a button for every awkward case — circular references, class instances, errors, and a 600-message flood.
npx expo install @axonpack/expo-devtools
MIT licensed and free. Source and issues: github.com/axonpack/axonpack
If you try it and something's missing, open an issue — that's the fastest way to get it built.