Fallback rendering, code splitting, and streaming SSR with Suspense boundaries.
Suspense is a mechanism that lets components 'wait' for something before rendering. When a component is not ready (a lazy import hasn't loaded, or a data fetch hasn't resolved), it throws a Promise. React catches this thrown Promise, finds the nearest Suspense boundary up the tree, shows its fallback UI, and automatically re-renders the subtree when the Promise resolves.
React.lazy(() => import('./MyComponent')) wraps a dynamic import in a special wrapper. The first time React tries to render it, the import() hasn't resolved yet, so the lazy wrapper throws a Promise.
During the render phase, React catches thrown Promises (not thrown Errors β those go to Error Boundaries). It traverses up the fiber tree to find the nearest <Suspense> ancestor and renders its fallback prop instead of the suspended subtree.
React attaches a .then() handler to the thrown Promise. When the Promise resolves (the module loads, or data arrives), React re-renders the suspended subtree from scratch, this time using the resolved value.
In concurrent mode, React can 'try' to render a suspended subtree without committing it to the DOM. If it suspends, React keeps showing the old content (no flash to fallback) until the Promise resolves, then swaps in the new content atomically.
With Next.js/React 18 streaming, the server immediately sends the HTML shell with Suspense fallbacks in place. As each async data fetch resolves on the server, React streams the filled-in HTML chunk to the browser, replacing the fallback inline.
Wraps a dynamic import to create a lazily-loaded component. On first render, throws a Promise. On resolution, React re-renders with the loaded module.
<Suspense fallback='{'<Spinner/>'}'> catches thrown Promises from any descendant. Shows fallback while waiting, reveals children when ready.
The mechanism by which components signal they're not ready. Any component (not just lazy ones) can throw a Promise to trigger Suspense β this is how data-fetching libraries like React Query integrate.
Server sends HTML progressively. Shell renders immediately with fallbacks. As async work completes, React streams filled content chunks to the browser.
Coordinates reveal order of multiple Suspense boundaries. Can enforce 'together' (reveal all at once) or 'forwards'/'backwards' (reveal in order).
1// Code splitting with React.lazy2const HeavyChart = React.lazy(() => import('./HeavyChart'));34function App() {5 return (6 <Suspense fallback={<Spinner />}>7 <HeavyChart /> {/* loads the JS bundle on demand */}8 </Suspense>9 );10}1112// Data fetching (with a Suspense-compatible library)13// The library throws a Promise internally when data isn't ready14function UserProfile({ userId }) {15 // use() hook (React 19) or library wrapper throws if not ready16 const user = use(fetchUser(userId));17 return <h1>{user.name}</h1>;18}1920<Suspense fallback={<ProfileSkeleton />}>21 <UserProfile userId={1} />22</Suspense>
Suspense unifies three previously separate problems β code splitting, loading states, and server streaming β under one declarative model. Instead of tracking isLoading flags in every component, you declare a boundary and a fallback, and React handles the orchestration. This leads to significantly simpler data-fetching code and eliminates loading state management bugs.
Your SPA ships a 2MB JavaScript bundle. The landing page only needs 200KB of it β the rest is for the admin panel, chart library, and markdown editor that 80% of users never visit.
Without code splitting, the browser downloads, parses, and executes 2MB of JavaScript before showing anything. On mobile (3G network + slower CPU), this means 8+ seconds before the page is interactive β most users bounce.
Use `React.lazy` + `Suspense` at route boundaries: `const AdminPanel = React.lazy(() => import('./AdminPanel'))`. The initial bundle drops to 200KB. When a user navigates to /admin, the AdminPanel chunk loads on demand. The Suspense fallback shows a skeleton while the chunk downloads.
Takeaway: Code splitting at route boundaries is the highest-impact optimization for initial load time. Wrap lazy-loaded routes in Suspense with skeleton fallbacks. Tools like Next.js do this automatically for page components, but you should also lazy-load heavy feature components within pages.
A dashboard page loads 3 things: sidebar navigation (fast), main content (medium), and analytics charts (slow). With a single Suspense boundary, the entire page shows a spinner until the slowest component (charts) finishes loading.
One Suspense boundary at the page level means the entire page is either 'loading' or 'ready'. The sidebar (50ms) and main content (200ms) are ready quickly, but the user stares at a full-page spinner for 2 seconds waiting for the charts.
Nest multiple Suspense boundaries: one around the sidebar (shows instantly), one around the main content (shows after 200ms), one around the charts (shows after 2s). Each section renders independently as its data/code becomes available. The page progressively reveals content instead of all-or-nothing.
Takeaway: Use nested Suspense boundaries to control loading granularity. The boundary placement determines the loading UX: coarse boundaries (page-level) show everything at once, fine boundaries (component-level) enable progressive, streaming-style loading.