Home/Learn/React/Lazy Routes, Suspense and Loading Transitions

Lazy Routes, Suspense and Loading Transitions

Intermediate
Routing

Splitting by route is the highest-value code splitting there is: a visitor to the home page should not download the admin panel or the code editor.

Overview

Every route in a single bundle means the first visitor downloads the entire application before seeing anything. Route-level splitting is the natural boundary — each route becomes its own chunk, fetched when someone navigates to it. React.lazy and Suspense express this in a few lines. The details that separate a good implementation from a janky one are prefetching on intent so the chunk is already there when the click happens, and handling a chunk that fails to load after a deploy.

Splitting a Route

lazy plus Suspense, and what to put in the fallback.

lazy + Suspense, boundary inside the layout
const Admin = lazy(() => import('./features/admin/AdminPage'))
const Editor = lazy(() => import('./features/editor/EditorPage'))

<Route
  path="/admin"
  element={
    <Suspense fallback={<PageSkeleton />}>
      <Admin />
    </Suspense>
  }
/>

// Put the Suspense boundary inside the layout, not around it —
// otherwise the header and nav disappear during every transition
// and the whole page flashes.

// Worth splitting: admin areas, the code editor, chart libraries,
// rich text editors, PDF generation, anything most visitors never open.
// Not worth splitting: small components on the critical path — an
// extra request costs more than the bytes saved.

// With React Router's own lazy, the route module itself is split,
// loader included:
{ path: 'admin', lazy: () => import('./routes/admin') }

Prefetching

Load the chunk on intent, so the navigation itself is instant.

Prefetch on hover, focus, or idle
// The chunk downloads when the user hovers or focuses the link —
// typically 200-300ms before the click lands
function PrefetchLink({ to, load, children }) {
  const prefetch = () => { load() }
  return (
    <Link to={to} onMouseEnter={prefetch} onFocus={prefetch}>
      {children}
    </Link>
  )
}
<PrefetchLink to="/admin" load={() => import('./features/admin/AdminPage')}>

// Or prefetch on idle for a route you know most users reach:
useEffect(() => {
  const id = requestIdleCallback(() => import('./features/editor/EditorPage'))
  return () => cancelIdleCallback(id)
}, [])

// useTransition keeps the current page interactive during the
// transition instead of dropping straight to a skeleton:
const [isPending, startTransition] = useTransition()
startTransition(() => navigate('/admin'))
{isPending && <TopProgressBar />}

When a Chunk Fails

The deploy-time failure that looks like a broken app, and its fix.

Handle the post-deploy chunk failure
// You deploy. Hashed filenames change and old chunks are removed.
// A user with the page still open clicks a lazy route:
//   "Failed to fetch dynamically imported module"
// The app appears broken, and a refresh silently fixes it — which
// is why this often goes unreported and unfixed for months.

// Catch it and offer the reload
class ChunkErrorBoundary extends Component {
  state = { failed: false }
  static getDerivedStateFromError(error) {
    return { failed: /dynamically imported module|Loading chunk/i.test(error.message) }
  }
  render() {
    if (this.state.failed) return (
      <div role="alert">
        <p>A new version is available.</p>
        <button onClick={() => location.reload()}>Reload</button>
      </div>
    )
    return this.props.children
  }
}

// Better still: keep old chunks available for a while after deploy,
// and detect a new build version to prompt a reload proactively.

Key Points to Remember

  • 1Route-level splitting is the highest-value split — visitors do not download features they never open
  • 2Place the Suspense boundary inside the layout so the shell does not flash on every transition
  • 3Prefetch a chunk on hover or focus so the navigation itself feels instant
  • 4useTransition keeps the current page interactive instead of dropping immediately to a fallback
  • 5After a deploy, old chunks disappear and lazy imports fail — catch that error and offer a reload

Interview Questions

Sign in to ask Aria
1

What should and should not be code-split in a React app?

Medium
2

Why does placing Suspense around the whole layout cause a flash?

Medium
3

Why do dynamic imports fail after a deploy, and how do you handle it?

Hard

Ask Aria about Lazy Routes, Suspense and Loading Transitions

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…