farmdev

Why Server Side Rendering In React Is So Hard

React first emerged as a powerful way to build living, breathing client side web applications. When my team at Mozilla set out to build a React frontend for addons.mozilla.org in 2016 we knew we needed server side rendering (SSR) for SEO and performance but we underestimated how much of a challenge it would be.

This is a deep dive into how SSR is fundamentally different from what React was designed for. If you're trying to add SSR to an existing client side app, you'll have to rethink many aspects. Building an SSR compliant app from scratch in 2019 will be easier (check out Next.js) but you still have to change the way you think in React.

My colleague Will Durand also gave a talk at JSDay in 2018 about this. It provides an overview of SSR and more details about the specific challenges at addons.mozilla.org.

Client Side Apps Are Infinite

Let's start with the idea behind server side rendering (SSR):

  1. Your Node.js server responds to HTTP requests using ReactDOM.renderToString(<App/>), delivering the rendered HTML.
  2. When your JavaScript bundle loads and executes on the page, React starts up but it has nothing to render. As the user interacts with your UI, it re-renders on the client.

Sounds simple, right? Let's consider an example of how client side React works: the user clicks a button in your UI, data loads, and the UI changes. This process of reacting to events is infinite: there is no explicit end state. An HTTP request / response is, however, finite.

How do you define the end state? Wait, how do you even define the initial state?

An App Begins By Loading Data

Before the user can click that UI button I was talking about, the app probably starts by fetching some data so it can show the first screen. A simple approach would be to fetch data when the component mounts and let React re-render when the data loads. In pre-hooks React, it might look something like this:

class UserProfile extends React.Component {
  async componentDidMount() {
    const user = await fetchUserFromAPI(this.props.userID);
    this.setState({ user });
  }

  render() {
    const { user } = this.state;

    if (user) {
      // fetchUserFromAPI has finished...
      return <span>{user.name}</span>;
    }

    return <span>Still loading...</span>;
  }
}

The render() method does two things:

  1. it renders an immediate loading screen;
  2. it renders the user's name after data has loaded.

First off, componentDidMount() won't execute during ReactDOM.renderToString()! You'd have to move the data fetching function to constructor() and probably switch to a state manager like Redux. Once you do that, you have another problem...

How Do You Finish Loading Data?

How does the server code know that fetchUserFromAPI() finished, user.name was rendered, and that it's safe to write an HTTP response? This is the first fundamental problem of SSR.

Most options involve inventing custom logic that will only run on the server which defeats the benefits of creating a universal app since it introduces a new, foreign code path. For example, you could put static loading functions on your components and rig up custom logic to traverse the JSX tree, calling each function and waiting for its promise. The Next.js approach looks like that: you define a static getInitialProps() function on each component to load data.

On addons.mozilla.org we went with a double rendering strategy (I don't recommend this). We use redux-saga to load data so we begin by rendering the app, letting each constructor kick off a saga then dispatch a special END_SAGAS action and wait for all running sagas to finish. The server rendering code looks a little like this:

const store = createStore();
const sagas = sagaMiddleware.run(localSagaModules);

// Let all components kick off their data fetching sagas.
ReactDOM.renderToString(<App store={store} />);

// Stop all running sagas with a magic action.
store.dispatch(END_SAGAS);
await sagas.done;

// Render everything again with the loaded data! Hah.
const html = ReactDOM.renderToString(
  <App store={store} />
);
expressResponse.send(html).end();

The first render triggers each constructor() which kick off the sagas and the second render builds the final HTML string after all sagas have dispatched data to the Redux store.

This approach is nice because the app executes in the same way on both the server and the client and this helps you understand execution flow. However, it starts to fall apart if the UserProfile component renders nested components that also need to load data. I'll get to that in a minute. The double rendering strategy is fragile and, again, I don't recommend it.

Data Persistence

The UserProfile component up above is naive in that it always begins loading data when it first mounts. This is a problem for SSR because a component's data is already loaded when client side rendering begins. If a naive loader like this were to reload its data on the client then you'd lose most of the performance benefits of SSR.

The loader has to become smarter (yay, more complexity). It has to look a little more like this, using Redux as an example:

class UserProfile extends React.Component {
  constructor() {
    const { dispatch, user, userId } = this.props;

    if (user === undefined) {
      // Imagine this triggers a saga to fetch data...
      dispatch({ type: 'FETCH_USER', payload: { userId } });
    }
  }

  render() {
    const { user } = this.props;

    if (user) {
      return <span>{user.name}</span>;
    }

    return <span>Still loading...</span>;
  }
}

function mapStateToProps(state) {
  return {
    // Select the user object stored in Redux state...
    user: state.users.currentUser,
  };
}

export default connect(mapStateToProps)(UserProfile);

This component now only fetches data when user is undefined so that's good but there are all sorts of new problems to think about! This new world of data persistence is very different from using setState() where local data only persists for the lifetime of a component. Here is a brain dump of some things you'd have to think about:

By supporting SSR you have gifted one of the hardest computer science problems to yourself and your team: cache invalidation. Dealing with data persistence is the second fundamental problem to SSR.

To be fair, you will probably have to deal with extreme data persistence for many client side app features anyway, especially when using Redux to persist state beyond the lifecycle of a component. With SSR it just becomes a bit more complex.

Data For Nested Components

What happens if UserProfile renders a nested component like UserNotifications that also begins by fetching some data?

class UserProfile extends React.Component {
  render() {
    const { user } = this.props;

    if (user) {
      return (
        <div>
          <span>{user.name}</span>
          <UserNotifications id={user.id} />
        </div>
      );
    }

    return <span>Still loading...</span>;
  }
}

If you want to render the nested UserNotifications component on the server it might get tricky. It wouldn't work with the double render approach since the second render is the one to initiate data fetching and that's too late. So, yeah, the double render approach is not so great. Even with the Next.js static getInitialProps() approach, I'm not sure how well nested data fetching is supported.

Maybe it's OK not to render your secondary content on the server. Sometimes this type of content doesn't need to be indexed by search engines. Unfortunately, this illustrates another fundamental problem of SSR: it's not always obvious that a given component is or is not rendering itself on the server. The isolated, composable nature of React components makes it unclear.

You vs. Third Party Libraries

Writing universal JavaScript takes additional considerations and most web related third party libraries only seem to consider web browsers. Node.js has notable differences, such as how throwing a ReferenceError for accessing properties on an undefined name (like window) might exit your process. More importantly, code that executes in an HTTP request handler (such as express) needs to be safe for concurrency. Finding third party libraries that are safe for SSR is another fundamental problem.

We've seen a handful of bugs on addons.mozilla.org where data from other users leaked across unrelated requests in the same server process. For example, the default way to use momentJS is not safe for concurrency and we found out the hard way. We even served cookies from other users accidentally because the cookie library we were using at the time was not safe for concurrency. Luckily, this didn't affect our authentication cookies which would have been catastrophic. These are not the types of bugs you want to be surprised by when using third party libraries.

Unique IDs Across Client and Server

When we tried to build a generic, Redux connected error component that could be created on the server and displayed on the client, we ran into a surprising problem: there is no built-in way to generate a predictable unique identifier in React.

We needed an ID for the error to store it in Redux state and link it to the container component. One approach would be to use a counter but that's hard to keep in sync between server and client. We ended up creating an ID by concatenating some of the container component's props; this worked but occasionally someone would forget to include an important prop and we'd be back to fighting the cache invalidation problem.

I Refreshed A Page And It Broke

Time after time, our QA team discovered bugs where the app would work fine when navigating client side (with react-router) but it would break with a 500 response if they hit the browser refresh button. The refresh, of course, was triggering a new code path: the server side render. This was the path less travelled during development so it was hiding many bugs!

We occasionally saw the inverse: a page loaded fine when rendered on the server but broke on the client. Either way, these bugs were usually related to the cache invalidation problem.

Security

In addition to normal web app security, there are some complications with SSR. First, you need to be careful about transferring initial application state from server to client. If it's serialized as JSON it needs be escaped so it's not susceptible to XSS attacks.

Secondly, you have to think hard about the common code you are sharing between client and server and what will end up in the client side bundle. If you use a common config object, you probably don't want to leak your server config to the client.

Developer Tools

Running code on the server for SSR complicated the standard tools we wanted to use for developing new features on addons.mozilla.org. We wanted to know what was happening on the server (logs, etc) so we either had to look in two places or access server logs in the client somehow.

We settled on a custom pino logger that streams server logs to the client with websockets. This lets us see all server side Redux actions that create the initial application state before beginning client side rendering. It doesn't show us actions in the Redux Developer Tool but we at least see them in the client side console.

We do have a config flag that lets us develop with SSR disabled -- this would show us all initial actions in the Redux Developer Tool but it won't expose SSR related bugs.

Sigh, Here We Are

So, yeah, we weathered many storms while supporting SSR on a high traffic app (11 million active monthly users). We've seen everything from race conditions to catastrophic third party library breakage, as mentioned up above. It's been a long journey and I'm tired.

As it turns out, the combination of SSR and subsequent client side rendering is a winning combination for performance. This, in addition to skeleton screens while loading makes addons.mozilla.org feel very fast. The Google Search Console shows that our SEO is good, too. I guess it was all worth it. Will it be worth it for you?

Next.js is probably the best solution for today's SSR React app but I haven't fully evaluated it. Alternatively, you may be able to pre-render each unique landing page of your app with something like Gatsby or prerender.io if you only have a small set of unique pages (we didn't). Pre-rendering might bypass most complexities of SSR while still gaining performance and SEO benefits. I'm not sure how you'd define end states for the pre-rendering process, though.