Diego Betto's Blog
Photo by Ed 259 on Unsplash

Diego Betto · September 9, 2026 · 4 min di lettura

React Router: why route.lazy blocks the URL/Outlet swap

route.lazy in React Router blocks the URL/Outlet swap until the chunk is ready: why it happens, and how Component: lazy(...) gets an immediate swap.

Condividi:XLinkedInFacebookWhatsApp

Converting every route in an app from route.lazy to Component: lazy(...) looks like a syntax detail — both are ways to code-split per route. In practice they change when React Router considers a navigation “done”, and that shows up immediately in the UI: with route.lazy, clicking a sidebar link starts the top loading bar but the old page stays on screen until the chunk finishes downloading. With Component: lazy(...), the route swaps right away.

The symptom

An app with a sidebar and about thirty routes, all defined like this:

const routes = [
  {
    path: "/settings",
    lazy: () => import("./routes/settings"),
  },
  // ...27 more routes like this
];

Each module exports Component (and optionally loader). Clicking a sidebar item correctly starts the global progress bar (the typical NProgress-style bar hooked into navigation events) — but the Outlet keeps showing the previous page for the entire duration of the chunk download. Only once the chunk is ready do the URL and the content change together, all at once. On a slow connection or with heavy chunks, the app appears to not respond to the click at all.

Why it happens: route.lazy is part of data loading

route.lazy isn’t syntactic sugar over React.lazy. It’s a hook into the router’s data loading cycle: when the user navigates, the router has to resolve route matching, run loaders, and obtain the destination Component before it can commit the transition. If Component comes from a dynamic import inside lazy, that import is part of the chain the router waits on.

This is intentional, not a bug: the router guarantees that URL and content change atomically. It never wants to show a new URL with the old state still on screen, nor an empty Outlet while waiting for data. The progress bar exists exactly for this — it’s the “loading” signal while the router waits for everything (route module + loader) to be ready before updating the UI.

ℹ️ It's not a bundler problem

The chunk downloads at the same speed in both cases. What changes is when the router decides to update the URL and the Outlet relative to the download — not how long the download itself takes.

The fix: move lazy from the router to React

Component: lazy(...) moves code splitting out of the router’s data loading cycle and into React itself, using React.lazy plus a Suspense boundary:

import { lazy, Suspense } from "react";

const Settings = lazy(() => import("./routes/settings"));

const routes = [
  {
    path: "/settings",
    Component: () => (
      <Suspense fallback={<PageSkeleton />}>
        <Settings />
      </Suspense>
    ),
  },
];

Now the router doesn’t wait for anything: as soon as the user clicks, it matches the route right away, updates the URL, and replaces the Outlet with the synchronous component defined above. That component mounts Settings inside a Suspense, which shows fallback until the lazy chunk resolves. The URL/Outlet swap is immediate; loading the chunk becomes a state inside the already-changed page, not a block before the page changes.

Applied across all twenty-eight routes, the pattern becomes: the route stays registered synchronously (no lazy at the route level), and only the heavy component gets wrapped in lazy + Suspense.

When route.lazy is still the right choice

It’s not a pattern to throw away — it has a real advantage: if the module also exports a loader, route.lazy downloads it together with the component, avoiding the waterfall of “first download the component, then the component discovers it needs data and requests it.” For routes where the data is needed to render anything meaningful (a detail page that makes no sense to show without its data, or an authorization guard), blocking the transition until everything is ready is often the correct behavior, not a flaw.

💡 Consiglio

Rule of thumb: use route.lazy for routes where showing an intermediate “empty” state would be worse than waiting (guards, data-dependent pages). Use Component: lazy(...) for regular navigation inside a persistent layout (sidebar, tabs), where immediate responsiveness to the click matters more than the content arriving a beat later.

Summary

  • route.lazy is part of the data loading pipeline: the router waits for the module before updating the URL and Outlet, so navigation appears blocked until the chunk arrives.
  • Component: lazy(...) moves loading inside React: the router commits the transition immediately, and Suspense handles the chunk loading as internal state of the already-changed page.
  • The choice depends on what would happen if the page rendered before the data/code is ready: if that’s acceptable, prefer the immediate swap; if not, let the router wait.
Condividi:XLinkedInFacebookWhatsApp
Diego Betto

Written by

Diego Betto

Co-Founder & CTO at PAPION. Senior full-stack engineer specializing in React, TypeScript, Node.js, and application security.