10 Aug, 2026 · 9 min read · React Native · Performance

Making React Native Pages Feel Fast.. Is Not Enough

A loading spinner makes the wait feel shorter. It does not make the wait shorter. Here are the strategies that actually shrink page load time in React Native, and the one that only hides it.

For a while, "improve this page's performance" and "add a loading screen" meant the same thing to me. The page felt slow, so I would drop in a skeleton, ship it, and move on. Users reported it felt snappier. Ticket closed. Yayy!

Except nothing about the page had gotten faster. Yes, the perceived performance improved as user reported but the technical performance, it did not change at all. The API still took the same 400ms. The user still waited 400ms. I had just given them something to look at while they waited.

That's not nothing - perceived performance is a real thing worth designing for. But it's a coping mechanism, not a fix. It hides the wait, it doesn't shrink it. Use it only when you know you cannot shrink the waiting time further.

Once I stopped treating "add a spinner" as the answer and started asking why the wait existed in the first place, three things actually moved the number: killing waterfalls, prefetching data before navigation, and splitting data store into independent modules instead of one big data store. This post covers those three.

Loading screens hide the wait, they don't shrink it

Let's first be very clear about what a loading screen is supposed to be.

A skeleton screen, a spinner or a shimmer effect - these change how long a wait feels, not how long it is. The API call underneath is exactly as slow as it was before you added it. You haven't removed any work, you've just given the user's attention somewhere to go while the work happens.

That's a legitimate UX tool. A skeleton that matches your layout genuinely feels better than a blank white screen and there is a real research behind the why.

But it is not the solution - no amount of spinner/skeleton turns a 900ms fetch into a 200ms one. If a page feels slow, the first question can't be "what do we show while it loads". It has to be "why does it take that long, and how much of that is actually necessary".

The rest of this post is what I found when I asked that second question instead of the first.

Kill the waterfall

The easiest way to make a page slow without meaning to: fetch A --> fetch B --> fetch C, where B and C never actually needed A's result in the first place.

This creeps in naturally. You write one useEffect, await the first call because that's how you write code top to bottom, then need a second value so you await again right below it. Nothing about it looks wrong in isolation. It's only wrong in aggregate, and aggregate is invisible until you look at a waterfall chart.

// Before - every call waits on the one before it,
// even though none of them depend on each other
async function loadCheckoutPage() {
  const items = await fetchItems();
  const address = await fetchUserAddress();
  const deviceFingerPrint = await getDeviceFingerPrint();
 
  return { items, address, deviceFingerPrint };
}
// total time: 120ms + 80ms + 60ms = 260ms
// After - fire everything that doesn't depend on
// another call's result at the same time
async function loadCheckoutPage() {
  const [items, address, deviceFingerPrint] = await Promise.all([
    fetchItems(),
    fetchUserAddress(),
    getDeviceFingerPrint(),
  ]);
 
  return { items, address, deviceFingerPrint };
}
// total time: max(120ms, 80ms, 60ms) = 120ms

Same three calls, same data, 140ms gone just from asking them at the same time instead of one after another. That's not a micro-optimization - that's over half the wait, deleted, with no new infrastructure.

The catch is that not everything qualifies. Some calls are genuinely sequential - one needs a value that only exists after another one finishes. Parallelizing those isn't possible without changing what the second call depends on. The actual skill here isn't "wrap everything in Promise.all" - it's telling apart calls that are sequential out of habit from calls that are sequential out of necessity, and only the first group is free performance.

Takeaway is that whatever can be parallelized should be parallelized.

Prefetch before the user asks

Killing the waterfall speeds up work that starts when the user lands on a screen. Prefetching goes a step earlier: start the work before they land, the moment you can predict they're about to.

Let's continue with the checkout example. The user is sitting on the cart screen and taps "Checkout" button. At that exact moment, you already know two things:

  1. they're headed to the checkout page, and
  2. you know what items they've selected.

You don't need the checkout screen to mount before you can start fetching checkout data - you can fire those same exact APIs the instant the button is pressed, in parallel with the navigation transition itself.

function useCheckoutPrefetch() {
  // ...prefetch logic
}
 
function CartScreen() {
  const prefetchCheckout = useCheckoutPrefetch();
 
  function handleCheckoutPress() {
    prefetchCheckout(items);
    navigation.navigate('Checkout', { items });
  }
 
  return <CheckoutButton onPress={handleCheckoutPress} />;
}
 
type OrderItem = {
  itemid: number;
  shopid: number;
  ...
}
 
function CheckoutScreen({ items }: { items: Array<OrderItem> }) {
  // if the prefetch already resolved, this returns
  // instantly from cache instead of firing a new request
  const { data } = useCachedQuery(['checkout', items], () => fetchCheckoutData(items));
 
  return <CheckoutContent data={data} />;
}

By the time the navigation transition finishes and CheckoutScreen mounts, the data is often already sitting in cache. The user never sees a loading state for it at all.

The obvious pushback: the nav transition is only 150-200ms anyway, is this worth the extra code path? Fair question, and on its own, no - as you just noticed in previous section, whatever can be parallelized, it should be parallelized. Using prefetching logic, the API response time is not changed BUT the API start time is changed. If that change is 50-100ms, then page can load 50-100ms faster.

The trade-off worth mentioning here: prefetching is a bet. If the user backs out instead of tapping through, you've fired a request that gets thrown away. That's usually a cheap bet - a wasted network call is far cheaper than a user staring at a spinner - but it's not free, and it's worth being deliberate about which screens are predictable enough to bet on.

Also, when it comes to prefetching you need to make sure that the API requests you are making are the same exact in order to fully utilize the prefetching benefits.

Stop gating the whole page behind the slowest call

Even after parallelizing, one problem remains: Promise.all only resolves once everything in it resolves. If the checkout page needs items, address and personalized recommendations, and the recommendations call happens to be slow that day, the entire page - including the items and address data that resolved in under 150ms - sits behind a spinner until the slowest of the three finally comes back.

The fix is to stop treating the page as one data-fetching unit. Split it by module, and let each module resolve - and render - independently.

// Before - one gate, page waits on the slowest module
function CheckoutPage({ items }: { items: Array<OrderItem> }) {
  const { data, isLoading } = useCachedQuery(['checkout', items], () => fetchCheckoutData(items));
 
  if (isLoading) {
    return <FullPageSpinner />;
  }
 
  const [items, address, recommendations] = data;
  return (
    <ScrollView>
      <Items data={items} />
      <Address data={address} />
      <Recommendations data={recommendations} />
    </ScrollView>
  );
}
// After - each module owns its own request and its own loading state
function CheckoutPage({ items }: { items: Array<OrderItem> }) {
  return (
    <ScrollView>
      <ItemsModule items={items} />
      <AddressModule />
      <RecommendationsModule items={items} />
    </ScrollView>
  );
}
 
function ItemsModule({ items }: { items: Array<OrderItem> }) {
  const { data, isLoading } = useCachedQuery(['items', items], () => fetchItems(items));
 
  if (isLoading) {
    return <ItemsSkeleton />;
  }
 
  return <Items data={data} />;
}
 
function AddressModule() {
  const { data, isLoading } = useCachedQuery(['address'], () => fetchUserAddress());
 
  if (isLoading) {
    return <AddressSkeleton />;
  }
 
  return <Address data={data} />;
}
 
function RecommendationsModule({ items }: { items: Array<OrderItem> }) {
  const { data, isLoading } = useCachedQuery(['recommendations', items], () =>
    fetchRecommendations(items),
  );
 
  if (isLoading) {
    return <RecommendationsSkeleton />;
  }
 
  return <Recommendations data={data} />;
}

Nothing here removes a single network call - the three requests still fire, still take exactly as long as they did before. What changes is that the items list, which resolves in 90ms, is no longer chained to the recommendations module, which resolves in 420ms. The user sees the top of the page almost immediately, and the rest fills in as it arrives.

This is the one with the most measurable payoff. Largest Contentful Paint (LCP) is measuring, more or less, "how long until the biggest above-the-fold thing shows up" - and once that thing is decoupled from your slowest, least-important API call, LCP drops to match whatever module actually paints it. Perceived UX improves too, but this time it is backed by a number that moved.

Putting it together

Loading screens were never the fix - they were the acknowledgment that I hadn't found one yet. The actual fixes were smaller and less visible than a skeleton screen:

  1. stop waiting on calls that don't depend on each other,
  2. start calls before the user finishes deciding to make them, and
  3. stop letting one slow module hold the rest of the page hostage.

None of these are special - Promise.all, firing a request on press-in instead of on mount, splitting one data-fetching component into three. What changed wasn't the tools, it was where I looked for the problem. Not "what do we show while we wait," but "which parts of this wait didn't need to happen".