This case study shows one way to implement partial Server-Side Rendering (SSR) and achive performance gains without big investments in middleware for cloud-based platforms.

Cloud CMSs: quick background

All cloud CMSs, such as Salesforce Commerce Cloud (SFCC) and Magento, have their pros and cons. In those CMSs, we have lots of restrictions, but the major one for the purposes of this article is that we do not have access to the server, so we can’t use Server-Side Rendering.

SFCC (ex Demandware) is cloud-based unified e-commerce platform for B2C retailers. Its core is written in Java but clients can extend it with JavaScript which SFCC transpiles to Java.

Our application is built with React and consumes JSON APIs returned from our headless SFCC.

If we want the performance gains of the SSR we have two options:

  1. Create middleware between the React app and the backend SFCC
  2. Create Partial SSR with what you have from the system

In our project, we can’t go with option 1 because of budget, resources and time. That’s why we chose option 2 and this post describes what we did. But first let’s start with some background information.

React, SSR, Hydration, Progressive Hydration

If our goal is to make our React website fast, one of the best things we can do is to use SSR for the whole application. For this to work, we need control over the server where the application is hosted and render the React app using, for example, Next.js or NodeJS.

SSR generates complete HTML for the page and returns it to the browser.

<html>
  <head>
    Some meta, CSS, scripts, third-parties etc.
  </head>
  <body>
    <div id="app">
      <div id="app-root">
        <header>
          Logo, username etc.
          <nav>The navigation items</nav>
        </header>
        <div id="app-container">
          All the content between header and footer
        </div>
        <footer>
          Copyright and links stuff
        </footer>
      </div>
    </div>
  </body>
</html>

That’s ok, now we just need to use hydration to let React attach all events handlers that it needs.

ReactDOM.hydrate(element, container[, callback]);

With that, we get approximately 20% faster in most of the metrics – LCP, Speed Index and TTI – but we will get a little bit slower Time to first byte (TTFB), because the backend needs additional time to SSR the application.

But we can improve the app even further: we can apply React Progressive Hydration. With Progressive Hydration, React can attach only the events for elements that are visible in the initial viewport, so we can further reduce JavaScript’s execution time. I won’t discuss Progressive Hydration in detail, but here are a few poiters for you to learn more: Dan Abramov Progressive Hydration demo, Progressive React, SSR React and Hydration.

Problems

Since we’re using SFCC we are not able to do the SSR described above, that’s why we had to think about what we can do in order to achieve similar results as if we had SSR.

Our Homepage and Category Landing Pages are pure HTML, CSS and a little bit of JavaScript that are created in the CMS from a WYSIWYG editor, again, that’s a limitation of the platform. This content is created by third party which is responsible for the whole dynamic content on the platform. Then this content (HTML, CSS, JS) is provided via JSON API that the React app gets and fills the app-container div.

Example:

let content = {
  "result": {
    "html": "ENCODED HTML/CSS/JS from the WYSIWYG editor"
  }
};
render() {
  return (
    <div dangerouslySetInnerHTML={ __html: content.result.html } />
  );
}

With this approach, the end result that the customers see is like this:

React no partial SSR

Problem one

What we can return directly from the backend is the HTML below, which is not enough for the React app to hydrate.

<html>
  <head>
    Some meta, CSS, scripts, third-parties etc.
  </head>
  <body>
    <div id="app-shell">Static Header</div>
    <div id="app-container">
      Content between header and footer
    </div>
    <div id="app"></div>
  </body>
</html>

Problem two

In order to use React and the hydration mode, we must provide the whole DOM structure of the React-generated HTML. It is a React app, almost every HTML is generated by the React and the JSON API that it consumes. With that, we don’t have for example the HTML of the <header> and <footer>. This is the maximum of what we can do as server-side generated HTML:

<html>
  <head>
    Some meta, CSS, scripts, third-parties etc.
  </head>
  <body>
    <div id="app">
      <div id="app-root">
        <header></header>
        <div id="app-container">
          Content between header and footer
        </div>
        <footer></footer>
      </div>
    </div>
  </body>
</html>

If we return this HTML without the content of the <header> and <footer>, tags, React will throw an error, because it needs the whole DOM structure in order to attach the events and cannot fill in the missing elements.

So what we did?

First of all, initially we thought that we can just create the above HTML structure and React will fill in the missing elements only, but few hours and errors later we figured out that React needs whole React-generated HTML in order to hydrate.

Step One

Return what we have as HTML from the backend and the initial structure looks like that:

<html>
  <head>
    Some meta, CSS, scripts, third-parties etc.
  </head>
  <body>
    <div id="app-shell">Static Header</div>
    <div id="app-container">
      Content between header and footer
    </div>
    <div id="app"></div>
    <script src="OUR_INITIAL_REACT_BUNDLES"></script>
  </body>
</html>

Step Two

Our initial App architecture is like this:

App.js:

class App extends Component {
  render() {
    <div className='app-root' >
      <RouteList {...this.props} />
    </div>
  }
}

RouteList.js

class RouteList extends Component {
  render() {
    return (
      <React.Fragment>
        <Header />
        <div className="app-container">
          <React.Suspense fallback={<span />}>
            <Route exact path='/' render={(props) => <Category {...props} />} />
            etc.
          </React.Suspense>
        </div>
      </React.Fragment>
    )
  }
}

When React is ready, in RouteList we delete the app-container and app-shell divs from Step one and let our <Category /> component get the HTML again by making a request to the JSON API and render it.

Something like this:

class RouteList extends Component {
  componentDidMount() {
    let elem = document.getElementById('app-shell');
    elem.parentNode.removeChild(elem);
    elem = document.getElementById('app-container');
    elem.parentNode.removeChild(elem);
  }

  render() {
    return (
      <React.Fragment>
        <Header />
        <div className="app-container">
          <React.Suspense fallback={<span />}>
            <Route exact path='/' render={(props) => <Category {...props} />} />
            etc.
          </React.Suspense>
        </div>
      </React.Fragment>
    );
  }
}

Then we have our first Partial SSR!

Step Three

The second step makes an additional request to get the same content that it’s deleting, so we have changed the HTML returned from the first request:

<html>
  <head>
    Some meta, CSS, scripts, third-parties etc.
  </head>
  <body>
    <div id="app-shell">Static Header</div>
    <div id="app-loader"></div>
    <script>
    const appContainer = {
      html: '<div id="app-container">Content between header and footer</div>'
    };
    const appLoaderElement = document.getElementById('app-loader');
    appLoaderElement.innerHTML = decodeURIComponent(appContainer.html);
    </script>
    <div id="app"></div>
    <script src="OUR_INITIAL_REACT_BUNDLES"></script>
  </body>
</html>

Then again in the RouteList component, we delete the app-loader div but the <Category /> component checks if appContainer is not empty and get the HTML from it and not make an additional request. (Yup, we know, it is ugly.)

The result is this timeline:

react partial SSR white gap

(Final) Step Four

That white gap that you see above is ruining all our previous efforts, the SpeedIndex and LCP won’t improve because of the gap and, more importantly, it’s really awful for the user.

This is happening because we use React.lazy() and <Suspense> on routing level for components that are not <Header> and we are passing an empty <span> to the fallback attribute, so while React is waiting the <Category /> to load, it shows empty span below the Header.

<React.Fragment>
  <Header />
  <div className="app-container">
    <React.Suspense fallback={<span />}>
      <Route exact path='/' render={(props) => <Category {...props} />} />
      etc.
    </React.Suspense>
  </div>
</React.Fragment>

To fix the gap we pass the JS global variable containing the HTML as the fallback:

<React.Fragment>
  <Header />
  <div className="app-container">
    <React.Suspense fallback={ <div dangerouslySetInnerHTML={ __html: decodeURIComponent(appContainer.html) } } >
      <Route exact path='/' render={(props) => <Category {...props} />} />
      etc.
    </React.Suspense>
  </div>
</React.Fragment>

dangerouslySetInnerHTML is not good practice at all, it can expose you to cross-site-scripting attack but we don’t have any other choice here except to live with it for now 🙂

And the result:

react partial SSR without white gap

The Performance improvements

While the above code is not the prettiest one, our performance improvements are significant for Homepage and Category Landing Pages:

FMP and FCP results

Lighthouse results

Thank you for reading this long article, I will be happy if you have any comments or suggestions 🙂

ABOUT THE AUTHOR
Lyubomir Angelov

Lyubomir Angelov (@angelovcode) is a Web Performance enthusiast working as Development Team Leader at Isobar Commerce. Contributing to the web since 2007 he went through hundreds of platforms and solutions. He is passionate to work with people to build great platforms. Perfectionist, sports man and tries to learn lock picking.

Leave a Reply

You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>
And here's a tool to convert HTML entities