Skip to main content

SSR and lazy loading of components

Reminder: what SSR and lazy loading are

MechanismWhat it does
SSR (Server-Side Rendering)Generates HTML on the server, so the page renders immediately (without waiting for JS).
Component Lazy LoadingLoads a component (or its code) only when needed - so the whole bundle isn't pulled in at once.

How they interact

When you use React.lazy() or dynamic import() in an SSR app:

  1. The server starts rendering the React components.
  2. If it runs into a lazy component that isn't loaded yet (its code is split into a separate JS chunk), the server cannot render it right now.
  3. React either:
  • stops the render (in classic SSR);
  • or shows the fallback from <Suspense> and keeps streaming (in Streaming SSR);
  • or loads the module synchronously, if it's already in memory (in dev mode).

1. In classic SSR (before React 18)

React.lazy() effectively blocks rendering if its module isn't loaded yet.

For example:

javascript
const LazyComp = React.lazy(() => import('./BigChart')); function Page() { return ( <div> <Header /> <LazyComp /> {/* SSR gets "stuck" here */} </div> ); }

On the server, import('./BigChart') is a Promise, but classic SSR (renderToString) cannot wait for promises.

The result:

  • The server throws an error: "A component suspended while rendering, but no fallback UI was specified."
  • Or it simply cannot finish the SSR.

So before React 18, lazy components didn't work on the server - they had to be loaded synchronously or SSR had to be turned off for them (ssr: false in Next.js).


2. In React 18 and Streaming SSR

React 18 completely changed the rules of the game. Now SSR knows how to work with Suspense and lazy components asynchronously.

What happens:

  1. The server starts rendering HTML.
  2. When React runs into a lazy component, it "suspends" the render of that part.
  3. It streams out the fallback HTML (for example, <Skeleton />).
  4. When the module loads, React continues streaming and inserts the ready content later.

Example:

javascript
import { Suspense, lazy } from 'react'; const LazyComp = lazy(() => import('./BigChart')); export default function Page() { return ( <div> <Header /> <Suspense fallback={<p>Loading chart...</p>}> <LazyComp /> </Suspense> </div> ); }

In Streaming SSR:

  • <Header /> and <p>Loading chart...</p> render and are sent to the client immediately;
  • when BigChart loads, the server sends the rest of the HTML.

This makes SSR asynchronous, smooth, and "lazy" by nature.


3. In Next.js

In Next.js (from version 13, App Router), this is built in out of the box:

javascript
import dynamic from 'next/dynamic'; const LazyChart = dynamic(() => import('./Chart'), { ssr: true, // or false, if you don't want to render on the server loading: () => <p>Loading...</p>, }); export default function Page() { return ( <> <Header /> <LazyChart /> </> ); }
  • ssr: true → the component will be rendered on the server (through Suspense).
  • ssr: false → the component is fully excluded from SSR and renders only on the client.

This gives flexibility: you can choose "lazy, but with SSR" or "lazy, but browser-only".


Advantages of this interaction

AdvantageDescription
Faster first renderSSR sends HTML immediately, even if some components load lazily
Smaller JS bundleLazy loading reduces the initial bundle size
Smooth content loadingThrough Suspense, SSR sends a fallback, then replaces it with the ready component
Support for async dataLazy components can load data from an API without blocking the whole SSR

Potential difficulties

ProblemDescription
React.lazy cannot be used without SuspenseOtherwise SSR "hangs"
The fallback can stay longer than expectedIf the import is heavy or the network is slow
The old renderToString doesn't support SuspenseYou need renderToPipeableStream()
Chunk cachingThe CDN needs to be configured so lazy components' JS chunks load correctly

A full SSR + lazy loading + Suspense example

javascript
import { Suspense, lazy } from 'react'; import { renderToPipeableStream } from 'react-dom/server'; const LazySection = lazy(() => import('./Section')); function App() { return ( <div> <Header /> <Suspense fallback={<LoadingSection />}> <LazySection /> </Suspense> </div> ); } // Server app.get('/', (req, res) => { const stream = renderToPipeableStream(<App />, { onShellReady() { res.setHeader('Content-Type', 'text/html'); stream.pipe(res); }, }); });

The client gets the header and LoadingSection immediately, and Section gets added to the DOM later, once it's ready.


Conclusion

SSR and lazy loading now combine really well - thanks to React 18 and Streaming SSR:

CriterionOld SSRStreaming SSR
Do lazy components work?NoYes
Is Suspense supported?NoYes
Can content be sent in parts?NoYes
UX without a "blank screen"?Not alwaysAlways

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.