# Speeding up Expensify's networking with NitroFetch

> How I migrated Expensify to NitroFetch's networking stack, enabled prefetching for large startup queries ahead of JavaScript bundle execution, reduced average request duration by 15-30%, and improved critical startup request completion by more than 200ms.

Canonical article: https://margelo.com/blog/speeding-up-expensifys-networking-with-nitro-fetch
LLM text: https://margelo.com/blog/speeding-up-expensifys-networking-with-nitro-fetch/llms.txt
Author: Christoph Pader
Category: Performance
Tags: React Native, Nitro, Networking, Performance, Expensify
Published: 2026-08-25
Reading time: 18 minutes

## Article

Expensify is a huge app that involves a lot of network requests in a regular user journey. Because reading and sending data over the internet is fundamental to nearly every app, not only Expensify, it is important to have a solid networking library under the hood that performs requests quickly and reliably. It is the backbone of the app's functionality and the foundation of its user experience.

[Video: Expensify app demo: Creating an expense to be reimbursed](https://margelo.com/videos/expensify-demo.mp4)

The [`fetch()` API](https://fetch.spec.whatwg.org/) is built into [React Native](https://reactnative.dev/docs/network) and is widely used. It provides an easy-to-understand API, but it could be faster and lacks features that can make apps more performant.

This is where [`react-native-nitro-fetch`](https://fetch.margelo.com/docs/getting-started) comes into play. NitroFetch is a drop-in replacement for `fetch()` that adds features such as query [prefetching](https://fetch.margelo.com/docs/prefetch) and [streaming](https://fetch.margelo.com/docs/streaming), along with a faster [WebSockets](https://fetch.margelo.com/docs/websockets) implementation.

For most apps, migrating to NitroFetch is straightforward: replace the global networking primitives and rebuild the native app. Expensify followed that same simple path. However, most real life production apps have more complex requirements. In this article I want to lay out what it takes to integrate NitroFetch in a complex production app that includes custom networking and authentication logic and requries features like certificate pinning. I will also cover how to enable startup prefetching for critical requests, to improve app startup time.

In Expensify, we could improve performance drastically by using NitroFetch, improving app startup time by around 200ms and average request duration by around 15-30%. More details about the performance improvement will be covered in the [Performance results](#the-performance-results) section.

## Why NitroFetch?

`react-native-nitro-fetch` is a [WHATWG](https://fetch.spec.whatwg.org/)-compatible Fetch implementation built with [Nitro Modules](https://nitro.margelo.com/). Depending on the request and network conditions, I observed around **15-30% faster request speed** than with the built-in React Native networking stack. It routes requests through [Cronet](https://developer.android.com/develop/connectivity/cronet) and [URLSession](https://developer.apple.com/documentation/foundation/urlsession), while keeping the familiar `fetch()` API.

Since NitroFetch provides the same API as `fetch()`, most apps needs few to no code changes. NitroFetch can be enabled app-wide using a [global replacement](https://fetch.margelo.com/docs/global-replace), or selectively for specific requests. Prefetching is an optional optimization that can be added for requests known before startup. NitroFetch can fetch requests ahead of JavaScript bundle execution, which I will cover in more detail later.

## An overview of the migration in Expensify

Expensify's React Native app has years of networking behavior spread across API middleware, authentication, file uploads, [certificate pinning](https://cheatsheetseries.owasp.org/cheatsheets/Pinning_Cheat_Sheet.html), offline-first recovery, and startup hydration. Because NitroFetch implements the familiar `fetch()` API, switching the underlying networking stack was still a small change, I used the same global replacement explained above.

Additional challenges came from integrating optional startup prefetching with existing API middleware, custom request building, authentication, and validation logic. Those changes preserved Expensify's own custom networking behavior; they are not prerequisites for using NitroFetch.

The complete integration landed in [Expensify/App#97069](https://github.com/Expensify/App/pull/97069): it globally enabled `react-native-nitro-fetch` on native and moved the critical [`ReconnectApp`](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/src/libs/Prefetch/PrefetchQueries/index.native.ts) request ahead of the JavaScript runtime. Most of its 56 changed files relate to that app-specific prefetching and validation work rather than the Fetch replacement.

## Installing and setting up NitroFetch

The base installation is small and is covered in the [Getting Started installation section](https://fetch.margelo.com/docs/getting-started#installation):

```sh
npm install react-native-nitro-fetch react-native-nitro-modules
```

Because both packages contain native code, the app must be rebuilt after installation. On iOS that also means updating the [CocoaPods](https://cocoapods.org/) dependencies. If the project already uses Nitro Modules, keep `react-native-nitro-modules` and Nitrogen on compatible versions; Expensify upgraded those together as part of the migration.

For Expensify, selectively migrating call sites would have left the app running two separate networking stacks and made it harder to tell which path a request took. NitroFetch's global replacement kept the migration simple and consistent.

NitroFetch can be used in two ways: You can either explicitly import and use `fetch` from NitroFetch wherever you need it:

```ts
import {fetch} from 'react-native-nitro-fetch';

const response = await fetch('https://example.com');
```

Alternatively, you can replace every `fetch()` call with NitroFetch's implementation. To do so, you can add a native-only [polyfill](https://fetch.margelo.com/docs/global-replace). The [API reference](https://fetch.margelo.com/docs/api) covers the matching `Headers`, `Request`, and `Response` objects. Expensify's native polyfill replaces all four global primitives together:

[View src/polyfills/NitroFetch.ts on GitHub (lines 5-10)](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/src/polyfills/NitroFetch.ts#L5-L10)

Expensify also ships on the web, where the browser's `fetch` implementation is already the preferred one. If your codebase also targets the web, add an empty `NitroFetch.web.ts` file, as in Expensify's [web stub](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/src/polyfills/NitroFetch.web.ts), so the same entry import is safe on every platform while the native bundler selects the real polyfill.

Then import the NitroFetch polyfill as the first module in your application's entry file:

[View index.js on GitHub (lines 1-25)](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/index.js#L1-L25)

Order matters here. A dependency can capture `globalThis.fetch` while its module is evaluated. Replacing the global later would create a mixed app where some modules use NitroFetch and others retain the old implementation.

## Registering prefetching of critical startup requests on the next app start

Prefetching is an optional optimization that can be added for requests known before startup. These requests can be fetched ahead of JavaScript bundle execution and therefore improve app startup time.

NitroFetch can persist selected requests and prefetch them natively on the next app start. Expensify sends one of two requests on every app start, needed to hydrate the data shown after launch: `OpenApp` on the first launch after the user logs in or `ReconnectApp` on subsequent cold starts. Since `OpenApp` only runs on the very first app launch, it cannot be prefetched, but `ReconnectApp` can be. Without NitroFetch, these requests cannot begin until the React Native runtime has started, the JavaScript bundle has loaded, and the app's request pipeline has prepared it.

With [`prefetchOnAppStart(...)`](https://fetch.margelo.com/docs/prefetch#auto-prefetch-on-app-start), the native app can start that request even before React Native is ready and execute it in parallel. When JavaScript eventually makes the matching request, NitroFetch either serves the native response cache or falls back to a normal network request. The optimization does not remove work; it hides network latency by firing the request as early as possible, thus once the user is in the app they don't get to experience the latency.

Expensify already routes API traffic through a shared request utility. That is the right integration point for startup prefetching, so the rest of the codebase can keep calling its existing API layer.

The production request path prepares the prefetch metadata, registers eligible requests for the next start, and then continues through the normal `fetch()` call:

[View src/libs/HttpUtils.ts on GitHub (lines 8-101)](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/src/libs/HttpUtils.ts#L8-L101)

The link between the prefetched request and the later JavaScript request is a [`prefetchKey`](https://fetch.margelo.com/docs/prefetch#basics). Both sides must use exactly the same key or NitroFetch correctly treats them as unrelated requests.

> The `prefetchKey` is the only thing that NitroFetch uses to match a prefetched response to the later `fetch()` call; it does not compare the URL or request body. The URL registered with `prefetchOnAppStart(...)`, including dynamic path segments and query parameters, and its body must therefore describe the same effective request the app will make on the next start. If those inputs can change, they need to be known when the prefetch is registered, and the registration needs to be updated when they change. Otherwise, the later `fetch()` can consume a response produced for different inputs.

`preparePrefetchRequest()` only returns a key for requests on a small allowlist. In Expensify's case, it's just the startup hydration request `ReconnectApp`. Every other request still uses NitroFetch, but it follows the ordinary network path and is not persisted for the next launch.

The allowlist itself contains only `ReconnectApp`:

[View src/libs/Prefetch/PrefetchQueries/index.native.ts on GitHub (lines 1-8)](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/src/libs/Prefetch/PrefetchQueries/index.native.ts#L1-L8)

`preparePrefetchRequest()` turns an allowlisted request into account-scoped prefetch metadata:

[View src/libs/Prefetch/preparePrefetchRequest/index.ts on GitHub (lines 1-26)](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/src/libs/Prefetch/preparePrefetchRequest/index.ts#L1-L26)

This is worth being conservative about. Starting fifty requests before JavaScript loads does not make an app start fifty times faster. It competes for bandwidth, CPU, and server capacity at the most sensitive point in launch. Pick the request that unlocks the first useful screen.

## Using prefetched requests on next app start

Prefetching requests across app launches has two parts: first, JavaScript stores the request for a future launch, then native code prefetches it on that launch.

> The JavaScript `prefetchOnAppStart(...)` path starts helping from the second cold launch because JavaScript must run once to seed the queue. An unauthenticated request whose URL and headers are already known can run on the first launch: call `AutoPrefetcher.registerPrefetch(...)` before `prefetchOnStart(...)` on Android, or call `NitroAutoPrefetcher.registerPrefetch(...)` from `application(_:didFinishLaunchingWithOptions:)` on iOS. Both native APIs write to the same persisted queue as `prefetchOnAppStart(...)`.

Expensify's `registerPrefetchOnAppStart` wrapper ignores ordinary requests, configures token refresh for eligible ones, and stores the prefetch without making startup depend on the optimization:

[View src/libs/Prefetch/registerPrefetchOnAppStart/index.ts on GitHub (lines 7-86)](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts#L7-L86)

The `registerPrefetchTokenRefresh()` function will be explained in more detail in the ["Adding Expensify's authentication to startup prefetching"](#adding-expensifys-authentication-to-startup-prefetching) section.

The real implementation adds the key to the normal request as well. On the next cold start, the sequence is:

1. Native code reads the stored request.
2. Native code starts the `ReconnectApp` request while React Native is loading.
3. The JavaScript app boots and reaches its normal API call.
4. The matching `prefetchKey` connects that call to the native response.
5. If the prefetched response is ready, `fetch()` returns it from the cache. If the prefetch is still in flight, `fetch()` waits for that same request instead of canceling it or starting another one. NitroFetch starts a new network request only when there is neither a fresh cached response nor a matching in-flight prefetch.

## Setting up prefetching in the native code

On iOS, for prefetching to set up, there is nothing to do. NitroFetch registers the startup hook from the library and automatically [prefetches requests on app start](https://fetch.margelo.com/docs/prefetch#native-side-prefetch-registration-first-run-prefetching), that have been registered with `prefetchOnAppStart(...)` in the previous app launch.

On Android, you need to call [`AutoPrefetcher.prefetchOnStart(this)`](https://fetch.margelo.com/docs/prefetch#android-setup) in [`Application.onCreate()`](https://developer.android.com/reference/android/app/Application#onCreate\(\)) before React Native is loaded. The `AutoPrefetcher` will then prefetch requests registered with `prefetchOnAppStart(...)` in the previous app launch. Expensify wires this into `MainApplication.kt`:

[View android/app/src/main/java/com/expensify/chat/MainApplication.kt on GitHub (lines 17-90)](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/android/app/src/main/java/com/expensify/chat/MainApplication.kt#L17-L90)

The failure is deliberately non-fatal. Prefetching is best-effort and is an optimization. A missing or corrupt stored request should never prevent the app from opening, and a failed prefetch should always leave the ordinary request path available.

## Benefits of prefetching over the normal fetch

The difference is easiest to see on a startup timeline. With the normal React Native Fetch, the app must finish loading the JavaScript bundle before JavaScript can create the request. With NitroFetch startup prefetching, native code begins the stored request as the app starts, in parallel with the JavaScript bundle load. Once JavaScript makes its normal `fetch()` call with the matching `prefetchKey`, it adopts the prefetched response and receives the critical data earlier.

**Cold-start request timeline:** NitroFetch starts the native prefetch while the JavaScript bundle loads, so JavaScript can adopt the response when it is ready. With vanilla React Native Fetch, the network request starts only after JavaScript is ready.

## Adding Expensify's authentication to startup prefetching

Executing network requests before the JS bundle loads creates a problem: Expensify's startup request is authenticated, but the usual JavaScript authentication pipeline does not exist yet. To solve this, I use the ["token-refresh"](https://fetch.margelo.com/docs/token-refresh) mechanism.

Persisting the current access token with the request would work until the token expires. After that, cold starts would prefetch a request that was fast but unauthorized.

NitroFetch supports a native [`registerTokenRefresh`](https://fetch.margelo.com/docs/token-refresh#register-the-refresh-config) configuration for this case. Expensify registers its `Authenticate` request through the same integration and maps the refreshed token back into the startup request. The `responseType`, [`formDataMappings`](https://fetch.margelo.com/docs/form-data), and `onFailure` options are documented in the [token-refresh response-mapping section](https://fetch.margelo.com/docs/token-refresh#response-mapping):

[View src/libs/Prefetch/registerPrefetchOnAppStart/index.ts on GitHub (lines 5-75)](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts#L5-L75)

The native bootstrap cannot call Expensify's existing JavaScript `Authenticate()` function because the JS runtime does not exist yet, so it needs a serialized description of the request it can execute independently.

## Keeping Expensify's persisted prefetches account-scoped

Because Expensify enabled persisted startup prefetching, its native queue can outlive both the JavaScript runtime and the user session. A `prefetchKey` that includes just the request type, such as `ReconnectApp`, could allow one account's cached response to be considered for another account. As shown in `preparePrefetchRequest()` above, Expensify scopes the key to the account instead.

I also clear both the stored prefetch queue and its token-refresh configuration during logout and authentication transitions. Expensify centralizes this using NitroFetch's [`clearTokenRefresh`](https://fetch.margelo.com/docs/token-refresh#js-helpers) and [`removeAllFromAutoprefetch`](https://fetch.margelo.com/docs/api#removefromautoprefetch--removeallfromautoprefetch):

[View src/libs/Prefetch/clearPrefetchOnAppStart/index.ts on GitHub (lines 4-16)](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts#L4-L16)

For Expensify's full local-state reset, implemented in [`clearOnyxAndSeedFullReconnect`](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/src/libs/actions/clearOnyxAndSeedFullReconnect.ts), I clear before the reset and again after it. A request can be registered in the gap while old credentials are still visible, so a single cleanup is not enough.

The rule is straightforward: if persisted networking state contains an identity, clear it anywhere that identity can change.

## Features specific to Expensify

The global NitroFetch replacement was the easy part. The remaining work in Expensify came from requirements that many apps do not have, and none of it is required for a standard migration.

### Certificate pinning

Expensify already had certificate pinning, but Android NitroFetch uses Cronet. Cronet owns its [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security) stack; it does not automatically inherit pins configured through React Native's [OkHttp](https://square.github.io/okhttp/) client or Android's [Network Security Configuration](https://developer.android.com/privacy-and-security/security-config) (`network_security_config.xml`).

Expensify therefore added an app-specific [certificate-pinning implementation](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/android/app/src/main/java/com/expensify/chat/CertificatePinning.kt) and a [NitroFetch patch](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/patches/react-native-nitro-fetch%2B1.5.4.patch) that applied its public-key pins to NitroFetch's Cronet engines. The implementation supports monitor-only reporting and enforcement, and covers ordinary requests, streaming requests, startup prefetches, and token refreshes.

This was not a NitroFetch API compatibility issue or a normal migration step. It was an Expensify-specific integration assumption exposed by changing the underlying transport. Apps with custom transport-level behavior should audit DNS, proxies, cookies, redirects, TLS, certificate pinning, observability, and debugging in addition to request and response bodies.

## How I tested the final rollout

I added focused unit tests around authentication prefetching, request preparation, sessions, and the shared network layer in Expensify's [`AuthPrefetchTest`](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/tests/unit/AuthPrefetchTest.ts), [`PreparePrefetchRequestTest`](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/tests/unit/PreparePrefetchRequestTest.ts), [`SessionUtilsTest`](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/tests/unit/SessionUtilsTest.ts), and [`NetworkTest`](https://github.com/Expensify/App/blob/7718b5e7efe6286367a49f17a0ab15d7dbd49009/tests/unit/NetworkTest.tsx). The release was then exercised across native Android, native iOS, mobile web, and desktop web. The QA pass covered offline behavior and high-traffic accounts. Web remained on browser Fetch, but it still mattered: platform-specific files and shared request changes can break a web build even when the transport itself is native-only.

I also kept the optimization fail-open. Registration errors are logged, cleanup errors do not strand sign-out, and a prefetch miss becomes a normal request. Performance work at app startup should reduce latency without becoming a new availability dependency.

## The performance results

The migration produced two separate performance improvements. NitroFetch reduced the duration of ordinary requests by using a faster native networking stack. Startup prefetching produced another win by moving the critical `ReconnectApp` request earlier, so networking overlapped React Native and JavaScript startup. The second improvement was not just a faster request, it changed when that request began.

### Regular requests took 15-30% less time

There is no honest single millisecond figure for all of Expensify's network traffic. A small cached request, a large file upload, and a request on a mobile connection have very different baselines. Across request types and network conditions, however, NitroFetch consistently reduced average request duration by roughly **15-30%**. See the NitroFetch [benchmark methodology](https://fetch.margelo.com/docs/benchmarks) for the library-level comparison.

The normalized view below compares relative request duration and visualizes the upper end of the measured improvement. Lower is better:

**Benchmark chart:**

- React Native Fetch: 100% (baseline)
- NitroFetch: 70% (1.4x vs baseline)

This comparison is deliberately normalized rather than expressed in invented milliseconds. The absolute saving depends on the endpoint and connection; the 15-30% reduction is the useful result that held across those conditions.

### The critical startup request finished much earlier

For startup, I have end-to-end measurements. I measured the span from app start until the critical startup request completed, comparing the main release with the PR that enabled native prefetching. These figures include the benefit of starting `ReconnectApp` before the JavaScript bundle is ready.

I benchmarked warm app starts by alternating between release binaries with and without NitroFetch, running each binary 100 times on an iPhone 14 Pro for iOS and a Samsung Galaxy S10e for Android over a stable 200 Mbit/s network connection.

This side-by-side recording shows the visible startup difference between the two iOS binaries:

[Video: Warm app start comparison with and without NitroFetch prefetching](https://margelo.com/videos/expensify-nitrofetch-benchmark.mp4)

On iOS, P50 fell from **1,676 ms** to **1,409 ms**, a reduction of **267 ms (15.93%)**:

**Benchmark chart:**

- Main release · P50: 1676 ms (baseline)
- With startup prefetch · P50: 1409 ms (1.2x vs baseline)

On Android, P50 fell from **3,223.5 ms** to **2,573 ms**, a reduction of **650.5 ms (20.18%)**:

**Benchmark chart:**

- Main release · P50: 3223.5 ms (baseline)
- With startup prefetch · P50: 2573 ms (1.3x vs baseline)

P50 represents the typical launch. It improved on both platforms, with the larger reduction appearing on Android.

## What I learned

The final integration confirmed that adopting NitroFetch is simple. The additional work came from preserving Expensify's existing networking behavior and adding startup prefetching, not from replacing `fetch()`.

For most apps, installing the packages, replacing the global primitives, and rebuilding the native app are the whole migration. For apps with custom networking behavior like Expensify, these lessons keep the optional integration work predictable:

- **Migrate at a shared boundary.** A central request layer lets the codebase keep its public API and gives you one place to add prefetch metadata, logging, and fallbacks.
- **Replace the whole primitive consistently.** `fetch`, `Headers`, `Request`, and `Response` belong together, and the replacement must run before dependencies capture the old globals.
- **Keep prefetching narrow.** Choose the request that unlocks the first useful screen rather than replaying the whole application workload at launch.
- **Treat native prefetch configuration as persisted, account-scoped data.** Version it where necessary, include identity in its key, and clear it on every auth boundary.
- **Design for prefetch failure.** The normal request remains the source of truth; prefetching is an optional head start.
- **Recheck transport-level security.** On Android, NitroFetch's Cronet engine does not inherit certificate pins from React Native's OkHttp client. If an app has custom transport behavior, verify each assumption against the new engine.
- **Test user journeys, not only HTTP calls.** The bugs that matter appear as a stuck onboarding screen or a failed receipt upload, not as a failing `fetch` unit test.

***

**And that's it! 🎉**

The final code still looks simple at the call site. Existing features call the same API layer, and that layer still calls `fetch()`. Underneath, native networking starts the most important request earlier, refreshes authentication without waiting for JavaScript, isolates the cached response to the current account, and falls back safely when the optimization cannot help.

That is what a good NitroFetch migration should look like: a small, familiar change at the call site, with any app-specific behavior handled at shared boundaries underneath.

You can read the complete implementation and discussion in [Expensify/App#97069](https://github.com/Expensify/App/pull/97069) and the library documentation at [fetch.margelo.com](https://fetch.margelo.com/docs/getting-started).

[https://github.com/Expensify/App/pull/97069](https://github.com/Expensify/App/pull/97069)
