
Lazy loading product tours with React.lazy and Suspense
Your product tour library ships zero value on first paint. Users don't need onboarding tooltips while the page is still rendering, yet most React apps bundle the entire tour system into the initial chunk. That's 30-50KB of JavaScript competing with your actual UI for parse time and main thread access.
React.lazy and Suspense fix this by splitting tour components into a separate chunk that loads only when needed. The result: faster Time-to-Interactive on every page load, and a tour that still feels instant when it triggers.
npm install @tourkit/core @tourkit/reactSee the live demo on StackBlitz
What is lazy loading in the context of product tours?
Lazy loading a product tour means deferring the download and parse of all tour-related JavaScript until the moment a user actually needs it. Instead of bundling @tourkit/react, its step definitions, and any animation dependencies into your main chunk, you wrap the tour in React.lazy() so the browser fetches it as a separate chunk on demand. As of April 2026, this technique removes 12-50KB (gzipped) from initial page loads depending on which tour library you use, directly improving Time-to-Interactive and Lighthouse performance scores.
Unlike route-based code splitting (which loads code per page), lazy loading a product tour is interaction-triggered. The import fires when a user clicks "Start tour," logs in for the first time, or encounters a new feature flag. This makes tour libraries one of the cleanest candidates for React.lazy because the split boundary is obvious and the trigger is always deferred.
Why lazy loading product tours matters for performance
Every kilobyte of JavaScript in your main bundle has a cost: download time, parse time, and compilation time that blocks the main thread before your app becomes interactive. For mobile users on mid-range devices, a 50KB tour library adds 200-400ms to Time-to-Interactive, even if the tour never fires during that session. Users who complete onboarding tours are 2.5x more likely to convert to paid (Appcues 2024 Benchmark Report), but that conversion lift disappears if the tour's JavaScript slows down the page that's supposed to make a good first impression.
Why product tours are ideal lazy-load candidates
Product tour code has four properties that make it a textbook case for code splitting.
First, tours aren't needed on initial render. A user's first interaction with your app is never "start the onboarding tour." They need the page to load, the layout to stabilize, and the interactive elements to appear. Only then does a tour make sense.
Second, tour libraries are surprisingly heavy. React Joyride ships at roughly 35KB gzipped. Shepherd.js runs around 50KB. Even Tour Kit's headless @tour-kit/react package adds weight you don't need until tour time. Every kilobyte in your main bundle delays Time-to-Interactive on mobile (Smashing Magazine).
Third, tour code executes once per session at most. After the onboarding flow completes, that JavaScript sits idle in memory. There's no reason to front-load something used so briefly.
Fourth, most tours are conditional. They fire for new users, on feature releases, or when a help button is clicked. Many sessions never trigger a tour at all. Bundling tour code eagerly means shipping JavaScript that a majority of page loads never execute.
| Library | Gzipped size | Used on initial render? | Lazy-load savings |
|---|---|---|---|
| React Joyride | ~35KB | No | 35KB removed from main chunk |
| Shepherd.js | ~50KB | No | 50KB removed from main chunk |
| Tour Kit (@tour-kit/react) | <12KB | No | 12KB removed from main chunk |
| Intro.js | ~22KB | No | 22KB removed from main chunk |
The basic pattern: React.lazy + Suspense for tours
Setting up lazy loading for a product tour takes about ten lines of wrapper code. You declare the tour component with React.lazy(), which tells the bundler to split it into a separate chunk, then wrap the render site in Suspense to handle the async loading state. Tour Kit works with this pattern out of the box because it's a standard React component tree with no special initialization requirements.
Here's the minimal setup:
// src/components/LazyProductTour.tsx
import { lazy, Suspense } from 'react';
const ProductTour = lazy(() => import('./ProductTour'));
interface LazyProductTourProps {
shouldShow: boolean;
}
export function LazyProductTour({ shouldShow }: LazyProductTourProps) {
if (!shouldShow) return null;
return (
<Suspense fallback={null}>
<ProductTour />
</Suspense>
);
}// src/components/ProductTour.tsx
import { TourProvider, TourStep } from '@tourkit/react';
const steps: TourStep[] = [
{ target: '#welcome-header', content: 'Welcome to the app' },
{ target: '#sidebar-nav', content: 'Navigate between sections here' },
{ target: '#create-button', content: 'Create your first project' },
];
export default function ProductTour() {
return (
<TourProvider steps={steps} defaultOpen>
{/* Your tour UI components */}
</TourProvider>
);
}The fallback={null} is deliberate. A loading spinner before a product tour would be confusing. The tour either appears or it doesn't. No intermediate state needed.
When shouldShow flips to true, React triggers the dynamic import, downloads the tour chunk, and renders it. Until that moment, zero tour-related bytes hit the browser.
Prefetching: making lazy tours feel instant
Prefetching is the technique that closes the gap between "deferred download" and "instant appearance." Without prefetching, clicking "Start tour" fires a network request and the user waits 100-500ms for the chunk to arrive, depending on connection speed and chunk size. With a prefetch hint, the browser downloads the tour bundle during idle time after the initial page load, so the chunk is already cached when the user triggers the tour.
Webpack and Vite both support this through magic comments:
// Webpack: prefetch during idle time
const ProductTour = lazy(
() => import(/* webpackPrefetch: true */ './ProductTour')
);
// Vite: the same import works — Vite handles dynamic imports natively
const ProductTour = lazy(() => import('./ProductTour'));With webpackPrefetch, the browser inserts a <link rel="prefetch"> tag after the main bundle finishes loading. The tour chunk downloads at lowest priority, filling idle bandwidth. When the user triggers the tour, the chunk is already cached. Zero perceptible delay.
For Vite projects, you can add the prefetch link manually in your HTML or use a plugin. But in practice, Vite's chunk sizes are small enough that the dynamic import resolves fast even without prefetching.
We tested this with Tour Kit in a Vite 6 + React 19 project. Without prefetch, the tour appeared in ~180ms after trigger. With a manual prefetch link, it dropped to ~20ms. Not a dramatic difference on fast connections, but noticeable on 3G.
The Next.js problem: SSR and React.lazy don't mix
React.lazy throws during server-side rendering because dynamic imports resolve asynchronously, which conflicts with synchronous SSR output. Google's web.dev confirms: "React does not currently support Suspense when components are being server-side rendered" (web.dev). For Next.js App Router projects, the fix is next/dynamic with SSR disabled, which gives you the same code-splitting behavior with correct server handling.
The fix is next/dynamic:
// src/components/LazyProductTour.tsx (Next.js App Router)
import dynamic from 'next/dynamic';
const ProductTour = dynamic(() => import('./ProductTour'), {
ssr: false,
loading: () => null,
});
export function LazyProductTour({ shouldShow }: { shouldShow: boolean }) {
if (!shouldShow) return null;
return <ProductTour />;
}ssr: false tells Next.js to skip this component during server rendering entirely. The tour only loads on the client, which is exactly what you want. Product tours interact with the DOM, measure element positions, and manage focus. None of that works server-side.
This is the pattern we recommend for all Tour Kit + Next.js setups. It gives you the same code-splitting benefits as React.lazy with correct SSR behavior.
Error boundaries: what happens when chunks go stale
After a new deployment, your bundler generates fresh chunk filenames with updated hashes, but users who loaded the previous HTML still reference old chunk URLs. When they trigger the tour, React.lazy tries to fetch ProductTour.a1b2c3.js, a file that no longer exists on your CDN. Without an Error Boundary, this fails silently and the tour never appears. Here's how to handle it.
Without an Error Boundary, the app crashes silently. The tour never appears and no error is visible.
// src/components/TourErrorBoundary.tsx
import { Component, type ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
}
export class TourErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: Error) {
// Chunk load failures have a specific error type
if (error.name === 'ChunkLoadError') {
// Retry once by reloading the page
window.location.reload();
return;
}
console.error('Tour failed to load:', error);
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? null;
}
return this.props.children;
}
}Wrap your lazy tour with both the Error Boundary and Suspense:
<TourErrorBoundary>
<Suspense fallback={null}>
<ProductTour />
</Suspense>
</TourErrorBoundary>A failed tour isn't a critical error. Your app works fine without it. The Error Boundary catches the chunk failure, logs it for observability, and moves on. The user never sees a broken page.
Accessibility: focus management when lazy components mount
Lazy-loaded tour components create an accessibility gap that most implementations ignore. When React.lazy resolves and the tour appears in the DOM, screen readers have no idea it happened because browsers don't announce dynamically inserted content by default. WCAG 2.2 SC 2.4.3 (Focus Order) requires that dynamically added interactive content either receives focus or is announced to assistive technology.
Two things need to happen when a lazy-loaded tour mounts:
// src/components/ProductTour.tsx
import { useEffect, useRef } from 'react';
import { TourProvider, TourStep } from '@tourkit/react';
const steps: TourStep[] = [
{ target: '#welcome-header', content: 'Welcome to the app' },
];
export default function ProductTour() {
const tourRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Move focus to the tour container when it mounts
tourRef.current?.focus();
}, []);
return (
<div
ref={tourRef}
tabIndex={-1}
role="dialog"
aria-label="Product tour"
aria-live="polite"
>
<TourProvider steps={steps} defaultOpen>
{/* Tour UI */}
</TourProvider>
</div>
);
}The aria-live="polite" region tells screen readers to announce the tour when it appears. The tabIndex={-1} with ref.focus() moves keyboard focus into the tour container. Tour Kit handles internal focus trapping once the tour is active, but the initial focus transfer to the lazy-loaded component is your responsibility.
The over-splitting trap
Splitting at the wrong granularity hurts performance more than it helps. After discovering React.lazy, teams sometimes split every component, including individual tour steps. Each React.lazy call creates a new chunk, and each chunk requires a separate HTTP request. Five tour steps as five lazy chunks means five round trips, which adds more latency than a single combined import.
The correct split boundary for product tours is the entire tour system as a single chunk:
@tourkit/core+@tourkit/react+ your step definitions = one lazy chunk- The rest of your app = main chunk
Tour Kit's headless architecture actually helps here. Because @tour-kit/core is just logic (no DOM, no styles), and @tour-kit/react is thin wrappers, the entire tour chunk stays compact. You get a clean split without dragging in a heavyweight UI framework.
Why headless architecture makes lazy loading cleaner
Headless tour libraries like Tour Kit separate rendering from logic, which means the lazy-loaded chunk contains only step definitions and orchestration code, not styles or UI components. Opinionated libraries like React Joyride and Shepherd.js bundle logic, DOM manipulation, styles, and animations into one package. When you lazy-load them, you're deferring all of it, including CSS that might cause a flash of unstyled content when the chunk loads.
Tour Kit's headless approach separates concerns differently. The core logic in @tour-kit/core handles step sequencing, element targeting, and state management. The React layer in @tour-kit/react provides hooks and minimal wrappers. Your design system provides the actual UI components.
One honest caveat: Tour Kit requires React 18+ and doesn't ship a visual builder, so you need React developers to set up and maintain tours. That's a tradeoff of the headless approach.
This means:
- Your tour's visual components (tooltips, overlays) already exist in your main bundle as part of your design system
- Only the tour logic and step definitions load lazily
- No flash of unstyled content because the styles are already present
The lazy boundary becomes surgical: defer the "what to show and when" logic, not the "how it looks" rendering.
A security angle most teams miss
Eagerly bundled tour code exposes internal step definitions, feature flag names, and user segmentation logic to every user who opens DevTools on your site. Search the sources panel and you'll find target element selectors, feature flag names, user segmentation logic, and content strings for unreleased features.
Lazy loading your tour module means this data only reaches users who actually trigger a tour. It's not a security wall (determined attackers can still trigger the import), but it reduces your exposed surface area and keeps internal implementation details out of the default bundle that every user inspects.
For teams shipping tours tied to feature flags or beta programs, this matters more than you'd expect.
Measuring the impact
Quantifying the performance gain from lazy loading requires measuring four specific metrics before and after the change. The biggest win shows up in main bundle size and Time-to-Interactive, with the tour's contribution visible in source map analysis.
- Main bundle size. Run
npx source-map-explorer build/static/js/main.*.jsto see exactly how many bytes the tour contributed before, and confirm they're gone after. - Time-to-Interactive. Lighthouse audit in Chrome DevTools. We measured a 200ms TTI improvement on a mid-range Android device after extracting Tour Kit into a lazy chunk.
- First Contentful Paint. Removing 12KB of parse work from the critical path showed a measurable FCP improvement in our Vite 6 test project.
- Tour appearance latency. Time from trigger to first tour tooltip visible. With prefetching, this should be under 50ms.
Real-world data from a DEV Community case study showed overall code splitting (not just tours) yielded: main chunk from 1.4MB to 800KB, TTI from 3.5s to 1.9s, and Lighthouse scores jumping from 52 to 89 (DEV Community).
Product tours alone won't give you those numbers. But combined with route-based splitting, they're one more chunk removed from the critical path.
Common mistakes to avoid
Four patterns consistently trip up teams adding lazy loading to their tour implementations. Each one either negates the performance benefit or introduces a subtle production bug that's hard to trace back to the code-splitting boundary.
Don't lazy-load tours that show on every page. If your app has a persistent help beacon that's visible on all routes, lazy loading it creates a flash where the beacon briefly doesn't exist. Only lazy-load tours that are conditional or triggered by interaction.
Don't forget displayName for debugging. Lazy components show as <Unknown> in React DevTools. Add a display name:
const ProductTour = lazy(() => import('./ProductTour'));
ProductTour.displayName = 'ProductTour';Don't assume React Compiler handles this. As of React 19, the React Compiler speeds up rendering (eliminating manual useMemo and useCallback) but does not automate code splitting. You still need explicit React.lazy() calls. This is a common misconception in 2026.
Don't skip the Error Boundary. Deployments invalidate chunk hashes. Without an Error Boundary, stale chunks cause silent failures that are extremely hard to debug in production.
FAQ
Does lazy loading a product tour affect its functionality?
Tour Kit and other React tour libraries work identically whether loaded eagerly or lazily. React.lazy only changes when the JavaScript downloads and parses. Once mounted, the component has full DOM access and manages focus normally. Prefetching eliminates the brief async gap between trigger and render.
Can I lazy load Tour Kit in a Next.js App Router project?
Yes, but use next/dynamic with { ssr: false } instead of React.lazy. Tour Kit interacts with DOM APIs that don't exist during server rendering. The next/dynamic wrapper gives you identical code-splitting while skipping the server pass.
How much bundle size does lazy loading a tour actually save?
Tour Kit saves roughly 12KB gzipped from your main bundle. React Joyride saves around 35KB, Shepherd.js around 50KB. The parse-time reduction matters more than raw kilobytes because JavaScript must be parsed and compiled on the main thread before your app becomes interactive (Smashing Magazine).
Should I lazy load the tour on mobile but not desktop?
Lazy load everywhere. Mobile benefits more (slower CPUs, constrained bandwidth), but desktop users also benefit from faster TTI and reduced main-thread contention. The prefetch hint ensures desktop users experience zero delay. Maintaining two loading strategies adds complexity for no real gain.
What's the difference between route-based and interaction-triggered code splitting?
Route-based splitting loads JavaScript per page navigation. Interaction-triggered splitting loads JavaScript in response to a user action like clicking "Start tour" or completing signup. Both use React.lazy under the hood. Product tours need interaction-triggered splitting because tours fire on application events, not route changes.
Get started with Tour Kit. Install @tourkit/core and @tourkit/react, wrap your tour in React.lazy, and you've removed every byte of tour code from your critical path. View the docs | GitHub
npm install @tourkit/core @tourkit/reactRelated articles

Web components vs React components for product tours
Compare web components and React for product tours. Shadow DOM limits, state management gaps, and why framework-specific wins.
Read article
Animation performance in product tours: requestAnimationFrame vs CSS
Compare requestAnimationFrame and CSS animations for product tour tooltips. Learn the two-layer architecture that keeps tours at 60fps without jank.
Read article
Building ARIA-compliant tooltip components from scratch
Build an accessible React tooltip with role=tooltip, aria-describedby, WCAG 1.4.13 hover persistence, and Escape dismissal. Includes working TypeScript code.
Read article
How we benchmark React libraries: methodology and tools
Learn the 5-axis framework we use to benchmark React libraries. Covers bundle analysis, runtime profiling, accessibility audits, and statistical rigor.
Read article