Limitations of Server Side Rendering (SSR)
Server-side rendering (SSR) offers great benefits in terms of initial loading performance and SEO, but it has some significant limitations. In this article, we'll take a look at some of the problems with traditional SSR, and learn about Suspense, which was introduced in React 18 to address them.
1. Blocking issues with data fetching
One of the biggest problems with SSR is that you need to fetch all the data from the server at once. Before we can render the component tree to HTML, we need to have all the data we need for that page.
For example, consider a blog post page that consists of the following components
- A navigation bar
- A sidebar
- The post content
- A comments section
If fetching comment data is taking a long time, developers have the following choices
-
Exclude comments from server rendering
- Pro: Allows other components to be shown quickly
- Cons: Users can't see the comments until the JavaScript is loaded
-
Rendering the server with comments
- Pros: Comments are included in the initial HTML
- Cons: Rendering of the entire page is delayed waiting for comment data
2. Hydration issues
The second big problem with SSR is related to the hydration process. In order to match the HTML generated on the server with the component tree on the client, React has to wait for the JavaScript code of all components to load.
There's also a problem with React's hydration process. Currently, React hydrates the entire component tree at once, which causes the following problems
-
You can't interact with any components until the entire tree is hydrated
-
Performance issues, especially on low-end devices
- Screen freezes if you have heavy JavaScript logic
- Users have to wait for hydration to complete even if they want to go to another page
3. Limitations of the waterfall process
The root cause of these issues is that SSR follows the following sequential "waterfall" process
- Data fetching (server)
- HTML rendering (server)
- JavaScript code loading (client)
- Hydration (client)
Each step can only start once the previous step is fully completed, which degrades overall performance and user experience.
The new solution, Suspense
To address these issues, React 18 introduces Suspense. With Suspense, different parts of the app can be loaded and hydrated separately, effectively solving the problems mentioned above.
How to use it
Using Suspense is simple. Simply wrap any component that you don't want to wait for all of its data to load with <Suspense>
and put the component you want to show on load in the fallback
props.
function BlogPost() {
return (
<div> <Nav
<Nav /> <Sidebar
<Sidebar /> <Article
<Article /> <Article
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</div>
);
}
The initial HTML would look like this
<main> <!!!
<!-- omitted... -->
<section id="comments-spinner">
<!-- Spinner -->
<img width=400 src="spinner.gif" alt="Loading..." />
</section>
</main>
After that, when the data for the comments component is ready on the server, we'll do something like this
<div hidden id="comments">
<!-- Comments -->
<p>First comment</p>
<p>Second comment</p>
</div> </div>
<script>
// This implementation is slightly simplified
document.getElementById('sections-spinner').replaceChildren(
document.getElementById('comments')
);
</script>
This way, we don't have to fetch all the data to show something on the screen.
Improving Hydration
We've created the initial HTML, but we still need to load all the JavaScript before we can start hydrating. In this case, code splitting with lazy
allows us to isolate the hydration process.
const Comments = lazy(() => import('./Comments'));
function BlogPost() {
return (
<div> <div
<Nav /> <Article
<Article /> <div> <Article
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</div>
);
}
Hydration starts when the rest of the JavaScript has finished loading, except for the comments section. Later, when all of the JavaScript in the comment component has been loaded, the comments will be hydrated as well.
Here's a quick overview of the process so far
Conditions that can trigger Suspense
Not every kind of data loading triggers Suspense, so what data sources are compatible with it?
- Fetching data with the
use
Hook
import { use } from 'react';
// Function to fetch data
function fetchUserData(userId) {
return fetch(`/api/users/${userId}`)
.then(res => res.json());
}
function UserProfile({ userId }) {
const user = use(fetchUserData(userId));
return (
<div> <h1>{user.name}</h1
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div> </div>
);
}
// Wrap with Suspense
function App() {
return (
<Suspense fallback={<Loading />}>
<UserProfile userId={1} />
</Suspense> <Suspense
);
}
- Lazy component
import { lazy, Suspense } from 'react';
// Dynamically loaded components
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<div> <div>My App</div
<h1>My App</h1>
<Suspense fallback={<Loading />}>
<HeavyComponent />
</Suspense>
</div> </div>
);
}
- // Server component's async
// Server component
async function BlogPost({ id }) {
const post = await fetchBlogPost(id);
return (
<article
<h1>{post.title}</h1>
<p>{post.content}</p>
</article> </article
);
}
function BlogPage({ id }) {
return (
<Suspense fallback={<LoadingPost />}>
<BlogPost id={id} />
</Suspense>
);
}
UseDeferredValue
function SearchResults({ query }) {
// Defer changing the search query
const deferredQuery = useDeferredValue(query);
return (
<Suspense fallback={<Loading />}>
<SearchResultsList query={deferredQuery} />
</Suspense>
);
}
When Suspense is not triggered
On the other hand, the following common data fetching patterns do not trigger Suspense
- Data patching inside
useEffect
// β Does not trigger Suspense
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUserData(userId).then(setUser);
}, [userId]);
if (!user) return <Loading />;
return <h1>{user.name}</h1>;
}
- Normal Promise handling.
// β Doesn't trigger Suspense
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
Promise.resolve(fetchUserData(userId))
.then(setUser)
.catch(setError);
if (error) return <Error error={error} />;
if (!user) return <Loading />;
return <h1>{user.name}</h1>;
}
When using Tanstack Query
make sure to use useSuspenseQuery
instead of useQuery
.
See