vM.

Make Next.js Navigation Feel Fast on Slow Networks

Author
Vishal Maurya
Published on
Reading time
4 min read

Overview

You click a link and nothing happens for a second or two. Then the page changes all at once. On a fast laptop with a local server you never notice, but on a throttled mobile connection it feels broken. The server work may not be slow; the interface simply gives no feedback while waiting.

This post covers the tools the App Router gives you to show something immediately.

1. Test the Real Conditions First

Prefetching is largely disabled in development, so navigation in next dev does not represent production behavior. Build and run production mode, then throttle the network in browser dev tools and add CPU throttling for mobile. Measure before changing anything.

2. Add loading.tsx for Instant Fallbacks

A loading.tsx file next to a page.tsx wraps the page in a Suspense boundary and shows its content immediately while the page's data loads:

// app/orders/loading.tsx
export default function Loading() {
  return (
    <div aria-busy="true">
      <div className="h-8 w-48 animate-pulse rounded bg-gray-200" />
      <div className="mt-4 h-64 animate-pulse rounded bg-gray-200" />
    </div>
  );
}

The fallback should resemble the final layout to avoid jumps. Because it is part of the route segment, the shared layout stays interactive while the fallback shows.

3. Use Suspense for Finer Granularity

loading.tsx blocks the whole page content on the slowest fetch. Move slow pieces into their own async components and wrap them:

// app/orders/page.tsx
import { Suspense } from 'react';

async function RecentOrders() {
  const orders = await fetch('https://api.example.com/orders').then((r) => r.json());
  return <ul>{orders.map((o: { id: string }) => <li key={o.id}>{o.id}</li>)}</ul>;
}

export default function OrdersPage() {
  return (
    <section>
      <h1>Orders</h1>
      <Suspense fallback={<p>Loading orders...</p>}>
        <RecentOrders />
      </Suspense>
    </section>
  );
}

The heading renders at once and only the list waits. Start independent fetches in parallel, for example with Promise.all, instead of awaiting them sequentially, which creates waterfalls.

4. Understand Prefetching

In production, Link prefetches routes as they enter the viewport. For dynamic routes, the prefetched portion generally covers the shared layout down to the nearest loading boundary rather than the full page data. That is one reason a loading.tsx matters: without it, the navigation may have to wait for the whole dynamic render before anything changes. Exact prefetch behavior has varied across versions, so check the Link documentation for yours.

You can control it per link:

import Link from 'next/link';

<Link href="/reports" prefetch={false}>Reports</Link>

Disabling prefetch saves bandwidth for rarely used links but removes the head start. Use it deliberately for heavy or long lists of links.

Even with a fallback, a clicked link may give no sign of activity while the transition is pending. Recent Next.js versions provide useLinkStatus from next/link for this. It must be used in a component rendered inside a Link:

'use client';

import Link, { useLinkStatus } from 'next/link';

function Spinner() {
  const { pending } = useLinkStatus();
  return pending ? <span role="status" aria-label="Loading">...</span> : null;
}

export function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
  return (
    <Link href={href}>
      {children}
      <Spinner />
    </Link>
  );
}

This hook is relatively new, so confirm it exists in your installed version. If it does not, use useTransition with router.push for programmatic navigation, and use useFormStatus or useActionState for forms.

6. Reduce What Has to Load

Indicators hide latency but do not remove it. Also reduce the work:

  • Cache or revalidate data that does not need to be fresh on every request.
  • Keep client bundles small by pushing "use client" down to leaves.
  • Use next/image with proper sizes so images do not block perceived rendering.
  • Avoid heavy synchronous work in layouts, since they sit in the path of every navigation.

7. Check Accessibility

Loading states should be announced. Use aria-busy, a visible text label, or a status role for spinners, and respect reduced-motion preferences for animation.

Conclusion

Perceived speed in the App Router comes from immediate feedback: loading boundaries close to slow data, parallel fetching, sensible prefetching, and pending state on the clicked element. Test in a production build under throttling, not in the dev server.

If your Next.js app feels slow in the field, I can help profile navigation and data flow and turn the findings into concrete fixes. Contact me.

Additional Resources

  • loading.js file convention
  • Link component reference
  • React Suspense reference