Optimizing a Page: Resource Hints, Critical CSS, and Webpack

Knocking down synchronous obstacles to high-performance web pages

Hugo Queirós
Expedia Group Technology
7 min readMar 19, 2019

--

A year ago, my team and I at Vrbo.com™ (part of Expedia Group™) started a new project called Merchandising Landing Pages (MLPs), which focuses on delivering personalised landing pages to travelers in segments such as family vacation rentals, beach rentals, and pet lovers.

One of our objectives is that users feel like they are having a great experience when landing on our pages. For that purpose to be achieved, we need to keep our page performing well.

Performance plays a significant role in retaining travelers, allowing them to engage as soon as possible, potentially increasing conversion rate and brand satisfaction. One of the best resources on this subject is Jeremy Wagner’s Why Performance Matters.

We started off with a basic skeleton of an isomorphic React application running on Node, with hapi as our server. The pages were not optimized at all; they had no resource hinting, preloading, prefetch of resources, or critical CSS and were still on Webpack 3. At the beginning, we were mostly concerned with building a good code base, one that we could scale up to several segments so that when the time came, we could be confident we’d be able to address its performance.

I’ll describe some of the improvements we’ve made to improve our app’s performance.

1. Resource Hinting

1.1. preconnect

A preconnect tag (like <link rel=preconnect>) informs the browser what connections the site will need before a request is made. It makes three round trips to do the DNS lookup, TLS negotiation, and TCP handshake. Using it wisely can eliminate costly round trips to important domains, shaving time from the critical rendering path.

Eliminating roundtrips with preconnect is a good resource for understanding preconnect.

<link rel="preconnect" crossorigin href="https://xxxxx.xxxxxx.com">
<link rel="preconnect" crossorigin href="https://xxx.xxxx.com">
<link rel="preconnect" crossorigin href="https://xxx.xxxxxxx.com">
<link rel="preconnect" crossorigin href="https://xxx.xxxxxxxxx.com">

Here, we provide hints where our assets like fonts, images, or external JavaScript are located.

1.2. dns-prefetch

DNS prefetching suggests to the browser that it should resolve an IP address from a domain as quickly as possible, initialising the DNS lookup process in the background and thus reducing its lookup latency. This speeds up loading the web page because the browser will know where your assets are, even before they are needed.

DNS-prefetching is an awesome resource on this topic.

<link rel="dns-prefetch" href="//xxxxx.xxxxxxxx.com">

The difference between preconnect and dns-prefetch is that the latter only does the DNS lookup, thus avoiding two extra round trips once a resource is ready to be downloaded.

1.3. preload

Preloading (like <link rel=”preload”>) hints to the browser that a resource is going to be needed and that it should fetch it as soon as possible.

Just explaining how to use preload, why to use it, critical paths, render blocking, etc. could consume an entire blog post, but there are already great resources I’ll encourage you to take a look at:

We figured that we could add preload links to the <head> HTML tag to address three use cases: noncritical CSS, fonts and images.

{{!-- Main --}}
<link rel="preload" href="{{main}}" as="style">

{{!-- CSS Bundle --}}
<link rel="preload" href="{{resolveBundle 'vendor.css'}}" as="style">

<link rel="preload" href="{{resolveBundle templateCss}}" as="style">

{{!-- Fonts --}}
<link rel="preload" href="{{webFont}}" as="style">
<link rel="preload" href="https://fonts.xxxxxxx.xxxx/x/whateverfont.woff2" as="font" type="font/woff2" crossorigin>

<link rel="preload" href="{{images.sm}}" as="image" media="(max-width: 375px)">
<link rel="preload" href="{{images.md}}" as="image" media="(min-width: 376px) and (max-width: 768px)">
<link rel="preload" href="{{images.lg}}" as="image" media="(min-width: 769px)">

Discussion

The introduction of rel="preload" removed some of our render-blocking resources, allowing preloaded resources to be fetched in parallel with high priority, and improved some of our metrics. In the image below, you can check our waterfall for the resources that we set to be preloaded. Resources are all fetched asynchronously with the highest priority, in parallel, with the advantage that they are not blocking page loading.

Be mindful of the use of preconnect and dns-prefetch — they are recommended only for critical origins the page will use soon. Making a DNS request to unneeded hosts can stall other DNS requests. In fact, Chrome can only send 6 concurrent DNS requests.

2. Critical CSS

Per Addy Osmani:

Why is the CSS critical path important?

CSS is required to construct the render tree for your pages and JavaScript will often block on CSS during initial construction of the page. You should ensure that any non-essential CSS is marked as non-critical (e.g. print and other media queries), and that the amount of critical CSS and the time to deliver it is as small as possible.

Why should the CSS critical-path be inlined?

For best performance, you may want to consider inlining the critical CSS directly into the HTML document. This eliminates additional roundtrips in the critical path and if done correctly can be used to deliver a “one roundtrip” critical path length where only the HTML is a blocking resource.

2.1 Critical CSS implementation

Our objective was to add inline styles for what we determined is our above-the-fold content. Everything that we considered below the fold would go in the CSS bundles that we would preload.

We determined that the above the fold should be something like the image below. Regardless of network conditions or device type, that should be what the user will instantly observe.

Our approach was to create a hapi plugin that would make available in the view context a helper function called resolveInlineBundle. This is the barebones implementation:

function resolveInlineBundle(filename) {
// for development environment
if (isDevelopment) {
return `/${filename}`;
}

if (inlineFiles[filename]) {
return inlineFiles[filename];
}

const inlineBundleFilename = `/${pathPrefix}/${files[filename]}`;

try {
const path = join(process.cwd(), 'build', inlineBundleFilename);

inlineFiles[filename] = readFileSync(path, {encoding: 'utf8'});

return inlineFiles[filename];
} catch (err) {
hoek.abort(err);
return '';
}
}

We can then use the function within our HTML template system.

Discussion

We removed some CSS from the main CSS bundle and added it to the critical bundle because we needed it when we render the above-the-fold content. Be mindful of what you add to the critical CSS, so you don’t increase its size with unnecessary rules.

Our approach on critical CSS working together with the preload assets worked like a charm for us, greatly increasing page loading speed and improving our page speed scores.

3. Webpack config

This journey also included upgrading our webpack to version 4 to take full advantage of its new features, aligning us with other teams in our company.

The following code encompasses our main changes:

entry: {
theme: './src/client/theme/index.js',
themecritical: './src/client/theme/critical/critical',
},
optimization: {
concatenateModules: true,
splitChunks: {
chunks: 'async',
minSize: 30000,
minChunks: 1,
maxAsyncRequests: 20,
maxInitialRequests: 20,
name: true,
cacheGroups: {
default: false,
vendors: false,
vendor: {
name: 'vendor',
chunks: 'all',
test: /[\\/]node_modules[\\/]/,
priority: 20,
},
}
},

The bundles created are theme.js, theme.css, themecritical.js, themecritical.css, vendor.js and vendor.css.

All the critical CSS bundles that will be read by the resolveInlineBundle helper are being declared to webpack as entries.

We used the package webpack bundle analyzer to tune our configuration for our use case.

4. JavaScript Loading

There is one thing that I still haven’t mentioned: How are we loading our JavaScript in our page, namelytheme.js and vendor.js? Here’s how:

<head>
{{!-- Client bundle loading --}}
<script src="{{resolveBundle 'vendor.js'}}" defer></script>
<script src="{{resolveBundle 'theme.js'}}" defer></script>
</head>

Some resources found around the web state that async and defer are similar, but the way browsers behave is quite different. Both attributes will make the browser load JavaScript asynchronously while HTML is being parsed, but there’s one big difference: With async, the browser will pause the HTML parsing as soon as the JavaScript is fetched and execute it. When execution is done, HTML resumes the parsing task. With defer, the script is fetched but execution only happens after the HTML parsing, right after domInteractive. As you can see, async would hurt our page loading speed, which is something we care about.

I encourage you to read this article about JavaScript loading comparing defer, async, and no defer or async.

Conclusion

  1. Our time-to-interactive (TTI) also greatly improved, which is very important for us. At the time of the changes, our lighthouse score went to the high 90’s on both desktop and mobile. But since the new release of the pipeline, we dropped significantly in mobile. We are currently working on improving that score.
  2. We reduced our render-blocking resources to 0, which greatly increased our page speed score.
  3. We split up more of our bundles, lazy-loading some of the components that weren’t critical to our pages.
  4. Lazy-loading images is another technique that is a must when you want your page to load fast. Images are often one of the biggest obstacles to your page performance.

--

--