Benchmarking performance gains from Next.js React Server Components

rm-rf-poet

Alan J

Posted on September 14, 2023

Benchmarking performance gains from Next.js React Server Components

Much discussion has been had about how Next.js 13, the new App Router, and React Server Components (aka. RSC) fits in with the future of the React ecosystem. I won’t rehash that here, but here are some great blog posts for catching up.

In spite of the limitations and stumbling blocks around RSC, what I really wanted to know was what concrete benefits can I reap from going through the trouble? To explore this I did an experiment to simulate how the use of RSC in an “average” project (in scare quotes) can actually affect bundle size and metrics like time to Largest Contentful Paint. After all, a 5% reduction in bundle size wouldn’t be as compelling as a 50% reduction in bundle size. I created multiple versions of a benchmark app, both with and without the use of RSC and in this post I present the results and my takeaways.

tl;dr: For some apps, the reduction in bundle size from migrating components server-side could be tremendous. For others, barely noticeable. It depends on what kinds of libraries you are using and how you use them. On the other hand, the use of the App router's component streaming with Suspense boundary can yield noticeable improvements to page metrics without much work.

Lets get a few things straight

First I should establish some foundations since there has been some confusion about exactly what React Server Components are.

  • React Server components allow you to have a subset of components that only run on the server. Their HTML makes it to the client, but the JS is not included in the bundle. This is different from plain SSR. In SSR without RSC, even though the html is rendered on the server, all of the components still need their code to be shipped to the client.
  • One of the benefits of using RSC is to improve performance by reducing bundle size. However, to get this benefit your app’s component hierarchy needs to be structured in a way to maximize the amount of functionality that is implemented with server components. In practice this can be difficult. For example, many UI libraries use CSS-in-JS, which is currently not supported in Next.js server components. And interactive components will always need to be client components.
  • Another important benefit is the ability to stream components from the client to the server as new data comes in on the server, all while showing loading indicators using the Suspense boundary. This means your browser can receive at least some html earlier, which means you can start loading other resources like css earlier as well.

The Benchmark UI

When talking about potential benefits from using RSC, I should make it clear up front that every project is going to be different. But in order to explore these concepts, I decided on simple set of benchmark functionality that might approximate the sorts of needs of an “average” project.

This benchmark includes rendering three sections on a page:

1) A simple form with a few interactive UI controls using specific external libraries. The controls included are, a date picker, an auto-complete, and an accordion. These must be client components, given their interactivity.

2) A section that renders some code snippets with syntax highlighting. This has the potential of being a server component.

3) A section that renders a hierarchy of two hundred separate component definitions, each with a unique component name and fake tailwind CSS styling, which I generated using a separate script. It’s not perfect, but roughly speaking this mimics the amount of app code that might exist in a medium to large size project. I generated them in such a way that they could potentially be used as server components.

Also in this benchmark, I am making a fake API call that has a sleep delay of 400ms. Obviously this benchmark is completely arbitrary, but it allows us to get a general sense of how RSC optimizations are affected in each of those scenarios, relative to each other.

Implementation strategies

With a benchmark of functionality defined, then I could run an experiment. I implemented this functionality thrice, each with a different strategy, which I call "SSR Only", "Minimal RSC" and "Maximal RSC".

1) SSR only - I used the Next.js pages router, which means no RSC whatsoever. These pages will load their data from getServerSideProps().

2) Minimal RSC - I put “use client” at the top of my component hierarchy. However, because I'm using the App router, the page level component is still an async server component.

3) Maximal RSC - I used RSC as much as I could, given reasonable restrictions. In this case, that meant that the syntax highlighting components and the custom code components were server components. The UI form needed to stay as client components because they are interactive.

Effects on Bundle Size on using client components

Image description

In this table, I’m showing how much each of the UI sections contributed to bundle size (gzipped). This was measured by using the output of the next build command npm run build, which shows for each endpoint the total amount of JS that would need to be downloaded when you first load the page (ie. First Load JS). And to approximate the contribution to bundle size for each feature, I just built the app both with and without that section and subtracted to get the resulting bundle size. I've omitted the SSR Only entry from the the table because its bundle size was essentially the same as the Minimal RSC case, if perhaps slightly larger.

You can see that the Maximal RSC case, the total bundle size was reduced by 223kb, which is a reduction of around 50%. But the amount of reduction that is possible will depend entirely on where the bloat is in your app. Because the interactive form was a client component in both cases, its contribution to bundle size was the same across both the Maximal RSC and Minimal RSC cases. For the 200 custom defined components, there was a 2.8kb reduction when shifting them to RSC. In fact, the majority of bundle size reduction was from moving the syntax highlighting component to the server. For reference, the bundle size of a completely empty Next.js app is 76.4kB.

Btw, I had included the use of the syntax highlighting library in the benchmark because it’s probably the best candidate use case for RSC that I’ve seen. It’s a non-interactive component backed by a library that needs to include support for a long list of languages, which means it’s by nature going to be a big library. By moving to the server, there’s a massive improvement.

I was surprised at first to see that moving the two hundred generated components to the server only yielded a reduction of 2.8kb. Even if this were multiplied by 5x, this still would not be a significant source of bundle bloat, especially when you compare it to the 76.4kb bundle size of an empty Next.js app. After minification and gzipping, the code in your own javascript app can be pretty small.

Ultimately, for most apps, the biggest source of bundle bloat is going to come from their use of external UI libraries. Unfortunately, given that UI components are often interactive, you will end up being forced to render these components on the client. In my app, I only included libraries for a few common UI form elements and the cost in bundle size was was around 20x the bundle size of just my own custom code. The reason for this is that components from external libraries often come with their own UI helper libraries (eg. Styled Components, Emotion, lodash, moment.js etc) which themselves bring their own resources or might include dozens or hundreds of other libraries. If you’re not vigilant with which UI libraries you add to your project, then your bundle size can quickly grow in size. This is old news, but the point here is that RSC can’t help with this. And indeed, if I remove the syntax highlighting functionality from the benchmark, then using RSC where I can only gives a 1% reduction in bundle size.

Effects on Loading Metrics

We've been talking about optimizing bundle size, but we also care very much about page metrics, which attempt measure how quickly the page loads in a meaningful way. Below, I'm showing screenshots comparing the relevant page metrics. Theses were calculated by Catchpoint, a page auditing service that automatically runs the tests multiple times at 4G speeds and reports the median measurement.

Test: SSR-Only

Image description

Test: Minimal RSC

Image description

Test: Maximal RSC

Image description

We can also see massive difference in Total Blocking Time, which means the browser is spending less battery and spending less time in a non-interactive state. This is because the browser is just doesn't have as much code to parse and run.

This significant difference in Total Blocking Time from the Maximal RSC case from the others is largely the result of the syntax highlighting component being rendered only server-side. But many projects won't be using such a library. So what happens if we run the experiment again without the syntax highlighting included on page?

Test: SSR Only

Image description

Test: Minimal RSC

Image description

Test: Maximal RSC

Image description

Now we can see, that if we remove syntax highlighting, then the difference in bundle size between Minimal RSC and Maximal RSC test cases is barely noticeable. However, both of those test cases still noticeably outperform the SSR Only test case, which uses the Next.js Pages Router instead of the App router. One of the most impactful features of using the Next.js App Router is that async server components can stream html to the browser even before its own data has finished loading. You can even get this benefit without diving too far instead refactoring client components to server components. Just put your async data calls into server components wrapped by Suspense and you get easy wins.

Takeaways

The Next.js implementation of React Server Components in the App router is still cutting edge and adds complexity to your mental model of your app. Many developers will be wondering whether they should be using the new App Router or the Page Router. Based on the results of these benchmark tests, I'd recommend at very least using the App Router with the Minimal RSC strategy, which allows your app to benefit from component streaming.

The next question is whether it's worth it to go to the headache of converting client components to server components (ie. the Maximal RSC strategy). To answer this you should first be looking at your required libraries. Make a list of all the libraries that can exclusively used in server components without turning your component hierarchy into spaghetti. Then add up those libraries' contribution to bundle size in your app. This final sum will tell you how much bundle size reduction you can get from converting client components to server components. For many projects, particularly those which will include a large number of interactive UI components, React Server Components won’t make a significant impact on bundle size. But for projects with abnormally large libraries or for sites that are mostly static, the headache of using server components will be well worth it.

Are you hiring?

I'm currently job searching for a manager or lead role at a company building cool stuff. If you liked my article and would like to talk, email me at make.curiously at gmail.

💖 💪 🙅 🚩
rm-rf-poet
Alan J

Posted on September 14, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related