While a lot has been written about the Server Components and the Suspense API, it is often lost how well they work in tandem. While each of the concepts is useful and fascinating in itself, they come together to form a cohesive whole which is greater than the sum of its parts, in that it allows us to have both superb developer experience and performance at the same time. But first, before we dive in, let us take a quick look at the background and the actual problems they are trying to solve.
Different types of apps and the Old-School React
React was created to help us build Single Page Applications where rich interactivity is the paramount. Its ideal use-case are truly interactive experiences where the state is so dynamic that you could’n possibly imagine them as server-side rendered applications in, say, PHP. Either because the state is primarily the result of constant user interactions, like in drawing applications, or the server-side state gets updated so frequently that reloading the whole page to reflect its changes in real time would drive the user insane, like in stock trading or chat apps.
On the other hand, many apps like back offices, booking sites, to-do apps, or even social networks do entail a considerable amount of interactivity but, nevertheless, would still be workable for a user even when written in PHP. However, implementing them as SPAs provides such a boost to their user experience that sacrificing the initial page load speed for it is a tradeoff well worth making.
However, the SPA approach largely falls apart for the apps where more-than-less static information is the most important thing that needs to be shown to the user as quickly as possible. This doesn’t mean that user interaction is of secondary importance, you most definitely need it for a user to be able to buy a product. Rather, it just comes second in the order in which things should happen. Innumerable usability studies have shown that the initial page load speed has such a great effect on conversion rates that it’s simply non-negotiable, most notably in the case of e-commerce apps. There are also SEO concerns involved, but that’s a different story that would further complicate things without contributing much to this discussion.
Why does the initial page load speed suffer in SPAs?
As their name suggests, there is only one page in a Single Page Application. It is largely just an empty canvas for the client-side JS to do its magic once it loads in the browser, and this poses two big problems for the initial page load.
1. Network Waterfalls
Only the empty page and the JS code are initially sent to the client. The request(s) for the actual data that the users actually want to see fire once the JS loads and gets parsed in the browser, and network requests tend to be the slowest part of this equation. Even worse, quite often you can make additional requests only after you have gotten some piece of data from the previous one. This is called a Network Waterfall, and this is particularly when you get in trouble performance-wise.
2. Huge bundle sizes
Given that data fetching and rendering happen in the browser, the code for these operations also needs to be shipped to the client, whereas it was able to remain on the server in the traditional server-rendered applications.
Server-Side Rendering
With all this in mind, it’s easy to see why React was not the best choice for the apps where the initial page load was of utmost importance. Yet, aiming to provide rich, interactive experiences in a framework they got to know well, developers kept using it even for the previously mentioned use case, and this has ultimately led to a lot of frustration.
The first attempt to resolve this issue was to make React components render on the server, and this helped a bit. Users were able to get the static version of the page sooner, though they still needed to wait for all the JS to load in the browser and hydrate the page to be able to actually interact with it.
Essentially, with SSR, instead of rendering the page from scratch on the client, React creates in-memory representation of its component tree from the bundled JS, then reads the static HTML generated on the server, and just attaches callbacks and event handlers to it, and then takes over and works as usual, with all the bells and whistles of interactivity users have come to expect.
For this to work, both the server-side created DOM tree and the client-side generated Fiber Tree (aka the Virtual DOM) need to match, which means that all the components have to run both on the client and the server, and the code used for data fetching and rendering still needs to be sent to the client, leaving the problem of huge bundle sizes unaddressed.
Still worse, hydration is synchronous and blocking, meaning that you cannot interact with anything on the page until literally everything on it has been fully hydrated. In the most severe cases, a user clicking a button before the hydration process has been completed would be left frustrated wondering why nothing happens in response to their actions.
Businesses could not let this happen, so the frustration was passed down to developers that had to mitigate this issue by often having to spend more time code-splitting and fine-tuning the delivery of their apps than focusing on the actual problem they were trying to solve. Needless to say, the result was complicated, error-prone code that was difficult to maintain and reason about, so it was ultimately the project velocity that has suffered the most.
React Server Components
In truth, very little can be done to address the problems of network waterfalls and huge bundle sizes solely on the client. The server is far better positioned to tackle these issues, so this is where React needed to expand.
Simply put, Server Components are React components that are rendered only once per request, which obviously implies that they don’t re-render. There are no state, no effects, nor any interactivity involved whatsoever. It is often suggested that Non-interactive Components would have been a better term as the place where they render doesn’t necessarily need to be the literal server (it can also happen during bundling), the point is that they get rendered ahead of time, before they hit the client.
The most obvious benefits of this approach is that data can be fetched on the server, with less latency, closer to where it is physically located (thus network waterfalls are far less of an issue) and formatted and rendered on the server, where all the code used for these operations can remain, while only the minimal JS necessary for handling user interactions (the traditional React components) get shipped to the client. The ultimate result is that non-interactive features can be added with literally zero bundle size cost involved.
From a developer’s perspective, another great benefit that often goes unmentioned is that, with the server-side data rendered to static elements on the server, only the state used for handling user interaction remains on the client. The use of effects is also greatly reduced as there are simply no effects in the Server Components, while their most common use case in the Client Components, data fetching, has been rendered unnecessary. With all this our apps become more stateless than before, and less side effects leaves less opportunities for errors to occur. Writing reliable, bug-free apps becomes easier than ever before, and this alone makes the Server Components a superior choice even when any kind of performance is not a concern at all.
How is this different from PHP?
While PHP solves the problem of network waterfalls and huge bundle sizes effectively, it completely destroys the client state when the whole page reloads, leaving us with sub-optimal user experiences. Technically, you could solve this issue with a client-side scripting library, but that would entail using two completely different languages and two completely different sets of idioms, and the final result would hardly be less brittle and end easier to maintain and reason about than the JS delivery ballet you’d dance when rendering a React app on the server.
In order to explain how React Server Components resolve these issues, it’s useful to briefly remind ourselves how React works. In React, UI is a function of the state. When the state changes, the framework reacts by updating the UI. However, interacting with the real DOM is expensive, so React provides you with the Fiver Tree (a.k.a. The Virtual DOM), a simplified, fast, JS implementation of the DOM your app actually interacts with, and then just diffs the two trees and applies the minimal necessary changes to the real DOM in an efficient manner, preserving any client state that doesn’t need to change along the way (scroll position, typed-in input, toggled sidebars, etc.).
This brings us to what the actual output of the Server Components is. It’s not HTML, as it is commonly (over)simplified, but a JSON stream that React client-side runtime can easily transform into a new Fiber Tree. This new tree will then be merged into the existing one using the same diffing and reconciliation algorithms as before. From the client’s perspective, the final update is no different than if it had happened entirely in the browser, and the entire client state is preserved.
React Server Components are not SSR, but an alternative to it. They run once per request, on the server, and they don’t exist on the client at all, just the static HTML tags they’ve rendered to. If the nature of their JSON output is static, it can totally be cached and delivered over a CDN.
You still need to have the JS for the React client-side runtime loaded in the browser in order for the Server Components to work, even if your app is actually a static website without any interactive features at all. The fact that you’ve seen Next.js apps work with JS disabled in the browser is because the framework has chosen to combine them with SSR (this time, with greatly reduced bundle sizes) in order to make the initial content appear even faster in the browser. From then onwards, every page navigation is a transition, and only the JSON output is used for loading subsequent pages.
Suspense
Even with all this in place, there are still situations that can get you into trouble, or at least sub-optimal situations. You might need to talk to two APIs, one of which could be slower, thus blocking the shipping of the content coming from the fast one; or you might have a static article that can quickly be retrieved from cache and sent to the client, but to no avail because because the dynamic nature of its comments requires a costly database retrieval. Finally, even if the bundle is smaller in size, it would still be beneficial to deliver it in several tiny packages, provided that it didn’t involve so many complexities as the Old-School React SSR.
This is where the Suspense API comes in. In this context, it is best thought of as the opt-in for React’s SSR features - Streaming HTML, and Selective Hydration. When you wrap app sections of your app in Suspense, you’re effectively splitting it into multiple parts, each of which goes through the traditional SSR process separately. This way, the slower parts will no longer drag down the fast ones, which get sent to the client immediately. The other, slower, parts are streamed later, when they’re ready, and the client automatically displays the specified fallback UI until that happens.
Streaming HTML
This feature streams your HTML chunks to the client as soon as possible. It’s main difference from the standard HTML Streaming is that HTML chunks can be shipped in no particular order to the browser because they are accompanied with small JS scripts that put them in their correct places in the page, whereas the standard HTML needs to send its chunks in top-to-bottom order.
Selective Hydration
Even with all the code for data fetching and rendering eliminated from the bundle, you might still need to use some heavy client side modules, and with the traditional approach, where the entire hydration process needs to happen in one go, this would ultimately lead to a frozen UI until everything has fully loaded and hydrated on the client.
With Selective Hydration, different chunks of your app are not only hydrated separately, but their hydration also happens with tiny gaps, which lets the browser prioritise and respond to user interactions even before the hydration process completes. Furthermore, even if you happen to click on a non-hydrated part of the UI while some other part is actively being hydrated, React has you covered. It will abandon whatever it’s doing at the moment to synchronously update the part of the UI you’re interactive with, just in time to respond to the event in a near instant.
Conclusion
With the Server Components and Suspense, React becomes a great choice even for the kinds of apps for which it wasn’t well suited before. In addition to improving the user experience, it greatly boosts the developer experience as well, which ultimately leads to greater project velocity. By reducing the amount of state and side-effects necessary to build an app, and by automatically taking care of code splitting and hydration optimisation, it frees our mind to focus on the actual problem we are trying to solve, and allows us to write natural code that’s easy to maintain and reason about.
Finally, keep in mind that this is not everything that the new React brings to the table, just a high-level overview of how React's performance issues were addressed with the Server Components and Suspense.
Sources
This article is primarily based on contents of discussion in the React Working Group, and the Next.js's docs, great places to start digging into and learn more about the New React.
Top comments (0)