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:
- The user navigates. React Router updates location state.
TransitionGrouporFramer Motionstarts the exit animation on the current route node.- When the exit animation duration elapses, the transition library unmounts the exiting component tree — including its wrapping
<Suspense>boundary. - The dynamic
import()triggered byReact.lazyis still in-flight (network latency, CDN miss, or slow 3G). - React has no
<Suspense>boundary left to suspend inside, so it throws or renders nothing. - 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:
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
-
Network tab timing. Open DevTools > Network, filter by
JS. Navigate between routes with throttling set to “Fast 3G”. Confirm that theroute-[name].[hash].jsfetch completes before the CSS transition’stimeoutvalue (e.g., before 800 ms). If the chunk fetch extends beyond the timeout, either increase the timeout or addwebpackPreload. -
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. -
Bundle stats verification (Webpack 5).
npx webpack --json=stats.json && npx webpack-bundle-analyzer stats.jsonConfirm each route chunk appears as a distinct named node. If
route-dashboardandroute-settingsmerge into a singlemainbundle, theenforce: trueflag is missing from thecacheGroupsentry. -
Bundle stats verification (Vite 5+).
npx vite build --reporter json > build-report.jsonCheck that
route-dashboard-[hash].jsappears as a separate output file. If all routes remain inindex-[hash].js, themanualChunksfunction is not matching the path — add aconsole.log(id)inside the function during a local build to inspect the actual module IDs. -
ChunkLoadError rate. Integrate a
window.addEventListener('error', ...)listener that capturesChunkLoadErrorevents and reports them vianavigator.sendBeacon. In production, this rate must be0%for routes that usewebpackPreloador Vite’s module preload. -
INP check. Use
PerformanceObserverwith{ type: 'event', buffered: true }to confirm that the click-to-navigation handler does not block the main thread for more than200 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.
Related
- Implementing Route-Level Code Splitting in SPAs — parent page covering the full route-splitting architecture this fix lives within
- Prefetch and Preload Strategies for Critical Routes — proactive chunk loading to eliminate fetch latency before transitions start
- Setting up Route-Based Prefetching in Next.js — framework-specific prefetch configuration for Next.js App and Pages Router
- Preventing Waterfall Requests with Dynamic Import Maps — network-layer strategies to parallelize chunk fetches during transitions