Everything I Did to Make This Site Fast, Part 1

The web is best when it’s fast. A fast web doesn’t just provide a good experience, though that’s certainly important, it also ensures as many people as possible can access it at all. Many people are on slow, unstable connections. By making a site fast, you make it more likely for them to be able to use it.


Some things I already did

I purposefully did not do many of the tasks below until I was able to write this post, because I wanted to measure the impact of each change and be able to explain each step. However, some things that help the performance of this site would’ve been too time-consuming to build “the slow way” first only to completely change them to make them faster later. For example:

  1. This site is static, meaning that all of the pages are pre-rendered on the server and then served directly. Most websites have many back-and-forth requests with a server before presenting a page; this one does not, which speeds things up considerably. Serving static content typically saves ~500ms of loading time.
  2. I’m using a webfont for the headings, but the body text uses a font stack that leverages the viewer’s system’s default font, which does not need to be loaded from the server.
  3. The animations are built with SVG and driven by a ~40 Kb JS library. It would’ve been easier to just include them as gifs or videos, but that would’ve had much larger download costs.

Please see the Colophon for more details.

Snapshot before any improvements

Before I do anything, let‘s get a snapshot of the current state of the site, to serve as a baseline. I‘m going to use the excellent WebPageTest and PageSpeed Insights for this.

Note: For all of the WebPageTest snapshots, I‘m testing the homepage of the site, three times, on a Motorola G in Chrome with a ‘3G - slow’ connection. The PageSpeed Insight snapshots are ran once, on the homepage.

Metric First View Repeat View
PageSpeed (Mobile) 68/100
PageSpeed (Desktop) 71/100
Speed Index (Mobile) 2025 511
Load Time (Mobile) 8.666s 2.864s
Summary of initial snapshot. Full results.

While developing on my home’s broadband connection, I thought the site felt fairly zippy, but these baseline results make it clear that there’s plenty of room for improvement. Here’s a breakdown of the content being loaded:

css (1) font (1) html (1) image (3) js (2) 12.5% 12.5% 12.5% 25% 37.5%
Content Type Requests
css 1
flash 0
font 1
html 1
image 3
js 2
other 0
First view content breakdown, by requests.
css (6.5 Kb) font (19.9 Kb) html (7.92 Kb) image (402.1 Kb) js (196 Kb) 31% 63.6%
Content Type Bytes
css 6454
flash 0
font 19974
html 7901
image 402108
js 196044
other 0
First view content breakdown, by bytes.

Typically, the first step in improving web performance is reducing the number of requests. For this relatively simple site, that number is already pretty low. There’s only one image on the page; the other two image requests are tiny and from Google Analytics. The JavaScript is all combined into one bundle except for the Google Analytics package, which is loaded asynchronously. I could embed the font file within the CSS, using base64 encoding, but, as we’ll see later, that would make things worse in other ways.

The next typical step is reducing the size of requests. That definitely applies here, so let’s dig in.

Reducing the size of images

Before this exercise, the avatar image on the home page was a 639px × 639px, 392 Kb PNG. It has a fixed-width of 150px in the design, so I resized to 300px × 300px^[The dimensions are doubled to account for retina screens.], and optimized it while converting to JPEG, which brought it down to 70 Kb.

But I can do more. Not every screen is retina, so I need to serve the smaller, non-retina version by default, then use srcset to serve the retina version to screens that need it:

<img src="/avatar.jpg" srcset="/avatar@2x.jpg 2x" alt="Profile picture of Kyle" />

And the result:

Metric First View Repeat View
PageSpeed (Mobile) 90/100 (+22)
PageSpeed (Desktop) 96/100 (+25)
Speed Index (Mobile) 2056 (+1.5%) 498 (-2.5%)
Load Time (Mobile) 8.666s (-16.2%) 2.864s (+1.01%)
Summary of snapshot after optimizing images. Full results.

I’m not sure why the speed index on the first view and load time on the repeat view both went up slightly, but it’s such a small change that I’m not too concerned. Those oddities aside, this simple optimization resulted in big, easy speed wins.

And here’s the new breakdown:

css (6.5 Kb) font (19.9 Kb) html (7.92 Kb) image (70.1 Kb) js (196 Kb) 23.3% 65.2%
Content Type Bytes
css 6583
flash 0
font 19974
html 7920
image 70060
js 195757
other 0
First view content breakdown after optimizing images, by bytes.

Performance commit #1

Note: Even though I’m only testing the homepage, I went ahead and did this exercise for all images on the site.

Reducing the size of JavaScript and other resources

190 Kb of JavaScript is quite a lot, especially since that size is after gzipping. For now, though, I’m going to leave it be, as I would like to see how fast I can make this site while it still runs React client-side. I suspect that I’ll need to remove React to meet my informal goal of a speed index under 1000, but I’ll save that for last.

My CSS is only 6.5 Kb (gzipped). At that size, that’s too small to justify the effort to make it any smaller^[You should always endeavor to keep your CSS as small and simple as you reasonably can. The effort I’m avoiding here is because my styles are already architected in a way to keep them very small. Any further optimization will require either a lot of manual fine-tuning that could break future updates or an automated process using something like PurifyCSS. I intend to implement the latter in a future post.]. There are other reasons being the filesize to make CSS as small as possible, which I’ll cover later in this post.

Absolute vs. perceived performance

So far, I’ve only made changes that affected the page load time/size, which correlates to the absolute page speed. But that’s not all that matters. Arguably, perceived performance matters even more, as that affects how fast your site feels.

For example, check out this filmstrip view of a portion of the experience waiting for the site to load on 3G:

Filmstrip view of this site loading on a mobile device

There’s a full 1.5 seconds between the initial content displaying and the webfont displaying. Worse, because I reference the webfont directly in CSS, via @font-face, there’s a FOIT making the headings completely unreadable until the webfont has finished loading. This is unacceptable.

Preventing a Flash of Invisible Text (FOIT)

I’m going to use FontFaceObserver to instead only apply the webfont to headings after it is loaded. While it is loading, they’ll use the same font stack as the body text, changing the FOIT into a FOUT. In some scenarios, this can provide a poor experience just like a FOIT, but I have a couple things going for me:

  1. The body text is set in a font that doesn’t have to load at all, meaning it will display correctly immediately. So when the headings also use that font while the web font loads, they’ll match the rest of the site.
  2. The webfont I’ve chosen for the headings, League Spartan, has a very similar baseline & caps height, so shifts after the webfont loads are minimal.

After making the change, the filmstrip now looks like:

Filmstrip view of this site loading on a mobile device, after applying a font loading strategy

Much better! You can see how the headings now display for a brief moment using the same font as the rest of the text, and then switch to the webfont after it has finished loading.

Performance commit #2

Clearing the critical path

What you can’t see in the filmstrips above is all that time before anything is visible:

Full filmstrip of site loading

Part of that is because it must download the full CSS file before it displays anything. In other words, the CSS is blocking the critical path, which happens regardless of the file’s size. So even though the size is fairly small, it has an outsized effect on the perceived performance of the site.

I’ll cover my approach for solving that issue (and possibly removing React on the client-side) in a future post.