Optimizing Critical-Path Performance With Express Server And Handlebars

 Recently, I’ve been working on an isomorphic React website. This website was developed using React, running on an Express server. Everything was going well, but I still wasn’t satisfied with a load-blocking CSS bundle. So, I started to think about options for how to implement the critical-path technique on an Express server. This article contains my notes about installing and configuring a critical-path performance optimization using Express and Handlebars. Throughout this article, I’ll be using Node.js and Express. Familiarity with them will help you understand the examples.

Recently, I’ve been working on an isomorphic React website. This website was developed using React, running on an Express server. Everything was going well, but I still wasn’t satisfied with a load-blocking CSS bundle. So, I started to think about options for how to implement the critical-path technique on an Express server.

This article contains my notes about installing and configuring a critical-path performance optimization using Express and Handlebars.

1. The Basics

Critical-path optimization is a technique that eliminates render-blocking CSS. This technique can dramatically increase the speed at which a website loads. The aim of this method is to get rid of the time that a user waits for a CSS bundle to load. Once the bundle has loaded, the browser saves it to its cache, and any subsequent reloads are served from the cache. Based on this, our objectives are the following:

  • Distinguish between the first and second (and nth) loads.
  • On the first load, load the CSS bundle asynchronously, and attach a load event listener so that we can find out when the bundle is ready to be served.
  • While the bundle is being loaded, inline some small critical CSS, to make the user experience as similar as possible to the end result.
  • Once the event listener reports that the CSS bundle is ready, remove the inline CSS and serve the bundle.
  • Ensure that other sources (JavaScript bundles, etc.) are not blocking the rendering.

2. Detecting The First Load

To detect the first load, we are going to use a cookie. If a cookie has not been set, then that means it’s the first load. Otherwise, it will be the second or nth load.

3. Loading The CSS Bundle Asynchronously

To start asynchronously downloading the CSS bundle, we are going to use a simple technique involving an invalid media attribute value. Setting the media attribute to an invalid value will cause the CSS bundle to download asynchronously but will not apply any styles until the media attribute has been set to a valid value. In other words, in order to apply styles from the CSS bundle, we will change the media attribute to a valid value once the bundle has loaded.

4. Critical CSS Vs. CSS Bundle

We will keep critical styles inline in the markup only during the downloading of the CSS bundle. Once the bundle has loaded, that critical CSS will be removed from the markup. To do this, we will also create some critical JavaScript, which will basically be a little JavaScript handler.

5. Lifecycle

To sum up, here is simple schema of our lifecycle:

Lifecycle of critical path performance optimization
Lifecycle of critical-path performance optimization

6. Going Isomorphic

Now that you know more about this technique, imagine it in combination with an isomorphic JavaScript application. Isomorphic JavaScript, also called universal JavaScript, simply means that an application written in JavaScript is able to run and generate HTML markup on the server. If you are curious, read more about React’s approach regarding ReactDOM.renderToString and ReactDOM.renderToStaticMarkup.

You might still be wondering why we need to generate HTML on the server. Well, think about the first load. When using client-side-only code, our visitors will have to wait for the JavaScript bundle. While the JavaScript bundle is being loaded, visitors will see a blank page or a preloader. I believe that the goal of front-end developers should be to minimize such scenarios. With isomorphic code, it’s different. Instead of a blank page and preloader, visitors will see the generated markup, even without the JavaScript bundle. Of course, the CSS bundle will also take some time to load, and without it our visitors will see only unstyled markup. Thankfully, using critical-path performance optimization, this is easy to solve.

Isomorphic JavaScript application
Isomorphic JavaScript application

7. Preparing The Environment

7.1. Express

Express is a minimal and flexible Node.js web application framework.

First, install all of the required packages: expressexpress-handlebars and cookie-parserexpress-handlebars is a Handlebars views engine for Express, and cookie-parser will help us with cookies later.

npm install express express-handlebars cookie-parser --save-dev

Create a server.js file with imports of those packages. We will also use the path package later, which is part of Node.js.

import express from 'express';
import expressHandlebars from 'express-handlebars';
import cookieParser from 'cookie-parser';
import path from 'path';

Create the Express application:

var app = express();

Mount cookie-parser:

app.use(cookieParser());

Our CSS bundle will be available at /assets/css/bundle.css. To serve static files from Express, we have to set the path name of the directory where our static files are. This can be done using the built-in middleware function express.static. Our files will be in a directory named build; so, the local file at /build/assets/css/bundle.css will be served by the browser at /assets/css/bundle.css.

app.use(express.static('build'));

For the purpose of this demonstration, setting up a single HTTP GET route (/) will suffice:

// Register simple HTTP GET route for /
app.get('/', function(req, res){
  // Send status 200 and render content. Content, in this case, is a non-existent template. For me, rendering the layout is important.
  res.status(200).render('content');
});

And let’s bind Express to listen on port 3000:

// Set the server port to 3000, and log the message when the server is ready.
app.listen(3000, function(){
  console.log('Local server is listening…');
});

8. Babel And ES2016

Given the ECMAScript 2016 (or ES2016) syntax, we are going to install Babel and its presets. Babel is a JavaScript compiler that enables us to use next-generation JavaScript today. Babel presets are just a specific Babel transformation logic extracted into smaller groups of plugins (or presets). Our demo requires React and ES2015 presets.

npm install babel-core babel-preset-es2015 babel-preset-react --save-dev

Now, create a .babelrc file with the following code. This is where we’re essentially saying, “Hey Babel, use these presets”:

{
  "presets": [
    "es2015",
    "react"
  ]
}

As Babel’s documentation says, to handle ES2016 syntax, Babel requires a babel-core/register hook at the entry point of the application. Otherwise, it will throw an error. Let’s create entry.js:

require("babel-core/register");
require('./server.js');

Now, test the configuration:

$ node entry.js

Your terminal should log this message:

Local server is listening…

However, if you navigate your browser to https://localhost:3000/, you will get this error:

Error: No default engine was specified and no extension was provided.

This simply means that Express doesn’t know what or how to render. We’ll get rid of this error in the next section.

9. Handlebars

Handlebars is referred to as “minimal templating on steroids.” Let’s set it up. Open server.js:

// register new template engine
// first parameter = file extension
// second parameter = callback = expressHandlebars
// defaultLayout is the name of default layout located in layoutsDir.
app.engine('handlebars', expressHandlebars(
{
  defaultLayout: 'main',
  layoutsDir:    path.join(__dirname, 'views/layouts'),
  partialsDir: path.join(__dirname, 'views/partials')
}
));
// register new view engine
app.set('view engine', 'handlebars');

Create the directories views/layouts and views/partials. In views/layouts, create a file named main.handlebars, and insert the following HTML. This will be our main layout.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Critical-Path Performance Optimization</title>
    <link rel="stylesheet" href="assets/css/bundle.css" id="cssbundle" media="none"/>
  </head>
  <body>
  </body>
</html>

Also create a file named content.handlebars in views directory, and insert the following HTML.

<div id="app">magic here</div>

Start the server now:

$ node entry.js

Go to https://localhost:3000. The error is gone, and the layout’s markup is ready.

10. Critical Path

Our environment is ready. Now, we can implement the critical-path optimization.

10.1. Determining the first load

As you will recall, our first objective is to determine whether or not a load is the first. Based on this, we can decide whether to serve critical styles or the CSS bundle from the browser’s cache. We will use a cookie for this. If a cookie is set, then that means it’s not the first load; otherwise, it is. The cookie will be created in the critical JavaScript file, which will be injected inline in the template with the critical styles. Checking for the cookie will be handled by Express.

Let’s name the critical JavaScript file fastjs. We must be able to insert the content of fastjs in the layout file if a cookie doesn’t exist. I’ve found Handlebars partials to be pretty easy to use. Partials are useful when you have markup that you want to reuse in multiple places. They can be called by other templates and are mostly used for the header, footer, navigation and so on.

In the Handlebars section, I’ve defined a partials directory at /views/partials. Let’s create a /views/partials/fastjs.handlebars file. In this file, we’ll add a script tag with an ID of fastjs. We will use this ID later to remove the script from the DOM.

<script id='fastjs'>
</script>

Now, open /views/layouts/main.handlebars. Calling the partial is done through the syntax {{> partialName }}. This code will be replaced by the contents of our target partial. Our partial is named fastjs, so add the following line before the end of the head tag:

<head>
…
{{> fastjs}}
</head>

The markup at https://localhost:3000 now contains the contents of the fastjs partial. A cookie will be created using this simple JavaScript function.

<script id='fastjs'>
// Let's create a cookie named 'fastweb', setting its value to 'cache' and its expiration to one day
createCookie('fastweb', 'cache', 1);

// function to create cookie
function createCookie(name,value,days) {
  var expires = "";
  if (days) {
    var date = new Date();
    date.setTime(date.getTime()+(days*24*60*60*1000));
    var expires = "; expires="+date.toGMTString();
  }
  document.cookie = name+"="+value+expires+"; path=/";
}
</script>

You can check that https://localhost:3000 contains the cookie named fastweb. The fastjs content should be inserted only if a cookie doesn’t exist. To determine this, we need to check on the Express side whether one exists. This is easily done with the cookie-parser npm package and Express. Go to this bit of code in server.js:

app.get('/', function(req, res){
  res.status(200).render('content');
});

The render function accepts in the second position an optional object containing local variables for the view. We can pass a variable into the view like so:

app.get('/', function(req, res){
  res.status(200).render('content', {needToRenderFast: true});
});

Now, in our view, we can print the variable needToRenderFast, whose value will be true. We want the value of this variable to be set to true if a cookie named fastweb does not exist. Otherwise, the variable should be set to false. Using cookie-parser, checking for the cookie’s existence is possible with this simple code:

//Check whether cookie named fastweb is set to a value of 'cache'
req.cookies.fastweb === 'cache'

And here it is rewritten for our needs:

app.get('/', function(req, res){
  res.status(200).render('content', {
    needToRenderFast: !(req.cookies.fastweb === 'cache')
  });
});

The view knows, based on value of this variable, whether to render the critical files. Thanks to Handlebars’ built-in helpers — namely, the if block helper — this is also easy to implement. Open the layout file and add an if helper:

<head>
…
{{#if needToRenderFast}}
{{> fastjs}}
{{/if}}
</head>

Voilà! The fastjs content gets inserted only if a cookie doesn’t exist.

11. Injecting Critical CSS

The critical CSS file must be inserted at the same time as the critical JavaScript file. First, create another partial named /views/partials/fastcss.handlebars. The contents of this fastcss file is simple:

<style id="fastcss">
  body{background:#E91E63;}
</style>

Just import it as we did the fastjs partial. Open the layout file:

<head>
…
{{#if needToRenderFast}}
{{> fastcss}}
{{> fastjs}}
{{/if}}
</head>

12. Handling The Loading Of The CSS Bundle

The trouble now is that, even though the CSS bundle has loaded, the critical partials still remain in the DOM. Fortunately, this is easy to fix. Our layout’s markup looks like this:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Critical-Path Performance Optimization</title>
    {{#if needToRenderFast}}
    <link rel="stylesheet" href="assets/css/bundle.css" id="cssbundle" media="none"/>
    {{> fastcss}}
    {{> fastjs}}
    {{/if}}
  </head>
  <body>
  </body>
</html>

Our fastjsfastcss and CSS bundle have their own IDs. We can take advantage of that. Open the fastjs partial and find the references to those elements.

var cssBundle = document.getElementById('cssbundle'),
fastCss = document.getElementById('fastcss'),
fastJs = document.getElementById('fastjs');

We want to be notified when the CSS bundle has loaded. This is possible using an event listener:

cssBundle.addEventListener('load', handleFastcss);

The handleFastcss function will be called immediately after the CSS bundle has loaded. At that moment, we want to propagate styles from the CSS bundle, remove the #fastjs and #fastcss elements and create the cookie. As mentioned at the beginning of this article, the styles from the CSS bundle will be propagated by changing the media attribute of the CSS bundle to a valid value — in our case, a value of all.

function handleFastcss() {
  cssBundle.setAttribute('media', 'all');
}

Now, just remove the #fastjs and #fastcss elements:

function handleFastcss() {
  cssBundle.setAttribute('media', 'all');
  fastCss.parentNode.removeChild(fastCss);
  fastJs.parentNode.removeChild(fastJs);
}

And call the createCookie function inside the handleFastcss function.

function handleFastcss() {
  createCookie('fastweb', 'cache', 1);
  cssBundle.setAttribute('media', 'all');
  fastCss.parentNode.removeChild(fastCss);
  fastJs.parentNode.removeChild(fastJs);
}

Our final fastjs script is as follows:

<script id='fastjs'>
var cssBundle = document.getElementById('cssbundle'),
fastCss =  document.getElementById('fastcss'),
fastJs =  document.getElementById('fastjs');

cssBundle.addEventListener('load', handleFastcss);

function handleFastcss() {
  createCookie('fastweb', 'cache', 1);
  cssBundle.setAttribute('media', 'all');
  fastCss.parentNode.removeChild(fastCss);
  fastJs.parentNode.removeChild(fastJs);
}
function createCookie(name,value,days) {
  var expires = "";
  if (days) {
    var date = new Date();
    date.setTime(date.getTime()+(days*24*60*60*1000));
    var expires = "; expires="+date.toGMTString();
  }
  document.cookie = name+"="+value+expires+"; path=/";
}
</script>

Please note that this CSS load handler works only on the client side. If client-side JavaScript is disabled, it will continue using the styles in fastcss.

13. Handling The Second And Nth Load

The first load now behaves as expected. But when we reload the page in the browser, it remains without styles. That’s because we’ve only dealt with the scenario in which a cookie doesn’t exist. If a cookie does exist, the CSS bundle must be linked in the standard way.

Edit the layout file:

<head>
  …
  {{#if needToRenderFast}}
  <link rel="stylesheet" href="assets/css/bundle.css" id="cssbundle" media="none"/>
  {{> fastcss}}
  {{> fastjs}}
  {{else}}
  <link rel="stylesheet" href="assets/css/bundle.css" id="cssbundle" media="all"/>
  {{/if}}
</head>

Save it, and view the result.

14. Result

The GIF below shows the first load. As you can see, while the CSS bundle is downloading, the page has a different background. This is caused by the styles in the fastcss partial. The cookie is created, and the bundle.css request ends with a status of “200 OK.”

As you will recall, our first objective is to determine whether or not a load is the first. Based on this, we can decide whether to serve critical styles or the CSS bundle from the browser’s cache. We will use a cookie for this. If a cookie is set, then that means it’s not the first load; otherwise, it is. The cookie will be created in the critical JavaScript file, which will be injected inline in the template with the critical styles. Checking for the cookie will be handled by Express.

Let’s name the critical JavaScript file fastjs. We must be able to insert the content of fastjs in the layout file if a cookie doesn’t exist. I’ve found Handlebars partials to be pretty easy to use. Partials are useful when you have markup that you want to reuse in multiple places. They can be called by other templates and are mostly used for the header, footer, navigation and so on.

In the Handlebars section, I’ve defined a partials directory at /views/partials. Let’s create a /views/partials/fastjs.handlebars file. In this file, we’ll add a script tag with an ID of fastjs. We will use this ID later to remove the script from the DOM.

<script id='fastjs'>
</script>

Now, open /views/layouts/main.handlebars. Calling the partial is done through the syntax {{> partialName }}. This code will be replaced by the contents of our target partial. Our partial is named fastjs, so add the following line before the end of the head tag:

<head>
…
{{> fastjs}}
</head>

The markup at https://localhost:3000 now contains the contents of the fastjs partial. A cookie will be created using this simple JavaScript function.

<script id='fastjs'>
// Let's create a cookie named 'fastweb', setting its value to 'cache' and its expiration to one day
createCookie('fastweb', 'cache', 1);

// function to create cookie
function createCookie(name,value,days) {
  var expires = "";
  if (days) {
    var date = new Date();
    date.setTime(date.getTime()+(days*24*60*60*1000));
    var expires = "; expires="+date.toGMTString();
  }
  document.cookie = name+"="+value+expires+"; path=/";
}
</script>

You can check that https://localhost:3000 contains the cookie named fastweb. The fastjs content should be inserted only if a cookie doesn’t exist. To determine this, we need to check on the Express side whether one exists. This is easily done with the cookie-parser npm package and Express. Go to this bit of code in server.js:

app.get('/', function(req, res){
  res.status(200).render('content');
});

The render function accepts in the second position an optional object containing local variables for the view. We can pass a variable into the view like so:

app.get('/', function(req, res){
  res.status(200).render('content', {needToRenderFast: true});
});

Now, in our view, we can print the variable needToRenderFast, whose value will be true. We want the value of this variable to be set to true if a cookie named fastweb does not exist. Otherwise, the variable should be set to false. Using cookie-parser, checking for the cookie’s existence is possible with this simple code:

//Check whether cookie named fastweb is set to a value of 'cache'
req.cookies.fastweb === 'cache'

And here it is rewritten for our needs:

app.get('/', function(req, res){
  res.status(200).render('content', {
    needToRenderFast: !(req.cookies.fastweb === 'cache')
  });
});

The view knows, based on value of this variable, whether to render the critical files. Thanks to Handlebars’ built-in helpers — namely, the if block helper — this is also easy to implement. Open the layout file and add an if helper:

<head>
…
{{#if needToRenderFast}}
{{> fastjs}}
{{/if}}
</head>

Voilà! The fastjs content gets inserted only if a cookie doesn’t exist.

15. Injecting Critical CSS

The critical CSS file must be inserted at the same time as the critical JavaScript file. First, create another partial named /views/partials/fastcss.handlebars. The contents of this fastcss file is simple:

<style id="fastcss">
  body{background:#E91E63;}
</style>

Just import it as we did the fastjs partial. Open the layout file:

<head>
…
{{#if needToRenderFast}}
{{> fastcss}}
{{> fastjs}}
{{/if}}
</head>

16. Handling The Loading Of The CSS Bundle

The trouble now is that, even though the CSS bundle has loaded, the critical partials still remain in the DOM. Fortunately, this is easy to fix. Our layout’s markup looks like this:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Critical-Path Performance Optimization</title>
    {{#if needToRenderFast}}
    <link rel="stylesheet" href="assets/css/bundle.css" id="cssbundle" media="none"/>
    {{> fastcss}}
    {{> fastjs}}
    {{/if}}
  </head>
  <body>
  </body>
</html>

Our fastjsfastcss and CSS bundle have their own IDs. We can take advantage of that. Open the fastjs partial and find the references to those elements.

var cssBundle = document.getElementById('cssbundle'),
fastCss = document.getElementById('fastcss'),
fastJs = document.getElementById('fastjs');

We want to be notified when the CSS bundle has loaded. This is possible using an event listener:

cssBundle.addEventListener('load', handleFastcss);

The handleFastcss function will be called immediately after the CSS bundle has loaded. At that moment, we want to propagate styles from the CSS bundle, remove the #fastjs and #fastcss elements and create the cookie. As mentioned at the beginning of this article, the styles from the CSS bundle will be propagated by changing the media attribute of the CSS bundle to a valid value — in our case, a value of all.

function handleFastcss() {
  cssBundle.setAttribute('media', 'all');
}

Now, just remove the #fastjs and #fastcss elements:

function handleFastcss() {
  cssBundle.setAttribute('media', 'all');
  fastCss.parentNode.removeChild(fastCss);
  fastJs.parentNode.removeChild(fastJs);
}

And call the createCookie function inside the handleFastcss function.

function handleFastcss() {
  createCookie('fastweb', 'cache', 1);
  cssBundle.setAttribute('media', 'all');
  fastCss.parentNode.removeChild(fastCss);
  fastJs.parentNode.removeChild(fastJs);
}

Our final fastjs script is as follows:

<script id='fastjs'>
var cssBundle = document.getElementById('cssbundle'),
fastCss =  document.getElementById('fastcss'),
fastJs =  document.getElementById('fastjs');

cssBundle.addEventListener('load', handleFastcss);

function handleFastcss() {
  createCookie('fastweb', 'cache', 1);
  cssBundle.setAttribute('media', 'all');
  fastCss.parentNode.removeChild(fastCss);
  fastJs.parentNode.removeChild(fastJs);
}
function createCookie(name,value,days) {
  var expires = "";
  if (days) {
    var date = new Date();
    date.setTime(date.getTime()+(days*24*60*60*1000));
    var expires = "; expires="+date.toGMTString();
  }
  document.cookie = name+"="+value+expires+"; path=/";
}
</script>

Please note that this CSS load handler works only on the client side. If client-side JavaScript is disabled, it will continue using the styles in fastcss.

17. Handling The Second And Nth Load

The first load now behaves as expected. But when we reload the page in the browser, it remains without styles. That’s because we’ve only dealt with the scenario in which a cookie doesn’t exist. If a cookie does exist, the CSS bundle must be linked in the standard way.

Edit the layout file:

<head>
  …
  {{#if needToRenderFast}}
  <link rel="stylesheet" href="assets/css/bundle.css" id="cssbundle" media="none"/>
  {{> fastcss}}
  {{> fastjs}}
  {{else}}
  <link rel="stylesheet" href="assets/css/bundle.css" id="cssbundle" media="all"/>
  {{/if}}
</head>

Save it, and view the result.

18. Result

The GIF below shows the first load. As you can see, while the CSS bundle is downloading, the page has a different background. This is caused by the styles in the fastcss partial. The cookie is created, and the bundle.css request ends with a status of “200 OK.”

The second GIF shows the reloading scenario. A cookie has already been created, the critical files are ignored, and the bundle.css request ends with a status of “304 Not modified.”

19. Conclusion

We’ve gone through the whole lifecycle shown in the schema above. As a next step, check that all requests to scripts, images, fonts and so on are asynchronous and do not block rendering. Also, don’t forget to enable gZip compression on the server; nice Express middleware is available for this.

Lifecycle of critical-path performance optimization
Lifecycle of critical-path performance optimization

Related posts:

Why is Node.js wildly popular among developers?
Building a Simple Cryptocurrency Blockchain using Node.js
API Authentication with Node.js
Getting Started with Node.js Child Processes
Getting Started with Fastify Node.js Framework and Faunadb
Writing A Multiplayer Text Adventure Engine In Node.js (Part 1)
Deploying RESTful APIs using Node.js, Express 4 to Kubernetes clusters
Making cURL Requests in Node.js
Agora Cloud Recording with Node.js
How to Build a Custom URL Shortener using Node.js, Express, and MongoDB
Building A Real-Time Retrospective Board With Video Chat
How to build a real time chat application in Node.js
Documenting a Node.js REST API using Swagger
Better Error Handling In NodeJS With Error Classes
Getting Started with Google Translate API with Node.js
Compiling a Node.js Application into an .exe File
Getting Started with Google Sheets API in Node.js
The Guide To Ethical Scraping Of Dynamic Websites With Node.js And Puppeteer
Getting Started with Push Notifications in Node.js using Service Workers
Golang vs. Node.js: Defining the Best Solution
Email Authentication and Verification using Node.js and Firebase
Debugging a Node.js app in VS Code
Process Manager 2 with Node.js
Writing A Multiplayer Text Adventure Engine In Node.js: Adding Chat Into Our Game (Part 4)
Introduction to Sequelize ORM for Node.js
Creating Secure Password Flows With NodeJS And MySQL
MySQL with Node.js
Working with Moment.js Date Libraries
Consuming the Unsplash API using Node.js Graphql API
10 Tips for Working with Node.js
Most Useful Node.js Packages
Getting Started with Node.js Event Emitter