React Suspense — Missing ErrorBoundary Took Down Checkout
A CDN timeout on a lazy chunk caused a blank checkout page — Suspense doesn't catch errors.
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
- 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.
Named Exports Are Dead to React.lazy — Here's the Workaround
React.lazy only works with default exports. That's not a quirk — it's a design constraint from how dynamic imports resolve modules. If your component lives as a named export, lazy will throw a runtime error. You'll see something cryptic in the console, and your component simply won't render.
You have two options. First: refactor the module to use default export. That's clean but may break existing imports. Second: create an intermediary module that re-exports the named component as default. This is the battle-tested pattern used in production codebases at scale.
The intermediary approach keeps your original module untouched and isolates the lazy-loading concern. It's an extra file, sure, but it saves hours of debugging when someone later tries to import the named export directly and wonders why Suspense never shows their component.
Suspense Boundaries: Don't Wrap Everything in One — That's a Bottleneck
Newcomers drop a single Suspense boundary around their entire app. That works — until one slow component holds up the whole page. The user sees a spinner for everything, even if 90% of the UI is ready. That's the opposite of lazy loading's promise.
The fix: place Suspense boundaries at natural UI breakpoints. Sidebar gets its own boundary. Main content gets another. Footer too. React will load and render each chunk independently. Users see parts of the page while others stream in.
This pattern, sometimes called "progressive hydration" or "skeletal loading," directly improves Largest Contentful Paint (LCP). The browser paints visible content faster because it doesn't wait for the entire tree. Measure before and after — you'll see the difference in your Core Web Vitals report.
Named Exports Are Dead to React.lazy — Here's the Workaround
React.lazy only works with default exports. If your component file exports multiple named functions, lazy() will throw a tantrum and refuse to render anything. This isn't a bug — it's by design, and it's a pain in production.
Why? Because lazy() expects the dynamic import to resolve to an object with a .default property. Named exports sit outside that default object. The solution is brutally simple: create a thin wrapper module that re-exports your named component as default. Or just refactor the component to use default export from the start. Stop fighting the framework and ship the wrapper.
The wrapper pattern keeps your original file untouched while giving lazy() what it wants. One file, one default export, zero drama. You've got better things to do than argue with a rendering API.
Suspense Boundaries: Don't Wrap Everything in One — That's a Bottleneck
Throwing every lazy-loaded component under a single Suspense boundary is the fastest way to turn your app into a loading spinner hellscape. One component starts fetching data, the whole screen freezes. That's not code splitting — that's code collapsing.
Why does this happen? Suspense boundaries are cascading. A single boundary wraps multiple children, but the moment any child suspends, the boundary falls back to its fallback UI for every child beneath it. You lose the granular loading states that make lazy loading worth the effort.
The fix: wrap each independent section in its own Suspense boundary. Sidebar gets one, main content gets another, footer gets a spinner. Now each component loads asynchronously without nuking the rest of the view. Your users see progress, not a blank wall.
Think of boundaries as circuit breakers. One trips, the rest keep running. Production apps with multiple Suspense boundaries outperform monolithic wrappers every time.
🎯 The Goal: Why Lazy Loading Exists
React lazy loading serves one primary goal: shrink initial bundle size by deferring component code until it's actually needed. Without it, every import at the top of a file gets bundled into the main chunk, no matter if the user ever sees that component. React.lazy() splits that dependency into a separate chunk at build time. When the component renders, React fetches that chunk, suspends rendering, and swaps in a fallback UI. The user waits less upfront. The critical metric is Time to Interactive. If you lazy-load a heavy dashboard widget that appears only after login, that widget's code never blocks the first paint. The goal is not to lazy-load everything—only components not needed immediately. Misapplied, lazy loading adds round trips that hurt performance. The rule: lazy-load below-the-fold or conditional views. The goal is faster initial load, not smaller total download.
🧭 Final Takeaways: React Suspense Disciplines That Last
After implementing lazy loading across dozens of React production apps, three rules always hold. First, nest Suspense boundaries per region — one boundary for the sidebar, another for the main content, never one giant wrapper. This prevents a slow lazy chunk in the footer from blocking the header from rendering. Second, always pair React.lazy with a named export re-export module — default exports only. Named exports require an intermediate file that re-exports default, or you get a runtime error. Third, use Suspense for data fetching only when you control the data layer (React 18 + Relay or SWR). Mixing Suspense with uncontrolled Promises leads to memory leaks. The most common mistake: lazy-loading a tiny icon component, adding more overhead than the kilobytes saved. Measure before and after. Code splitting is a surgical tool, not a performance blanket.
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).
document.querySelector('script[src*="myLazyChunk"]') — verify if script tag existswindow.__webpack_require__.c — inspect webpack module registry (dev only)./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
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
That's React.js. Mark it forged?
9 min read · try the examples if you haven't