An Unravelling Tale of Performance, Part 2: Render Blocking CSS

Two CSS files, two DNS resolutions, both blocking rendering. Let’s kill ‘em.

Matt Perry
Nov 21, 2017 · 10 min read

The story so far: Analysis

Browsers generally treat CSS files as render blocking. That is, the browser will show an empty white screen until the CSS is downloaded and parsed.

If browsers showed content before styles had loaded, there’d be a flash of plain, unstyled content. From a user’s perspective, this wouldn’t just be ugly — it’d be difficult to use.

LGTM

Users on a desktop might see the flash only briefly, but users on a slow mobile connection will have enough time to parse layout information on the page. Then, when the CSS loads, the page layout will change, leading to the disorientating cognitive overhead of having to re-parse what they’re seeing.

There’s also the considerable chance that they try and press a link or button, which, between them moving their finger towards the screen and actually hitting it, changes position as the CSS loads. This would leave the user pressing nothing, or worse, a totally different action.

We’ve all had this frustrating experience when an advert or image pushes content around (looking at you, TFL).

So it makes sense that browsers treat CSS as render blocking in order to avoid these situations. However, it can mean that users spend longer waiting to read their content on slower connections.

The situation on DriveTribe

Our analysis in part one identified two render blocking CSS files:

As we can see from the waterfall diagram from WebPageTest, these assets are also both served from different domains:

The turquoise, orange and pink bars are DNS and SSL overhead.

Many users will spend more time connecting to the servers that these files are served from, than downloading the files themselves.

If we can figure out how to ditch these two requests, we’ll save at least 100ms (much more on slow connections).

This might not sound like a lot of time, but ideally we’d serve a readable website to a mobile user within a second. This quickly gives us a new perspective on that figure.

So, let’s take a look at both files, and see how we can get rid of them.

Google Fonts

The default method Google Fonts provides for embedding its fonts is as a blocking CSS resource (this is the method we implemented):

<link href="https://fonts.googleapis.com/css?family=Open+Sans:400,700" rel="stylesheet">

This links to a tiny CSS file that contains a series of @font-face rules that look like this:

/* latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v15/cJZKeOuBrn4kERxqtaUH3ZBw1xU1rKptJj_0jans920.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215;
}

Here, the unicode-range rule tells the browser that if it detects a character within the given range, it should download the referenced file.

It’s essentially code-splitting for fonts, allowing web fonts to support a range of international character sets while minimising the payload for the user.

A whole DNS resolution feels like overkill to receive one tiny file of font definitions (the file is under 1kb gzipped). Especially when the fonts themselves are served from a separate domain, requiring yet another DNS resolution(!)

Potential solutions

The first potential solution that comes to mind is to simply copy and paste this file into a style tag as part of our initial HTML payload.

This would be an “all other things being equal” approach that would keep our loading strategy exactly the same except with one fewer blocking network request.

There are two potential issues I can imagine:

1. Maintaining manual references

We’d now be maintaining a manual reference to the font files themselves. This feels brittle; I have no knowledge of whether Google is likely to remove those files when they add a new version.

Also, each file has an id like cJZKeOuBrn4kERxqtaUH3ZBw1xU1rKptJj_0jans92. To get the new one, the maintainer will need to manually call this CSS file rather than make an educated guess.

On the other hand, the fact that these font files are versioned instinctively means it’s unlikely (but possible) that Google are deleting past versions.

2. Flash of invisible text

Currently, browsers treat the fonts themselves as a semi-blocking resource. This means they’ll typically wait for a duration of time (of their choosing) for the font to load before falling back to a system font.

This results in a flash of invisible text (FOIT). The page is rendered, but none of the text is visible. Our current approach results in FOIT.

A possible alternative is to allow the browser to use a fallback font in the first instance and then replace it with the web font once it’s successfully loaded.

This results in a flash of unstyled text (FOUT), which looks messy but doesn’t present any major cognitive overhead. However, there is still a minor amount of reflow which, if you’re loading multiple fonts, isn’t great for performance.

FOUT also reduces the extra amount of time a user has to wait until text is displayed from up to thirty seconds to something on the order of milliseconds.

Alternatives

Zach Leatherman has an excellent run-down of all web font loading strategies on his blog. Each has an extensive list of pros and cons. The bottom line is, there’s currently no one-size-fits-all solution to web fonts.

One of the most promising approaches is “FOUT with a class.” This uses JavaScript to load the fonts asynchronously. When all fonts have downloaded, a class is added to the html tag, which applies the web fonts via CSS.

On the plus side, it allows us to show content immediately. We also batch the multiple reflows into just one.

However, we’re still downloading an external dependency just to load the fonts. The Web Font Loader, recommended by this CSS Tricks article, is 16kb itself. That’s bigger than a typical JavaScript library, and about the same size as any one of our font files!

It also wouldn’t allow us to continue to define fonts as unicode ranges, a great benefit of the vanilla @font-face CSS rules.

font-display

Zach’s blog post reminded me of the font-display CSS property. We already use this for our locally-hosted brand fonts, but the external CSS file from Google omits it.

font-display allows developers to define their own strategy for displaying content while web fonts are loading. Browser support is currently very good, with only Microsoft browsers lacking it.

The font-display: fallback rule is a compromise between the ugly FOUT and the slow-connection unfriendly FOIT. It will wait a smaller amount of time (around 100ms) before rendering text with a fallback font. If the web font is loaded after that, we’ll experience a FOUT.

Users with a fast connection won’t notice any FOUT, while users on a slow connection will be able to read their content sooner.

The only remaining barrier to inlining the CSS provided by Google’s external file is maintaining links to the font files on Google’s servers.

And do you know what? Sod it. In my opinion this approach strikes the best balance, and it’s fully standard. No hacks or polyfills, no JavaScript. If we were hosting the files locally, it’s definitely the solution I’d choose. So there’s no value in worrying about what if’s. Let’s give it a go. I’ll update this article if and when I get burned.

Which leaves us with the final render blocking file…

Global stylesheet

The majority of sites rely on at least one external CSS file. Ours is a 32kb global CSS file that contains styles for the whole website.

Here’s the thing: The homepage doesn't need it. Or, at least, it probably doesn’t. As I explained in my explanation of why we’re moving to Styled Components, a major attraction is moving to the promised land, where only the styles required to render the current view are loaded.

All the styles required for the homepage are currently inlined with the initial HTML payload. The only potential issue is ensuring that legacy components, like the alerts menu, are all ported to Styled Components.

This will almost certainly break

As components with missing styles will silently inherit visual bugs, this will be a process of 1) removing the global stylesheet and 2) checking every potential action on the page to ensure any hidden dialogs are correctly ported and styled.

Onward routes

Of course, the rest of the site does need this global stylesheet, so we need a strategy for loading it.

We could potentially make the global stylesheet opt-in by including it in every other component with React Helmet.

<Helmet>
<link rel="stylesheet" href="/path/to/global.css" />
</Helmet>

This would inject the global.css tag only into pages that need it.

My concern with this approach stems from the reason we’re here in the first place: browsers treat CSS as a render blocking resource. With React Helmet, a user could land on a page that doesn’t include the CSS. When they navigate to a route that does, React will need to render it before we even know we need to request it.

This will almost certainly result in a flash of unstyled content.

The solution

Every route on DriveTribe is configured by a route definition file. It contains stuff like data dependencies, root component, a path generation function and an assortment of other settings. It looks a little like this:

const chatRoute: RouteDefinition = {
path: (id: string) => `/chat/${id}`,
view: () => import(/* webpackChunkName: "chat" */'./Chat'),
data: {
check: (state, { params }) => getChat(state, params.resourceId),
fetch: ({ params }) => getChatRequest(params.resourceId)
}
};

All route definitions are included in main.js (which is a potential optimisation for a later article). Conventionally, a React component will define its own data dependencies. This is the approach taken by Next.js amongst others.

However, that means a component needs to be downloaded and parsed before we can even fetch the data. With this configuration file approach, we can fetch the data in parallel with the component.

I’m going to add a new parameter to RouteDefinition, isStylesheetBlocking?: true. As a legacy config, I’ll add it to all routes except the homepage.

Then, in the HTML template I’ll add the following code:

isStylesheetBlocking
? `<link rel="stylesheet" type="text/css" href="${cssPath}">`
: `<link rel="preload" as="style" href="${cssPath}" onload="this.rel='stylesheet'">`;

On pages that require it, the stylesheet will continue to be loaded as a blocking resource.

On newer pages, we can use the <link rel="preload" /> tag to asynchronously fetch the CSS file.

The onload="this.rel='stylesheet'" attribute is a tiny JS command that the tag can use to convert itself to a regular stylesheet resource once the CSS has loaded.

rel="preload" doesn’t, as with so much in life, enjoy wide browser support. Instead, I’ll inline loadCSS at the bottom of the HTML template. This micro script from Filament Group will polyfill the functionality and load the CSS async as desired.

As a final benefit, this approach means that we don’t have to port any remaining components that aren’t immediately visible to Styled Components. As the global CSS is still ultimately loaded, it’s unlikely that that the styles for hidden dialogs will load after the JavaScript that powers the dialogs themselves.

Results

I ran some tests locally before deploying to production. Part due diligence (have I made things worse?) and part sheer curiosity.

While I didn’t expect this to necessarily reflect the gains I’d see on production (where assets are hosted on CDNs, minified and gzipped), I was so shocked by them I ran the tests again. And again. And for good measure… well, I actually left it at three attempts, but I was tempted.

The time until first render on a “fast 3G”, locally, currently sat at 1060ms. After making only the above adjustments, that had plummeted to 295ms.

Encouraged, I deployed to production.

Results, actually

I chose the render blocking CSS as the first performance issue to tackle because I thought it’d be a quick win.

Compared to some of the other identified performance issues, I assumed it would result in relatively minor gains for relatively minor effort. Instead, this has ended up making a big difference to the performance for users on slower connections.

Here’s two filmstrip views of the site throttled to “fast 3G,” before and after:

We can see that originally, our first render didn’t happen until 1.05 seconds, whereas now it happens at 370ms.

The user couldn’t previously read content until 1.16 seconds, whereas now that’s down to 555ms. We could lower this further by using a more aggressive FOUT approach, though I’m happy with this for now.

Lighthouse reports “First meaningful paint” as being reduced from 2 to 1.5 seconds, with a Perceptual Speed Index score improvement of ~500 points (from ~3200 to ~2700).

This speed increase has a knock-on effect all the way down the request chain, providing benefits for the unoptimised assets too. On the throttled Lighthouse test our JavaScript previously didn’t load for 8 seconds, that’s down to 7.5.

Conclusion

Removing the render blocking CSS assets took a day or so of research and development, in return for substantial speed improvements.

Inlining the Google Fonts asset request may prove brittle, but for now it’s knocked out an unnecessary DNS resolution.

Our global stylesheet is now asynchronously loaded by default. Going forward, new routes will enjoy the same performance benefits as the homepage and we’ve put in place a final piece for easing our transition to Styled Components.

If you think all this is exciting, in the next part we’re going to look at ways we can improve our time to first byte. And ultimately, what could be more exciting than that? Literally nothing.

Read Part 3: Time to First Byte

DriveTribe Engineering

Building the future of social content.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store