Server-side vs client-side rendering in React apps (Technical)

Author: | Created on: Feb 27th 2019 | Last edited on: 22 days ago


In this tutorial, we will take a deeper look at the two types of rendering for web apps: server-side and client-side. I will walk you through basic setup, testing, and comparison of two pages:

  • SSR page (server-side rendered page)
  • CSR page (client-side rendered page)

Once you understand the pros and cons of SSR and CSR, you will have a better framework to decide whether to use SSR or CSR for particular pages in your web application.

In this tutorial, you will run experiments using a basic React-Next-Express app launch . To clone this app to your local machine, run:

git clone git@github.com:builderbook/builderbook.git

Navigate to tutorials/3-end. Inside this folder, run yarn or npm install to install all packages. Start the app with yarn dev.

If you don’t have time to run the app locally, I deployed this app at: https://ssr-csr.builderbook.org launch

This tutorial has two parts:

  • Setup of SSR and CSR pages
  • Testing and comparison

If you find this article useful, consider starring our Github repo launch and checking out our book launch , SaaS boilerplate launch and Async launch .


Setup of SSR and CSR pages

Here, I’ll give an overview of the setup for our SSR and CSR pages. If you want to dig deeper, check out the complete code for each page:

To save time on the setup for server-side rendering, I use the Next framework to render a React page on the server. Internally launch , Next.js uses React’s server-side methods renderTostring() launch and renderToStaticMarkup() launch to render pages on the server.

On both SSR and CSR pages, we will display a list of items. I’ll explain how to create them in the sections below.


SSR Page

For the SSR page, we use the getInitialProps() method of Next.js with async/await to call and wait for a getList API method. The method getInitialProps() populates props of the SSR page component with data. We define the page component as a child of ES6 class:

class SSR extends React.Component {
  static async getInitialProps() {
    const list = await getList();
    return { list };
  }

  render() {
    const { list } = this.props;
    return (
      // some HTML code that displays a list of items
    );
  }
}

Note that the getList API method (located at lib/api/public.js) is universally available, meaning it’s available on both client and server. Check out the code launch for this method. The method sends a GET request to an API endpoint and waits for a response with data:

export const getList = () =>
  sendRequest('/api/v1/public/list', {
    method: 'GET',
  });

We discussed sendRequest method in detail in our other tutorial launch . If you want about learn about sendRequest and fetch in more depth, check out that tutorial our book.


CSR page

Now let’s build the CSR page. For this page, we will use the lifecycle method componentDidMount(). We could’ve used componentWillMount(), however this method is in the process of depreciation launch . In addition to that, our component is a simple list. React renders it quickly, so the time gain from using componentWillMount() would be small. componentWillMount() gets called before rendering. Read more about both lifecycle methods in the official docs launch .

At first, we will render a CSRWithData component without data. Instead of data, we will show:

<p>loading...(CSR page without data)</p>

Then we will call componentDidMount(), which calls and waits for the getList API method to return data. Once getList returns data, we re-render the CSRWithData component by using this.setState:

class CSRWithData extends React.Component {
  state = {
    list: null,
    loading: true,
  };

  async componentDidMount() {
    NProgress.start();
    try {
      const list = await getList();
      // console.log(list.listOfItems);
      this.setState({ // eslint-disable-line
        list,
        loading: false,
      });
      NProgress.done();
    } catch (err) {
      this.setState({ loading: false, error: err.message || err.toString() }); // eslint-disable-line
      NProgress.done();
    }
  }

  render() {
    return <CSR {...this.props} {...this.state} />;
  }
}

Here is our CSR child component that returns either loading UI or data:

function CSR({ list, loading }) {
  if (loading) {
    return (
      <div style={{ padding: '10px 45px' }}>
        <p>loading...(CSR page without data)</p>
      </div>
    );
  }

  return (
    // some HTML code that displays list of items
  );
}

Important note about CSR: similar to SSR, we will use async/await for componentDidMount (instead of getInitialProps) to call and wait for the same getList API method. This universally available getList API method runs on the server for a SSR page and on the client for a CSR page. Later in this tutorial, we will come back to this fact when we test the loading behavior of both pages.

Both pages are ready for testing. Before we test, let’s briefly mention the Express route that handles the request from the getList API method and sends a response that contains data (the list of items). Here’s the Express route with async/await (for more detail, see server/app.js):

server.get('/api/v1/public/list', async (req, res) => {
  try {
    const listOfItems = await list();
    res.json({ listOfItems });
    // console.log(listOfItems);
  } catch (err) {
    res.json({ error: err.message || err.toString() });
  }
});

And here’s the server-side method list that generates an array of 20,000 objects (for more detail, see server/list.js):

export default function list() {
  const n = 20000;
  const array = [];

  for (let i = 0; i < n; i += 1) {
    array.push({ name: `Item ${i + 1} of ${n}` });
  }

  // console.log(array);

  return array;
}

Time to test. Either clone our public repo launch and run the app locally with yarn dev or navigate to https://ssr-csr.builderbook.org launch .


Testing and comparison

In this section, we will load the SSR and CSR pages to compare their loading behavior and metrics.

Our index page looks like this:

Async

This page has two links in its Header. The links open in new tabs. Click the SSR link and observe the loading behavior:

Async

Next, click the CSR link and observe the loading behavior:

Async

There is a striking difference between SSR and CSR in terms of loading UX. In the SSR case, we see a completely blank page for a short period of time before the server-side rendered page with data arrives to the client. In the case of CSR, we see a client-side rendered page without data. We show a placeholder “loading..(CSR page without data)” while the CSRWithData component waits for data and re-renders. Once data from the list is loaded, both pages look the same.

Thus, UX-wise, your choice is to either initially show a brief flash of a blank page (SSR) or, for a bit longer, a page with a loading placeholder (CSR).

To compare the loading time, we need to inspect both pages with Chrome Developer tools. Qualitatively, a list of 20,000 items appears on the page noticeably faster in the SSR case, but we want to confirm if this is quantitatively true. In this tutorial, we inspect pages using Developer tools > Network and Developer tools > Performance.

For each page, do the following:

  • Open the page
  • Open Developer tools > Network while on the page
  • Check the box Disable cache
  • Reload the tab

Developer tools > Network for SSR page:

Async

Developer tools > Network for CSR page:

Async

The CSR page makes just one more request (9 vs 8) than the SSR page. The client-side method getList sends this extra request to the server to fetch the list (see the last item of the requests for the CSR page). However, this request starts with ~1 sec delay, after main.js from Next.js finishes loading. In other words, the SSR page requires one less trip over the network and thus saves time.

On the other hand, the server takes time to render a page before sending a response, and the first response from the server already has data that takes time to upload/download over the network. This results in a delay. See the first item on the the list of requests for both SSR and CSR pages. I drew a sketch to summarize these observations:

Async

Another observation is the amount of total data transferred. For SSR, the total amount is 172KB. For CSR, it is 157KB. That’s expected, since the response for SSR returns both page HTML (+other data) + list data, and the response for CSR returns page HTML (+other data) only.

So far, we can make two conclusions. If the network is very slow, the extra trip over the network will be slow, and a SSR page will show the list data sooner. The large main.js file delays the extra request that is sent for the CSR page.

Developer tools > Network is useful for inspecting timing for req-res cycles, but it does not tell us when a user actually starts seeing our list of items. To measure this metric, we will use Developer tools > Performance.

For each page, do the following:

  • Open the page
  • Open Developer tools > Performance while on the page
  • Check the box Screenshots
  • Click the icon Start profiling and reload page

Developer tools > Performance for SSR page:

Async

Developer tools > Performance for CSR page:

Async

Take a look at the second row with the sequence of screenshots. On your actual time traces, hover over with your mouse to find the time point at which our list of items first appears on the page. For the SSR page, it is 1320 ms. For the CSR page, it is 2760 ms. If your network is slow, the time point at which data appears on the CSR page will be more delayed.

Previously, we only had a qualitative conclusion, but now we have a quantitative metric that confirms that our list of items appears sooner on the SSR page. This difference becomes larger if you try sending a larger list, for example 50,000 items instead of 20,000 items. In a real situation, your data (dynamic data, not page HTML and main scripts) is probably way smaller. Thus, testing for larger data makes little sense.

Let’s make actionable conclusions based on what we learned in this tutorial.

Consider using a SSR page when:

  • The network is slow.
  • The server has plenty of resources to render the page with data. Remember, Node is single-threaded and heavy computation may block incoming requests ( read more here launch ).
  • The delay before data shows up is short. In other words, the brief flash of a blank page is perceived as fast loading or not noticed by users.
  • The main scripts (in our example, main.js) is large and loads slowly. For our SSR page, the very first request asks for a rendered page with data. Unlike the CSR case, there is no delay in sending this request. In CSR, our app sends an extra request only after the app finishes loading the main scripts.
  • SEO is important. Googlebot and other search engine bots properly index a SSR page.

Consider using CSR page when:

  • The network is fast.
  • The server has few resources to spare for server-side rendering.
  • The delay before data shows up is significant (you have to display a lot of data). In other words, users need to see some reassurance that the page is loading, for example a progress bar launch , loading spinner, or some other placeholder.
  • The main scripts (in our example, main.js) is small and loads fast. In that case, the app loads the main scripts quickly, and the extra request that asks for dynamic data gets sent sooner.

The choice does not have to be binary. In Next.js, if a page uses getInitialProps, then the page renders with data on the server for the initial load. However, for subsequent loads, when a user navigates to the page via <Link> or Router.push, getInitialProps runs on the client and the page is client-side rendered. Thus, the app uses the best of both rendering worlds.

In addition to the above setup, you may choose to use <Link prefetch> for navigation (read more about prefetch launch ). This ensures that page HTML is prefetched in the background, although without dynamic data. If we used <Link prefetch> for the CSR link instead of the <a> tag (inside the Header component), then the list of items would appear sooner on our CSR page. Because HTML of the CSR page would be prefetched in the background, the getList method would be called sooner. As a result, a page without dynamic data would load almost instantly after a user clicks on the link.

If you learned something from this article, consider giving a star to our Github repo launch and checking out our book launch where we cover this and many other topics in detail.

If you are building a software product, check up our SaaS boilerplate launch and Async launch , a team communication framework and tool for small teams of software engineers).