DevLift
Back to Blog

React: Suspense and Lazy Loading: Code-Split Your App Without the Jank

Learn how React.lazy, Suspense boundaries, and React 19 concurrent features (useTransition, useDeferredValue) eliminate bundle bloat and loading jank.

Admin
August 2, 20267 min read2 views

React: Suspense and Lazy Loading: Code-Split Your App Without the Jank

Every React app eventually hits the same wall: your bundle is 2MB, first paint takes four seconds, and users on slower connections bounce before they ever see your UI. You've heard "code splitting" solves this, but React.lazy alone isn't the full story. The part most tutorials skip is how Suspense turns lazy loading from a loading-screen hack into a genuine UX primitive — and how transitions let you keep your UI stable while data loads in the background. Everything here is against React 19.2 and Next.js 16.

This is the pattern for apps where initial load time matters, where you have route-level pages that aren't needed on first render, and where "loading state" needs to be more thoughtful than a full-page spinner.

The Problem

Here's what a typical React app looks like before code splitting:

// app/App.tsx — one big bundle, everything imported upfront
import { Routes, Route } from 'react-router-dom';
import Dashboard from './pages/Dashboard';
import CourseLearn from './pages/CourseLearn';
import AdminPanel from './pages/AdminPanel';
import UserSettings from './pages/UserSettings';
import CertificatesPage from './pages/CertificatesPage';
// ...15 more imports
 
export function App() {
  return (
    <Routes>
      <Route path="/dashboard" element={<Dashboard />} />
      <Route path="/learn/:slug" element={<CourseLearn />} />
      <Route path="/admin/*" element={<AdminPanel />} />
      <Route path="/settings" element={<UserSettings />} />
      <Route path="/certificates" element={<CertificatesPage />} />
    </Routes>
  );
}

Every import here is bundled together. A user landing on /dashboard downloads the code for AdminPanel, CertificatesPage, and every other route they may never visit. Your Webpack/Vite bundle grows linearly with every new page.

The loading state problem compounds this:

// pages/Dashboard.tsx — classic fetch-then-render waterfall
function Dashboard() {
  const [user, setUser] = useState<User | null>(null);
  const [stats, setStats] = useState<Stats | null>(null);
 
  useEffect(() => {
    fetch('/api/user')
      .then(r => r.json())
      .then(data => {
        setUser(data);
        // Only starts fetching stats AFTER user loads — waterfall
        return fetch(`/api/stats/${data.id}`);
      })
      .then(r => r.json())
      .then(setStats);
  }, []);
 
  if (!user || !stats) return <div className="spinner">Loading...</div>; // blocks everything
 
  return <DashboardContent user={user} stats={stats} />;
}

This breaks down in two ways. First, the whole UI is blocked by a single spinner until both requests complete — even though the page header could render immediately. Second, the requests are sequential: stats don't start loading until user data finishes. On a slow connection, that's two round-trips in series.

The Pattern

Step 1: Route-Based Code Splitting with React.lazy

// app/App.tsx — each route loads its own chunk on demand
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import { PageSkeleton } from './components/PageSkeleton';
import { ErrorBoundary } from './components/ErrorBoundary';
 
const Dashboard = lazy(() => import('./pages/Dashboard'));
const CourseLearn = lazy(() => import('./pages/CourseLearn'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
const UserSettings = lazy(() => import('./pages/UserSettings'));
const CertificatesPage = lazy(() => import('./pages/CertificatesPage'));
 
export function App() {
  return (
    <ErrorBoundary fallback={<ErrorPage />}>
      <Suspense fallback={<PageSkeleton />}>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/learn/:slug" element={<CourseLearn />} />
          <Route path="/admin/*" element={<AdminPanel />} />
          <Route path="/settings" element={<UserSettings />} />
          <Route path="/certificates" element={<CertificatesPage />} />
        </Routes>
      </Suspense>
    </ErrorBoundary>
  );
}

React.lazy takes a function returning a dynamic import(). The first time the user navigates to a route, React fetches that chunk and shows the Suspense fallback until it arrives. Subsequent visits use the cached chunk — no loading state at all.

⚠️

React.lazy only works with default exports. For named exports, use: lazy(() => import('./Foo').then(m => ({ default: m.FooComponent }))).

Always pair Suspense with an ErrorBoundary. Suspense handles loading; the error boundary handles chunk-load failures (network errors, CDN misses). It is not "silent" without one — React 19 reports the error through the root's onUncaughtError callback and unmounts the tree — but you get a blank page instead of a retry button.

Four things about Suspense and errors, each mounted and observed on React 19.2.3:

  • Suspense does not catch errors. A component that throws goes to the nearest error boundary, not to the Suspense fallback.
  • A rejected promise read by use() surfaces at the error boundary, not as a permanent fallback.
  • A failed lazy() import lands at the error boundary with the import's own error, which is what makes "Reload the page" a workable fallback UI.
  • A fallback that throws escapes any boundary inside the Suspense children. The fallback is rendered as the boundary's own content, so an <ErrorBoundary> wrapped around the children never sees it — only a boundary above the <Suspense> catches it. Keep fallbacks dumb: skeleton markup, no data access, no hooks that can throw.

React 19 also lets you observe both cases globally via createRoot(node, { onCaughtError, onUncaughtError }) — useful for wiring Sentry once instead of in every boundary.

Step 2: Granular Suspense Boundaries for Streaming Data

Instead of one big spinner, give each independent section its own boundary:

// pages/Dashboard.tsx — parallel fetches, independent loading states
import { Suspense } from 'react';
import { RevenueCard } from '../components/RevenueCard';
import { ActivityFeed } from '../components/ActivityFeed';
import { EnrolledCourses } from '../components/EnrolledCourses';
import { RevenueSkeleton, ActivitySkeleton, CoursesSkeleton } from '../components/Skeletons';
 
// Fire all three promises immediately — no awaiting
const revenuePromise = getRevenue();
const activityPromise = getRecentActivity();
const coursesPromise = getEnrolledCourses();
 
export default function Dashboard() {
  return (
    <div className="space-y-6">
      {/* Static — renders instantly, no Suspense needed */}
      <DashboardHeader />
 
      <div className="grid grid-cols-3 gap-4">
        <Suspense fallback={<RevenueSkeleton />}>
          <RevenueCard dataPromise={revenuePromise} />
        </Suspense>
 
        <Suspense fallback={<ActivitySkeleton />}>
          <ActivityFeed dataPromise={activityPromise} />
        </Suspense>
 
        <Suspense fallback={<CoursesSkeleton />}>
          <EnrolledCourses dataPromise={coursesPromise} />
        </Suspense>
      </div>
    </div>
  );
}

Each card resolves independently. The Revenue card pops in as soon as its data is ready — it doesn't wait for Activity or Courses. The header and layout render immediately.

Step 3: Consuming Promises with use()

// components/RevenueCard.tsx
'use client'; // or just a client component
import { use } from 'react';
 
interface Props {
  dataPromise: Promise<Revenue>;
}
 
export function RevenueCard({ dataPromise }: Props) {
  // Suspends here if promise is still pending
  const revenue = use(dataPromise); // <-- the key line
 
  return (
    <div className="rounded-lg border p-4">
      <p className="text-sm text-muted-foreground">Total Revenue</p>
      <p className="text-2xl font-bold">${revenue.total.toLocaleString()}</p>
    </div>
  );
}

use() reads a promise inside a component. When the promise is pending, React suspends the component and shows the nearest Suspense fallback. When it resolves, React re-renders with the data. The promise must come from outside the component — never create it at render time (more on this below).

Let's break down the key decisions:

  • Promises fired outside components: revenuePromise and friends are module-level in this example, which keeps the illustration short. Do not ship that. A module-level promise is created once per process, so on a server it is shared by every request and every user — one person's dashboard data, cached for everybody, plus an unhandled rejection at import time if the first call fails. In a real app fire them in a parent Server Component or a router loader. The point is only that they start before the component renders.
  • One Suspense boundary per independent section: Three cards = three boundaries. If one fetch is slow, the other two still render as soon as they're ready.
  • Skeletons match real layout: RevenueSkeleton should have the same dimensions as the real card. A skeleton that jumps to a different size when content loads causes layout shift, which is visually worse than a spinner.

How It Flows

Rendering diagram...

Variations

Variation 1: Smooth Navigation with useTransition

Without useTransition, navigating between pages shows the Suspense fallback immediately — your content disappears and a skeleton takes its place. With it, the old content stays visible while the new content loads:

// components/CourseNav.tsx
import { useTransition, useState, Suspense, lazy } from 'react';
 
const CoursePlayer = lazy(() => import('./CoursePlayer'));
 
function CourseNav({ courses }: { courses: Course[] }) {
  const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
  const [isPending, startTransition] = useTransition();
 
  function handleSelect(slug: string) {
    startTransition(() => {
      setSelectedSlug(slug); // non-urgent — keeps current UI stable
    });
  }
 
  return (
    <>
      <nav>
        {courses.map(c => (
          <button
            key={c.slug}
            onClick={() => handleSelect(c.slug)}
            className={isPending ? 'opacity-50 cursor-wait' : ''}
          >
            {c.title}
          </button>
        ))}
      </nav>
 
      {/* Old content stays visible while isPending = true */}
      <Suspense fallback={<PlayerSkeleton />}>
        {selectedSlug && <CoursePlayer slug={selectedSlug} />}
      </Suspense>
    </>
  );
}

startTransition tells React this update is non-urgent. React keeps the currently committed UI on screen (stale but visible) while loading the new chunk and data. isPending gives you a signal to dim the UI or show a subtle indicator.

⚠️

There is no timeout heuristic here — React does not "eventually decide" to show the fallback after some delay. Measured on React 19.2.3 with the same suspending child: driven by a plain setState, the fallback replaced the children immediately; driven inside startTransition, the fallback never appeared at all and the previously committed content stayed on screen until the data resolved. That's the whole trade: a transition buys you a stable screen and costs you the loading indicator, so if the wait can be long you have to render your own signal from isPending.

Variation 2: Responsive Search with useDeferredValue

Use useDeferredValue when you don't own the state update — typically when a third-party component or library triggers the state change:

// components/CourseSearch.tsx
import { useState, useDeferredValue, Suspense } from 'react';
import { SearchResults } from './SearchResults';
import { SearchResultsSkeleton } from './Skeletons';
 
export function CourseSearch() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  // deferredQuery lags behind query — typing stays responsive
  
  const isStale = query !== deferredQuery;
 
  return (
    <>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search courses..."
        className="w-full border rounded px-3 py-2"
      />
 
      {/* Dims results during deferred re-render, no jarring skeleton flash */}
      <div className={isStale ? 'opacity-50 transition-opacity' : ''}>
        <Suspense fallback={<SearchResultsSkeleton />}>
          <SearchResults query={deferredQuery} />
        </Suspense>
      </div>
    </>
  );
}

The input updates immediately on every keystroke. SearchResults only re-renders with the deferred value, which React schedules at lower priority. If the user types faster than renders complete, React skips intermediate values and only renders the final one.

Variation 3: Preloading on Hover

Start fetching the next chunk before the user clicks:

// Utility for lazy components with a preload handle
function lazyWithPreload<T extends React.ComponentType<any>>(
  factory: () => Promise<{ default: T }>
) {
  const Component = lazy(factory);
  (Component as any).preload = factory;
  return Component as typeof Component & { preload: () => Promise<{ default: T }> };
}
 
const CoursePlayerPage = lazyWithPreload(() => import('./pages/CoursePlayerPage'));
 
// CourseCard.tsx — preload the chunk on hover, not on click
function CourseCard({ course }: { course: Course }) {
  return (
    <a
      href={`/learn/${course.slug}`}
      onMouseEnter={() => CoursePlayerPage.preload()} // <-- chunk starts loading here
    >
      <img src={course.thumbnail} alt={course.title} />
      <h3>{course.title}</h3>
    </a>
  );
}

By the time the user clicks, the chunk has often already arrived. The Suspense fallback never shows because there's nothing to wait for.

Variation 4: Next.js App Router — loading.tsx

In Next.js, a loading.tsx file in any route segment automatically wraps that segment's page.tsx in a Suspense boundary. React streams the loading UI in the first HTML flush, then streams the real content as it resolves server-side:

app/
  dashboard/
    loading.tsx     ← auto-wraps page.tsx in <Suspense>
    page.tsx
  learn/
    [slug]/
      loading.tsx
      page.tsx
// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="space-y-6 p-6 animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-48" />
      <div className="grid grid-cols-3 gap-4">
        {Array.from({ length: 3 }).map((_, i) => (
          <div key={i} className="h-32 bg-gray-200 rounded-lg" />
        ))}
      </div>
    </div>
  );
}
// app/dashboard/page.tsx — Server Component, can use await
export default async function DashboardPage() {
  // Fire in parallel — don't await sequentially
  const [user, stats] = await Promise.all([getUser(), getStats()]);
  return <DashboardContent user={user} stats={stats} />;
}

For more granular streaming within a single page, add manual <Suspense> boundaries inside the page component itself — each wrapped section streams independently.

When NOT to Use This

⚠️

Don't reach for React.lazy for components that are needed on first render — the header, navigation, or your main content area. Lazy loading these adds a loading flash where there should be none. Code split at route boundaries, not at component boundaries, unless the component is genuinely optional (modals, sidebars, off-screen panels).

Small apps don't need this. If your whole bundle already lands inside your performance budget, code splitting adds complexity without meaningful gain — extra chunk requests on a cold cache can be slower than one bundle you already had. Check your own budget before splitting.

Don't create promises inside components. This is the most common Suspense mistake:

// BAD — a brand-new promise on every render
function BadComponent() {
  const data = use(fetch('/api/data').then(r => r.json())); // new promise each render!
  return <pre>{JSON.stringify(data)}</pre>;
}
 
// GOOD — promise created once, in a parent, a cache, or a framework loader
const dataPromise = fetch('/api/data').then(r => r.json());
 
function GoodComponent() {
  const data = use(dataPromise);
  return <pre>{JSON.stringify(data)}</pre>;
}

What actually happens is worth knowing precisely, because "infinite loop" is the usual folklore and it isn't what React 19 does. Mounted and observed: React detects the situation and logs "A component was suspended by an uncached promise. Creating promises inside a Client Component or hook is not yet supported, except via a Suspense-compatible library or framework." With a promise that resolves immediately it stumbled through in three render attempts; with a genuinely pending promise recreated each render, the boundary was still showing its fallback more than a second later and never recovered. Not a hot loop — a component permanently stuck on "Loading...", which is harder to spot in review.

Don't put 50 Suspense boundaries on a list. One boundary per list item means 50 skeleton flashes as each item loads independently. Put one boundary around the whole list and show a single skeleton for all of them.

useTransition isn't free. If the new content is already there in a frame or two, wrapping the update in startTransition only means the user stares at the old screen a moment longer with no loading signal. Use it when the wait is long enough that a fallback flash would be jarring — otherwise you're adding complexity to solve a problem that doesn't exist.

The pattern pays off when you have a multi-route app, meaningful variation in what different users load, and data that arrives at different times. At that point, Suspense boundaries and lazy loading let you ship a fast initial load and a smooth subsequent experience without building custom loading machinery in every component.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read