Menu
Menu Sheet Overlay
Search
Search Sheet

      Building High Performing Server-Side Rendered PWAs with CDN Caching

      Introduction

      Note This article provides an overview into building and testing high-performing server-side rendered Progressive Web Apps (PWAs). To learn how these PWAs differ from Mobify’s tag-loaded PWAs, read our overview.

      Server-side rendered (SSR) PWAs are Mobify’s fastest PWA technology. SSR PWAs run on Mobify’s servers, and integrate with your site at either the DNS level or through your CDN. The resulting PWA is served from a cache to maximize speed.

      In this article, we’ll show you how to build the fastest-possible SSR PWA by making sure that most pages are cached, and ensuring any un-cached pages render quickly. We will also discuss other functionality you’ll need to implement throughout the build, such as making sure HTTP status codes are handled. To wrap up, we will introduce some helpful debugging tools, so you can pinpoint and diagnose any performance issues that may arise.

      Building cacheable SSR PWAs

      For an optimal user experience and for search engine optimization, it’s important that content is rendered as quickly as possible. In server-side rendered PWAs, the quickest response is the CDN-cached response. This response is essentially instantaneous:

      Mobify CDN Cache Hit

      Mobify’s content delivery network (CDN) checks its cache to see if it has a fresh version of that page available. If it does, it responds right away.

      In addition to the user experience benefits, the near-instant page load speed ensures that web crawlers will index the page for search engines.

      When a page is not cached, the response process is slower because we need to run the React app server-side to get the output:

      Mobify CDN Cache Miss

      The request/response flow is much longer when a page is not cached.

      How Mobify's CDN caching works

      Mobify's CDN caches responses to requests. The cached responses are indexed by the request URL (hostname, path and query string) plus whatever headers are configured to be forwarded to the origin. For the SSR server, only headers relating to device type (mobile, desktop or tablet) and request class are forwarded, so only those headers are used to look up cached responses. URL fragments are ignored when looking up cached responses.

      For example: responses to requests for the URLs www.example.com, www.example.com/path, and www.example.com/path?a=1 will all be cached separately, since the URLs are different. However, a request for www.example.com/path#123 will match www.example.com/path, since fragments are ignored.

      Once the request comes in, Mobify’s CDN first checks the cache: is a matching response found? If it is, the CDN can respond right away. (Note that stale responses disappear from the cache.)

      A cache hit means that the CDN checked the cache for a matching response, and was able to find a matching response right away. This results in an immediate response.

      A cache miss means that either a matching response was not found in the cache, or the content was stale. In this case, the CDN needs to forward the request to the origin. (The Mobify Platform sets up several different origins: the SSR server, bundle files, and separate origins for any project-specific proxies that are configured.) This request flow is orders of magnitude slower than finding the resource in the cache.

      You can test if you’re getting a cache hit or miss in the HTTP response headers. Just look for the x-cache header, and you'll either see “x-cache: Miss from cloudfront” or “x-cache: Hit from cloudfront".

      To improve PWA performance, we need to increase the number of cache hits.

      Maximizing performance by improving the cache hit rate

      To achieve a high cache hit rate (caching as many pages as possible), you’ll need to create cacheable SSR representations of pages, customize cache control headers, and use the Mobify Platform’s request processor to map many URLs to a small number of matching responses. Let’s go through each technique in detail.

      Creating the SSR representation of a page

      To build fast, cacheable pages, responses should be appropriate to serve to all users, and they should be appropriate to serve for some duration of time. To achieve this, pages for caching should not contain any personalized or frequently-changing content.

      Personalized information such as a user’s name, number of items in the cart, and preferred payment method is inappropriate to cache and send to different requestors. The same is true for information that changes frequently, such as price, remaining inventory, or sales promotions. If a page includes personalized information, that page is not relevant to other users.

      Frequently-changing content can also be less suitable for caching, because there’s a risk that we would respond with stale content. Imagine a product details page which typically shows the remaining inventory for a product. We would exclude the inventory from the cacheable version of the page, to prevent rendering a stale inventory count.

      Because we cannot include any personalized or frequently-changing information in the server-side rendered page, it will always be a subset of the client side page. That is, the client side version of the page will have the addition of any personalized or frequently-changing content. This is critical for any entry pages in your site that are relevant to guest users, such as the home page, category listing, product listing, and product detail pages, as they need to leverage the cache to load as quickly as possible. Personalized or frequently-changing content can be requested once the PWA has loaded on the user’s device.

      You can achieve this using the isServerSideOrHydrating flag in the Redux store. Let’s walk through an example:

      Let’s consider a product detail page, as an example. Typically, cacheable content on this page would include the product name, images, description, and price. We would not cache personalized content such as the shopping cart, or saved items. We would also avoid caching frequently-changing information such as the price of remaining inventory.

      Example: cacheable product description component

      // To use the `isServerSideOrHydrating` state, we must import its selector
      import {isServerSideOrHydrating} from 'progressive-web-sdk/dist/store/app/selectors'
      
      
      // This component renders cacheable content, and non-cacheable content
      const ProductDescription = ({bag, img, isServerSideOrHydrating, name, price}) => {
          return (
              <div>
                  <header>
                      <h1>{name}</h1>
                      <div>
                          <img src="/cart.png" />
      
                          {/* The shopping bag consists of personalized content,
                            * so we only render it client side */}
                          {!isServerSideOrHydrating && <span>{bag.count}</span>}
                      </div>
                  </header>
      
                  <main>
                      <img {...img}/>
                      <p>${price}</p>
      
                      {/* Here, the sale price and inventory are only shown
                        * client side, because they change frequently */}
                      {!isServerSideOrHydrating &&
                          <>
                              <p>Sale: ${sale}</p>
                              <p>Stock: {inventory} left!</p>
                          </>
                      }
      
                      <button>Add to cart</button>
                  </main>
              </div>
          )
      }
      
      
      // To use the `isServerSideOrHydrating` state, we must map it to props
      const mapStateToProps = createPropsSelector({isServerSideOrHydrating})
      export default connect(mapStateToProps)(ProductDescription)
      

      Setting optimal cache lifetimes

      The next step toward maximizing your PWA’s cache hit rate is setting cache control headers. Cache control headers determine the length of time that a page can be stored in the CDN cache. If you do not set cache control headers, the page will not be cached!

      The default cache control headers should be customized depending on the type and status of the page. For example, a content page that rarely changes can be safely cached for a very long time. In contrast, a product listing page that’s frequently updated with new products might require a short cache lifetime, such as fifteen minutes. Whenever possible, choose long cache lifetimes in order to maximize the cache hit rate.

      Set cache control headers either through the responseHook class method (where you can set s-maxage to a time value in seconds), or by using a template by template approach (only available to projects which started on or after March 28, 2019). Explore our examples outlining the two approaches below, or you can continue reading about HTTP caching.

      Example: the responseHook class method

      The following example can be applied within your project’s ssr.js file, which lives in the same directory as your PWA’s main.jsx file:

      • Projects which started before March 28, 2019: /web/app/ssr.js
      • Projects which started on or after March 28, 2019: /packages/pwa/app/ssr.js
      class ExtendedSSRServer extends SSRServer {
          // ...
      
          responseHook(request, response, options) {
              response.set(
                  'cache-control',
                  `max-age=${cacheTime}, s-maxage=${cacheTime}`
              )
          }
      }

      Example: template by template

      This method is only available to projects that started on or after March 28, 2019. It uses the trackPageLoad function that comes default with a new project.

      // First, import the `trackPageLoad` function
      import {trackPageLoad} from '../../page-actions'
      
      // An `initialize` action is the promise required to by `trackPageLoad`
      import {initialize} from './actions'
      
      class MyTemplate extends React.Component {
          // ...
      
          componentDidUpdate() {
              const {trackPageLoad, initialize} = this.props
      
      
              // The trackPageLoad has three arguments:
              //
              // `promise`: usually an action that fetches the page data
              // `pageType`: a string that identifies the current page
              // `getResponseOptions`: a callback that takes the value 
              // returned by the promise. It may return a response
              // options object to customize the response.
              trackPageLoad(initialize, this.pageType, (result) => {
                  // `result` is the value eventually resolved from
                  // the `initialize` promise.
                  const {statusCode} = result
      
                  // A `responseOpt` object is created, and it will be
                  // used to customize the response sent to the user.
                  const responseOpt = {statusCode}
      
                  // In the case of a 200 status code, we can customize
                  // the response headers as follows:
                  if (statusCode === 200) {
                      responseOpt.headers = {
                          'Cache-Control': 'max-age=0, s-maxage=3600'
                      }
                  }
      
                  return responseOpt
              })
          }
      }
      
      const mapStateToProps = createPropsSelector({initialize})
      export default connect(mapStateToProps)(MyTemplate)

      You can test that your cache controls are present in the response headers by inspecting your network requests, using Chrome DevTools’ Network tab. Alternatively, you can use your command line interface with the following curl command, which will show all response headers. Simply replace "" with the URL you’re interested in:

      curl --dump-header - --silent --output /dev/null <enterYourSiteURLhere>

      Using the request processor to ensure as many URLs map to a small number of matching responses

      Mobify’s request processor handles requests as soon as they’re received by the Mobify Platform, before the CDN looks for cached responses. You can use it to improve cache hits by modifying parts of a request, to ensure that similar URLs map to the same response.

      To learn more about using the request processor to improve your PWA’s performance, read our request processor tutorial.

      Maximizing performance by making sure uncached pages are fast

      Before a page can be cached, it must first be rendered as a response from the server-side rendering server. With that in mind, we want to ensure that rendering of responses is as fast as possible. This is important for both users’ experience and for SEO. While users may abandon a site that’s slow to load, Googlebot has an upper bound on how long it will wait for the first byte when crawling. If your rendering of the page exceeds that limit, Googlebot won’t crawl your page! To avoid this, we need to keep the response time under 3 seconds, and ideally much quicker.

      Your PWA’s rendering speed correlates directly to the amount of time it takes to fulfill these requests. Consider the following techniques to improve response times:

      1. Test and monitor your API response time
      2. Check that your Mobify target is as close to your API datacenter as possible
      3. Use cache control headers on your API responses, where possible
      4. Reduce the size of the Redux store in the response from the SSR server, which will decrease the size of your initial HTML, making it quicker to load

      In many cases, there are two main culprits that slow your PWA’s uncached rendering time: network requests to get data for the page on the server-side, and the speed of parsing.

      When building your page on the server-side, strive to have the SSR server make as few external requests as possible, and avoid making requests in serial. Ideally, all data for a page should come from only one external request, or two requests made in parallel. Making more than a few external requests, or making the requests in serial will drastically reduce performance. In addition to external network requests, a significant contribution toward initial load speed is the time it takes for requests to get from the SSR server to the backend server, and for the responses to be returned.

      For builds using a scraping connector, the speed of parsing can also have a significant impact on rendering time. While DOM operations are extremely fast in browsers, these same operations are not as fast in the DOM-like environment that the SSR server uses. This can cause some selectors to be slow. When possible, use simple selectors to parse data.

      In general, strive to do the least amount of work necessary to render the page. Wherever possible, avoid long-running computations or multiple React rendering passes, which will slow down server-side rendering.

      Handling HTTP status codes

      For search engine optimization (SEO) as well as performance, it’s important to set the HTTP status code for all responses and cache the page accordingly. For example, if Googlebot lands on a page such as www.example.com/product/does-not-exist, we will want to set a status code to communicate that no product was found. Otherwise, it may misrepresent that page in search listings.

      We also recommend customizing cache lifetimes for different status codes. For example, you may consider setting a shorter cache lifetime in the case of a transient error, when there is a problem connecting to the ecommerce API. This would ensure that you can serve your users the correct version of the content more rapidly when the error is resolved. Meanwhile, successful responses should have a longer cache lifetime to maximize cache hits.

      You can set the status code for responses in the parameters to ssrRenderingComplete (within progressive-web-sdk/dist/utils/universal-utils) or in the responseHook, which is located within pwa/app/ssr.js.

      Testing and debugging the local backend

      There are a few key tools you can access to debug your server-side rendered PWA’s local backend. Here, we’ll discuss the steps to access Chrome DevTools’ breakpoints and profiler.

      Using ssr:inspect to debug with breakpoints

      With a simple command, you can use Chrome DevTools to debug the local backend of your server-side rendered PWA. Here are the steps:

      1. Open your favorite command line interface
      2. Navigate to the following directory:
        • For projects that began prior to March 28 2019, navigate to your project’s web directory.
        • For projects that began on March 28 or later, navigate to your project’s packages/pwa directory.
      3. Once you're in the correct directory, run the following command:
        • For projects that began prior to March 28 2019, run npm run ssr:inspect.
        • For projects that began on March 28 or later, run npm run start:ssr:inspect. Running this command allows you to use DevTools to inspect the processes that are running in the background.
      4. Open your Chrome browser, and enter the URL chrome://inspect.
      5. The page will show a link for running the SSR server, click that link.
      6. DevTools will open up in a new window, connected to running the SSR server.
      7. Add breakpoints throughout the file, and inspect. As the server runs the PWA, use the Console within the DevTools window to view messages, errors and warnings from the server. (Notice that the console logs the same messages as the command line interface. The main advantage of using the console is that it allows you to leverage Chrome DevTools’ JavaScript debugging features.) To learn more about debugging with breakpoints, read the guide Get Started with Debugging JavaScript in Chrome DevTools.

      Profiling the backend to pinpoint performance bottlenecks

      Chrome DevTools’ Profiler is another key tool for debugging your PWA. It provides a visual graph of all the JavaScript processes that run on the backend, highlighting how long each process takes to run. Because of the tool’s visual nature, you can use it to identify lengthy processes that may be affecting the performance of your PWA. To use the Profiler tool:

      1. Complete the steps above to open the DevTools console.
      2. Within the DevTools console, click on the Profiler tab.
      3. Within the Profiler tab, click the record icon in the top left of the console. (If you hover over the record icon, the tooltip will specify “Start CPU recording”.)
      4. Open another Chrome window, and navigate to the page you’re interested in profiling.
      5. As the page loads, you will see messages logging in your command line interface. Once you stop seeing log activity, click the recording icon again to stop recording. Your recorded profile will appear within the DevTools window on the left, under Profiles.
      6. Click on the profile you just recorded to access the visual graph. Here, you’ll see time on the x-axis, and the depth of the stack on the y-axis.
      7. Now, you can use the x- and y-axes of the visual graph to help pinpoint performance issues.

      Diagnosing issues with the Profiler tool

      The x-axis is the most important source of information in the chart, as the width of a given process will tell you the time that it takes to run. Zoom-in to any given section, and identify long-running processes with a goal of understanding why they may be slow. You can inspect whether it’s your code, from a dependency, or native code from the browser. In addition, the y-axis provides useful insights. If the stack is very tall you may want to evaluate if a process is too deep, as it relates to memory issues.

      Learn more about the profiler and other techniques to analyze runtime performance in Google’s Chrome DevTools documentation.

      IN THIS ARTICLE:

      Feedback

      Was this page helpful?