How to implement React.lazy with route transitions

Symptom: After an animated page transition fires, the viewport goes blank for 200–800 ms, or the console logs Uncaught (in promise) ChunkLoadError: Loading chunk [hash] failed. The blank window appears even though the route URL updated correctly.

This failure mode lives at the intersection of two async systems: the transition library’s exit/enter animation lifecycle, and the React.lazy + Suspense chunk-loading lifecycle. Mis-aligning their timings causes React to discard the <Suspense> fallback before the dynamic import resolves, leaving the DOM empty.


Root cause analysis

The failure chain is deterministic:

  1. The user navigates. React Router updates location state.
  2. TransitionGroup or Framer Motion starts the exit animation on the current route node.
  3. When the exit animation duration elapses, the transition library unmounts the exiting component tree — including its wrapping <Suspense> boundary.
  4. The dynamic import() triggered by React.lazy is still in-flight (network latency, CDN miss, or slow 3G).
  5. React has no <Suspense> boundary left to suspend inside, so it throws or renders nothing.
  6. The enter animation starts on an empty subtree — blank viewport.

This is a specific failure pattern within implementing route-level code splitting in SPAs, where the <Suspense> boundary must outlive the transition’s exit phase.

The diagram below shows the two timelines and where they collide:

Transition exit vs chunk fetch timing mismatch Two horizontal timeline bars. The transition exit bar ends at 300ms. The chunk fetch bar extends to 550ms. The gap between 300ms and 550ms is highlighted as the blank-viewport window. 0 ms 150 ms 300 ms 450 ms 600 ms Transition exit animation (300 ms) Suspense unmounted Dynamic import() chunk fetch (0 – 550 ms) blank viewport CSS/JS Chunk

Exact config and CLI fix

Fix 1 — Extend transition timeout beyond the fetch window

The transition timeout must be longer than the worst-case chunk fetch duration. Set it to at least 800 ms in development (where chunks are uncompressed) and keep 300 ms for production builds with HTTP/2 and Cache-Control: immutable headers.

// React Router v6 + react-transition-group
// Requires: react-transition-group ^4.4, react-router-dom ^6.4

import React, { Suspense } from 'react';
import { useLocation } from 'react-router-dom';
import { CSSTransition, TransitionGroup } from 'react-transition-group';

const LazyDashboard = React.lazy(
  () => import(/* webpackChunkName: "route-dashboard" */ './Dashboard')
);

// Skeleton height must match the real route's bounding box to prevent CLS.
// Measure the production layout and hard-code min-height accordingly.
const RouteSkeleton = () => (
  <div
    className="route-skeleton"
    style={{ minHeight: '60vh', width: '100%', contain: 'layout' }}
    aria-busy="true"
    aria-label="Loading route"
  >
    <div style={{ height: '48px', background: 'var(--color-skeleton, #f0f0f0)', borderRadius: 4 }} />
    <div style={{ height: '400px', marginTop: 16, background: 'var(--color-skeleton-body, #fafafa)', borderRadius: 4 }} />
  </div>
);

export const TransitionRouter = ({ element }) => {
  const location = useLocation();

  return (
    <TransitionGroup component={null}>
      <CSSTransition
        key={location.pathname}
        timeout={800}        // Must exceed worst-case chunk fetch; tune down for prod
        classNames="route-fade"
        unmountOnExit        // Only unmounts AFTER timeout elapses
      >
        {/* Suspense must wrap the lazy component inside the CSSTransition
            so it is mounted for the full timeout duration */}
        <Suspense fallback={<RouteSkeleton />}>
          {element}
        </Suspense>
      </CSSTransition>
    </TransitionGroup>
  );
};

The critical constraint: <Suspense> must be inside the <CSSTransition> node, not outside it. If <Suspense> wraps <TransitionGroup>, the boundary is shared across all route nodes — a new route’s pending state will show the fallback on top of the exiting route instead of keeping the exiting content visible.

Fix 2 — Use React.startTransition to keep the current route visible

React 18’s startTransition marks the navigation as a non-urgent update. React continues to render the previous route until the lazy chunk resolves, then commits the new route atomically — no blank window:

// Vite 5+ project using React Router's useNavigate
import { startTransition } from 'react';
import { useNavigate } from 'react-router-dom';

export function NavLink({ to, children }) {
  const navigate = useNavigate();

  const handleClick = (e) => {
    e.preventDefault();
    // Wrapping in startTransition tells React: keep current UI
    // visible (as "stale") while the lazy chunk loads.
    startTransition(() => {
      navigate(to);
    });
  };

  return <a href={to} onClick={handleClick}>{children}</a>;
}

startTransition pairs directly with <Suspense>: React shows the previous route’s rendered output (not the fallback) while suspended, and only swaps once the new route is ready.

Fix 3 — Webpack 5 and Vite 5+ chunk naming and isolation

Unnamed chunks receive content-hashed filenames that change on every build, breaking the browser cache. Named chunks with stable filenames enable immutable caching — critical for keeping transition fetch times under 50 ms on repeat visits.

// Webpack 5 — webpack.config.js
// Pin route chunks to stable names so CDN caches survive deploys.
module.exports = {
  output: {
    // [contenthash:8] only changes when the chunk content changes
    chunkFilename: '[name].[contenthash:8].js',
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        routes: {
          test: /[\\/]src[\\/]routes[\\/]/,
          name(module) {
            // Derive stable name from file path
            const match = module.resource?.match(/routes[\\/](.+?)[\\/]/);
            return match ? `route-${match[1]}` : 'route-misc';
          },
          chunks: 'async',
          enforce: true,
        },
        // Exclude heavy animation libs from vendor chunk
        // to avoid blocking lazy evaluation with a shared bundle fetch
        vendor: {
          test: (m) =>
            /node_modules/.test(m.resource || '') &&
            !/react-transition-group|framer-motion/.test(m.resource || ''),
          name: 'vendor',
          chunks: 'all',
          priority: -10,
        },
      },
    },
  },
};
// Vite 5+ — vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // Stable chunk file names prevent cache busting on unrelated changes
        chunkFileNames: 'assets/[name]-[hash:8].js',
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // Keep animation libraries out of the shared vendor chunk
            if (/framer-motion|react-transition-group/.test(id)) return 'anim';
            return 'vendor';
          }
          if (id.includes('src/routes/')) {
            // Extract route name for readable chunk filenames
            const seg = id.match(/routes\/([^/]+)/)?.[1] ?? 'misc';
            return `route-${seg}`;
          }
        },
      },
    },
  },
});

Fix 4 — Preload the chunk for the current route

webpackPreload emits a <link rel="preload"> in the HTML document, instructing the browser to fetch the chunk immediately at high priority — before the transition even starts. Use it only for the chunk required on the current route. For speculative next-route chunks, use prefetch and preload strategies for critical routes instead.

// Webpack 5 magic comments for current-route preload
const LazyDashboard = React.lazy(() =>
  import(
    /* webpackChunkName: "route-dashboard", webpackPreload: true */
    './routes/Dashboard'
  )
);

// Vite 5+ equivalent: use vitePreload via @vitejs/plugin-react
// or manually add a modulepreload link in index.html for the critical route.

Step-by-step verification

  1. Network tab timing. Open DevTools > Network, filter by JS. Navigate between routes with throttling set to “Fast 3G”. Confirm that the route-[name].[hash].js fetch completes before the CSS transition’s timeout value (e.g., before 800 ms). If the chunk fetch extends beyond the timeout, either increase the timeout or add webpackPreload.

  2. CLS audit. Record a route transition with Lighthouse or the Performance panel. Expand the “Layout Shift” rows in the Experience track. CLS attributable to route transitions must be < 0.05. If the skeleton fallback does not match the incoming route’s height, you will see a large shift at the Suspense resolution point.

  3. Bundle stats verification (Webpack 5).

    npx webpack --json=stats.json && npx webpack-bundle-analyzer stats.json

    Confirm each route chunk appears as a distinct named node. If route-dashboard and route-settings merge into a single main bundle, the enforce: true flag is missing from the cacheGroups entry.

  4. Bundle stats verification (Vite 5+).

    npx vite build --reporter json > build-report.json

    Check that route-dashboard-[hash].js appears as a separate output file. If all routes remain in index-[hash].js, the manualChunks function is not matching the path — add a console.log(id) inside the function during a local build to inspect the actual module IDs.

  5. ChunkLoadError rate. Integrate a window.addEventListener('error', ...) listener that captures ChunkLoadError events and reports them via navigator.sendBeacon. In production, this rate must be 0% for routes that use webpackPreload or Vite’s module preload.

  6. INP check. Use PerformanceObserver with { type: 'event', buffered: true } to confirm that the click-to-navigation handler does not block the main thread for more than 200 ms. Transition handlers that synchronously read layout (e.g., getBoundingClientRect() during the exit phase) inflate INP significantly.


Edge cases and gotchas

Framer Motion AnimatePresence + React.lazy

Framer Motion’s <AnimatePresence> holds the exiting component in the DOM until its exit animation variant completes — similar to CSSTransition with unmountOnExit. The same timing constraint applies: wrap each child in its own <Suspense> boundary inside <AnimatePresence>, not outside.

// Framer Motion — correct Suspense placement
import { AnimatePresence, motion } from 'framer-motion';

<AnimatePresence mode="wait">
  <motion.div
    key={location.pathname}
    initial={{ opacity: 0 }}
    animate={{ opacity: 1 }}
    exit={{ opacity: 0 }}
    transition={{ duration: 0.3 }}
  >
    {/* Suspense inside the motion node, not wrapping AnimatePresence */}
    <Suspense fallback={<RouteSkeleton />}>
      <LazyRoute />
    </Suspense>
  </motion.div>
</AnimatePresence>

Using mode="wait" in Framer Motion is safer than the default mode="sync" because it guarantees the exit animation completes before the enter animation begins, preventing two lazy boundaries from competing for the same DOM space.

React.lazy inside React Server Components (Next.js App Router)

Server Components cannot suspend on the client — React.lazy must be called from a Client Component boundary. If a lazy import references a Server Component, the import resolves but React throws a hydration mismatch because the server-rendered HTML differs from the client-rendered lazy output.

Solution: mark the lazily loaded component with "use client" and ensure the <Suspense> boundary sits inside a Client Component tree, not at the Server/Client boundary itself.

Network-partitioned chunk fetch failure

On corporate networks with aggressive DLP proxies, chunk requests containing hash strings are occasionally blocked (the proxy mistakes the hash for an obfuscated URL). The ChunkLoadError manifests identically to a cache miss, but with status: 0 in performance.getEntriesByType('resource').

Mitigation: configure a retry wrapper around React.lazy:

// Vite 5+ compatible retry wrapper
const retryLazy = (factory, retries = 3, delay = 500) =>
  React.lazy(() =>
    factory().catch((err) => {
      if (retries === 0) return Promise.reject(err);
      return new Promise((resolve) =>
        setTimeout(() => resolve(retryLazy(factory, retries - 1, delay * 2)()), delay)
      );
    })
  );

const LazyDashboard = retryLazy(
  () => import(/* webpackChunkName: "route-dashboard" */ './routes/Dashboard')
);

This exponential-backoff wrapper is also effective for intermittent CDN cache misses at deploy time, before the edge cache warms up. For a comprehensive treatment of retry strategies and service-worker fallbacks, the preventing waterfall requests with dynamic import maps page covers the network-layer approaches that complement client-side retry logic.


FAQ

Why does the viewport go blank during a route transition when using React.lazy?

The transition library’s exit animation finishes and unmounts the <Suspense> boundary before the dynamic import promise resolves. React has no fallback left to render, so the DOM is empty until the chunk arrives. Fix this by placing <Suspense> inside the transition node and extending the transition timeout beyond the worst-case fetch duration, or by wrapping navigation in startTransition.

Does webpackPreload help with route transition chunk fetching?

Yes, but only for chunks required on the current route. webpackPreload emits a <link rel="preload"> tag so the browser fetches the chunk at high priority. For the next route’s chunks, use webpackPrefetch instead, which emits <link rel="prefetch"> during idle time.

How does React.startTransition interact with React.lazy and Suspense?

Wrapping router.push() in startTransition marks the navigation as non-urgent. React keeps the current route visible (showing stale content) while the lazy chunk resolves, and only commits the new route once the import promise settles — eliminating the blank-viewport window.