Skip to main content
Apeleg Blog

Progressively loading CSR pages

·9 mins

Progressive enhancement #

Progressive enhancement is a design philosophy that consists in delivering as much functionality to users as possible, regardless of the manner they may access our system. This ensures that a baseline of content and functionality is available to all users, while a richer experience may be offered under the right conditions. For instance, the website for a newspaper may make the text content of its articles available to all users through HTML markup, while providing interactive media content for users with a capable browser.

Nowadays, client-side scripting is used in most websites to provide various levels of functionality. Frameworks like React, Angular and Vue.js allow developers to deliver highly interactive experiences, which in turn has made modern rich web applications feasible, such as spreadsheet applications that run completely in the browser. Because of many of the conveniences that they provide, these frameworks are also used for all sorts of websites and not just for those with complex interactivity.

The principles of progressive enhancement can be applied to all websites, no matter how they are built or what they do. For websites that are open to the public, progressive enhancement is essential to ensure the best possible experience for our users.

Progressively loading web applications #

Server-side rendering vs. client-side rendering #

A website can be server-side rendered, client-side rendered or use a combination of both approaches.

When an application is server-side rendered, the server delivers HTML markup with content that is ready for a user to consume. In contrast, an application that is client-side rendered constructs the document presented to users with the help of client-side scripts.

By default, applications built with modern frameworks will be rendered on the client side, whether the content is static or generated dynamically from external parameters (such as data returned from an API).

For example, a typical React application may have some HTML like this:

<!DOCTYPE html>
<html>

<head>
    <title>Example Page</title>
    <script
        crossorigin
        src="https://unpkg.com/react@18/umd/react.production.min.js">
    </script>
    <script
        crossorigin
        src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js">
    </script>
</head>

<body>
    <div id="root"></div>
    <script src="basic.js"></script>
</body>

</html>

With the corresponding rendering logic in basic.js, which could look like this:

const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(React.createrrorElementement('div', null, 'Hello, World!'))

This simple example will show a page with the text Hello, World!.

Screen capture showing a simple client-side rendered page
A modern browser will execute the script to render the application, which in this case displays the text Hello, World!.

Since in this example all of the rendering code depends on client-side scripting, users with an older browser, one that doesn’t support scripts or one with scripts disabled will see a blank page.

Screen capture showing a blank page in a text-based browser
The Links2 text-based browser doesn’t support scripts and will show a blank page.
Screen capture showing a blank page in an older browser
The now-depreacted Internet Explorer 11 browser doesn’t support modern scripts and will display a blank page.

The situation of having a blank page in certain browsers can be avoided by using server-side rendering. Depending on the framework in question, the approach can be different. For React, this can be accomplished with ReactDOMServer. Following the principles of progressive enhancement, server-side rendering should be used whenever possible or feasible to give users the possibility to use and interact with our page. The advantage of this approach is that client-side scripting can still be used to enhance interactivity and functionaliy if the client supports it. However, server-side rendering might not always be a viable option, depending on what our page is meant to do. For instance, a highly-interactive rich application, like an online image editor or game, may require scripting to provide an adequate experience beyond the capabilities of a statically-generated document.

Regardless of whether server-side rendering is a viable option for our page, we can follow some principles of progressive enhancement to improve the experience of all users.

<noscript> #

Firstly, there is the <noscript> element, which presents content for browsers that do not support or process client-side scripts. We could use this tag to tell our users either that scripts are required and that they should use a different browser or to let them know that some functionality might not be available without scripting.

We could use the <noscript> to display a message like this:

<noscript>
    Scripting must be enabled to use this application.
</noscript>

Handling errors #

A second consideration is that a client might support scripts but they could result in an error. For example, an older browser might not understand the syntax used in our scripts, like using const or let for variables, or it might be that we use some functionality that isn’t implemented, such as promises.

While we could avoid some of these potential errors by other means (like feature detection), for robustness we should implement an error handler that lets users know that an unexpected error occurred so that they can take appropriate action, such as using a different browser or contacting support.

We can implement this with a separate script that implements an error event handler. The reason for using a separate script is that we can implement this handler with syntax and features that all browsers are likely to support.

For example, our error handler could look something like this:

onerror = function (e) {
  var errorElement = document.getElementById('error')
  var loadingElement = document.getElementById('loading')
  if (errorElement) {
    // Show some information about the error
    if (typeof e === 'string' && e.length) {
      errorElement.appendChild(document.createTextNode(': ' + e))
    }
    // Make the error element visible
    errorElement.style['display'] = 'block'
  }
  // Hide 'loading' message if an error occurred
  if (loadingElement) {
    loadingElement.style['display'] = 'none'
  }
  return false
}

For a corresponding HTML body with error and loading elements:

<div id="root">
    <noscript>
        Scripting must be enabled to use this application.
    </noscript>
    <div id="loading">
        Loading...
    </div>
    <div id="error" style="display:none">An error occurred</div>
</div>

This way, browsers without client-side scripting will display the message in the <noscript> element, while browsers with scripting will show a ’loading’ message indicating that the application is not yet ready (alternatively, this can be the page contents when using server-side rendering) or an error message should some error occur.

Screen capture showing a browser with scripting disabled
A browser without scripting enabled displays a message indicating that scripting is required.
Screen capture showing an error message
If an error occurs during script execution, an error message is presented.
Screen capture showing an error message on a legacy browser
If an error occurs during script execution, an error message is presented, even on a legacy browser.

<script> attributes #

Generally, <script> elements block rendering. This means that the page won’t be interactive or display content until scripts have loaded. Depending on factors like network speed, this can result in poor user experience because the page will appear slow without indication of what is happening.

HTML5 introduced the async and defer attributes to the <script> tag to address this issue. The async attribute specifies that the script should be fetched in parallel and executed as soon as its contents are available. The defer attribute is similar, except that it specifies that the script should be executed right after the document has been parsed, but before the DOMContentLoaded event is fired.

For scripts that should render the initial contents of the page, as we are discussing, the defer attribute is the most appropriate, since it’ll allow us to progressively enhance the page when possible while still being able to provide some fallback content while scripts are being downloaded and executed.

We could use a helper function like this in a <script defer> element:

// Exceptions to throw
var InvalidOrUnsupportedStateError = function () {}

// Entry point
var browserOnLoad = function (handler) {
  if (['interactive', 'complete'].includes(document.readyState)) {
    // The page has already loaded and the 'DOMContentLoaded'
    // event has already fired
    // Call handler directly
    setTimeout(handler, 0)
  } else if (typeof document.addEventListener === 'function') {
    // 'DOMContentLoaded' has not yet fired
    // This is what we expect with <script defer>
    var listener = function () {
      if (typeof document.removeEventListener === 'function') {
        // Remove the event listener to avoid double firing
        document.removeEventListener('DOMContentLoaded', listener)
      }

      // Call handler on 'DOMContentLoaded'
      handler()
    }
    // Set an event listener on 'DOMContentLoaded'
    document.addEventListener('DOMContentLoaded', listener)
  } else {
    // The page has not fully loaded but addEventListener isn't
    // available. This shouldn't happen.
    throw new InvalidOrUnsupportedStateError()
  }
}

browserOnLoad(function () {
  // code that does client side rendering
})

Putting it all together #

We can combine all of the techniques presented to load scripts in a way that progressively enhances our page load on each step: first, by presenting users with a message that scripting is necessary, then by displaying a loading message while the application gets ready (and an error message if something goes wrong), followed by a fully loaded application once all scripts have been executed.

First, we set up the HTML document:

<!DOCTYPE html>
<html>

<head>
    <title>Example Page</title>
    <script>
    onerror = function (e) {
        var errorElement = document.getElementById('error')
        var loadingElement = document.getElementById('loading')
        if (errorElement) {
            // Show some information about the error
            if (typeof e === 'string' && e.length) {
                errorElement.appendChild(
                    document.createTextNode(': ' + e)
                )
            }
            // Make the error element visible
            errorElement.style['display'] = 'block'
        }
        // Hide 'loading' message if an error occurred
        if (loadingElement) {
            loadingElement.style['display'] = 'none'
        }
        return false
    }
    </script>
    <script defer src="app.js"></script>
</head>

<body style="font-size:24pt;background-color:white;color:black">
    <div id="root">
        <noscript>
            Scripting must be enabled to use this
            application.
        </noscript>
        </div>
        <div id="loading" style="color:blue">
            Loading...
        </div>
        <div id="error" style="display:none;color:teal">
            An error occurred<!--
        --></div>
    </div>
</body>

</html>

Followed by the app.js script:

!function () {
  // Exceptions to throw
  var InvalidOrUnsupportedStateError = function () {}

  // Entry point
  var browserOnLoad = function (handler) {
    if (['interactive', 'complete'].includes(document.readyState)) {
      // The page has already loaded and the 'DOMContentLoaded'
      // event has already fired
      // Call handler directly
      setTimeout(handler, 0)
    } else if (typeof document.addEventListener === 'function') {
      // 'DOMContentLoaded' has not yet fired
      var listener = function () {
        if (typeof document.removeEventListener === 'function') {
          // Remove the event listener to avoid double firing
          document.removeEventListener('DOMContentLoaded', listener)
        }

        // Call handler on 'DOMContentLoaded'
        handler()
      }
      // Set an event listener on 'DOMContentLoaded'
      document.addEventListener('DOMContentLoaded', listener)
    } else {
      // The page has not fully loaded but addEventListener isn't
      // available. This shouldn't happen.
      throw new InvalidOrUnsupportedStateError()
    }
  }

  // Function to load dependency scripts
  // For simplicity, this assumes all scripts are independent
  // from each other
  var loadScripts = function (scripts) {
    return Promise.all(scripts.map(function (src) {
      return new Promise(
        function (resolve, reject) {
          var el = document.createElement('script')
          el.addEventListener('load', resolve)
          el.addEventListener('error', reject)
          el.src = src
          el.crossOrigin = 'anonymous'
          document.head.appendChild(el)
      })
    }))
  }

  browserOnLoad(function () {
    loadScripts([
      'https://unpkg.com/react@18/umd/react.production.min.js',
      'https://unpkg.com/react-dom@18/umd/react-dom.production.min.js'
    ]).then(
      function () {
        var root = ReactDOM.createRoot(document.getElementById('root'))
        root.render(React.createElement('div', null, 'Hello, World!'))
        onerror = null
      }).catch(function (e) { onerror(e.message || e) })
  })
}()