React Suspense — Missing ErrorBoundary Took Down Checkout
A CDN timeout on a lazy chunk caused a blank checkout page — Suspense doesn't catch errors.
- React.lazy + Suspense defer component loading until render time
- Code splitting via dynamic import() breaks the bundle into async chunks
- Suspense shows a fallback UI while the lazy chunk loads
- Performance gain: up to 60% smaller initial bundle on route-heavy apps
- Production trap: missing ErrorBoundary causes a white screen when chunk fails
- Biggest mistake: assuming Suspense handles data fetching in React 17 — it doesn't
Imagine a restaurant that doesn't cook every dish before you arrive — they only start your order once you sit down and ask for it. React Lazy Loading works exactly the same way: instead of downloading every part of your app the moment someone visits, React waits and only fetches the code for a page or component when the user actually needs it. Suspense is the 'please wait, your food is being prepared' sign the waiter puts on your table — it shows a fallback UI while the real component loads. Together they stop your app from making the user download a giant bundle upfront, just like a good restaurant doesn't make you pay for dishes you never ordered.
Bundle size is the silent killer of React app performance. A typical single-page application compiled without any code-splitting hands the browser a monolithic JavaScript file — sometimes several megabytes — before rendering a single pixel. On a 4G connection that feels slow; on a 3G connection in rural areas or emerging markets it feels broken. Google's Core Web Vitals measure this directly: a high Time-to-Interactive score tanks your SEO ranking and drives users away inside three seconds. React Suspense and lazy loading are the framework-level answer to this problem, and they're more powerful — and more nuanced — than most tutorials show.
Before React.lazy and Suspense, code-splitting meant manually wiring Webpack dynamic imports, writing your own loading-state logic per component, and scattering conditional renders across your codebase. Every team solved it differently, which meant every codebase looked different and bugs crept in at the seams. React 16.6 introduced React.lazy and Suspense to give the framework itself ownership of asynchronous component resolution, and React 18 extended Suspense to cover data fetching — turning it into a first-class primitive for anything that takes time.
By the end of this article you'll understand exactly what happens inside React's reconciler when a lazy component suspends, how to structure route-level and component-level splits for maximum impact, which edge cases can silently break your fallback UI in production, and how to combine Suspense with React 18's concurrent features like startTransition to build apps that feel instant. You'll walk away with production-ready patterns, not toy examples.
What is React Suspense and Lazy Loading?
React.lazy and Suspense are a declarative API for code splitting. React.lazy takes a function that returns a dynamic import() and wraps it in a lazy component. Suspense is a component that renders a fallback while the lazy component's chunk is loading.
React.lazy and Suspense are not the same as manual dynamic imports. The key difference: React.lazy integrates with the reconciler so that when a lazy component renders, React automatically fetches the chunk, suspends the tree, and shows the fallback. Manual dynamic import() requires you to manage loading state yourself with useState and useEffect.
But there's a catch: React.lazy only works in environments that support dynamic import() natively (bundlers like Webpack, Vite, Parcel). It does nothing in server-side rendering without additional setup — see Suspense on the server section.
- React.lazy internally stores the promise from the dynamic import.
- When the component renders, React checks if the promise has resolved. If not, it throws a promise (yes, literally throws it).
- Suspense catches that thrown promise and renders the fallback.
- When the promise resolves, React re-renders the lazy component with the actual module.
- If the promise rejects, React throws the error — which Suspense does NOT catch. That's why ErrorBoundary is required.
import().How React.lazy and Suspense Work Internally
Under the hood, React.lazy creates a special component type that the reconciler treats differently. When the reconciler encounters a lazy component, it checks a hidden __status property. If the status is 'pending', React throws the promise object (a caught promise). This triggers Suspense to catch it and render the fallback. Once the promise resolves, React marks the status as 'resolved' and schedules a re-render. On the next render, the lazy component's render function is called normally.
This mechanism is the basis for all Suspense-based data fetching in React 18. The key insight: throwing a promise is the core primitive. Any library that wants to integrate with Suspense (like Relay or SWR) can throw a promise to let Suspense manage the loading state.
The reconciler also respects concurrent features: if you wrap a state update that triggers a Suspense in startTransition, React can keep showing the old UI while the new chunk loads, avoiding a loading flash.
Route-Level vs Component-Level Code Splitting
The most effective code-splitting pattern is route-level: split by URL. Each route gets its own lazy chunk. In React Router, you wrap your route elements with lazy imports.
Component-level splitting is more granular — you lazy-load a heavy component inside a page, like a data grid or chart. This is useful when a page has one heavy element that most users never need, but it adds complexity: you now have multiple Suspense boundaries on one page, and must coordinate their loading states carefully.
The rule of thumb: start with route-level splitting. Then profile: if a single component on a page accounts for more than 30% of the page bundle size and only 20% of users interact with it, component-level split it. Otherwise, don't.
import() that returns the same module.Error Handling with ErrorBoundaries and Suspense
Suspense does not catch errors. When a lazy component's import fails (network error, CDN fails, module syntax error), the promise rejects, and React.lazy throws an error. That error propagates up the component tree until an ErrorBoundary catches it. If no ErrorBoundary exists, React unmounts the entire tree and logs an error — but the user sees a white screen.
Best practice: every Suspense boundary that encloses a lazy component must have a corresponding ErrorBoundary as its parent. Not a global one — at least one per route or per major feature. This way, if a chunk fails for one route, other routes still work.
ErrorBoundaries are React components that implement componentDidCatch or static getDerivedStateFromError. They cannot catch errors in event handlers or asynchronous code (outside render), but they do catch errors thrown during render — exactly what React.lazy does.
Suspense for Data Fetching in React 18
React 18 extended Suspense to cover data loading. Now any component can 'suspend' by throwing a promise. Libraries like Relay, SWR, and TanStack Query can integrate. The benefit: you get a unified loading UI, automatic race condition handling (React will ignore outdated requests), and the ability to use startTransition to keep showing stale data while new data loads.
This pattern eliminates the need for loading states scattered across components. Instead of checking isLoading in every component, you wrap the tree in Suspense and let React handle it. Combined with Streaming Server Rendering, you can send HTML as the data arrives.
But there's a trade-off: Suspense for data fetching requires the data fetching to be done at the component level (not in useEffect). Libraries like Relay and SWR already support this; if you're using custom fetch logic, you'll need to wrap it in a Suspense-enabled data source.
Production Gotchas and Performance Optimisations
Even with proper Suspense setup, several traps await in production:
- Service worker caching — If a chunk URL changes (e.g., hash update), but the service worker serves the old chunk, you may get a stale module with wrong exports. Use versioned chunk names and always update the service worker cache.
- Font and CSS loading — Suspense only handles JavaScript modules. Your lazy-loaded component may depend on CSS-in-JS or custom fonts that haven't loaded yet, causing a flash of unstyled content. Preload fonts and critical CSS.
- Multiple Suspense boundaries on one page — Each boundary triggers a separate loading state. Users may see several spinners pop in and out. Combine related components under one Suspense boundary.
- Lazy loading of components that are already in the bundle — If you lazy-import a component that's already available in the same chunk (e.g., from a barrel export), you gain nothing. Ensure the import points to a separate file.
- Prefetching and preloading — For routes the user is likely to navigate to (e.g., next page in a wizard), use <link rel=prefetch> or dynamic
import()with a low priority to load the chunk before navigation.
The Blank Screen: A Missing ErrorBoundary Took Down our Checkout
- Suspense is not an error handler — always pair it with an ErrorBoundary.
- Test on throttled connections: simulate chunk failures with DevTools offline mode.
- Every route-level or feature-level Suspense boundary needs its own ErrorBoundary, not a global one.
- Include a retry mechanism that reloads the failed chunk (e.g.,
window.location.reload()or dynamic import retry).
./MyComponent?t=${Date.now()})Key takeaways
Common mistakes to avoid
4 patternsMissing ErrorBoundary around Suspense
Lazy-loading tiny components (< 5KB)
Using lazy inside a conditional or loop that creates new instances
Forgetting to handle the case where lazy import returns a non-default export
Interview Questions on This Topic
What happens internally when React encounters a lazy component that hasn't loaded yet?
Frequently Asked Questions
That's React.js. Mark it forged?
5 min read · try the examples if you haven't