Sitemap

A web performance audit checklist

14 min readSep 5, 2019

--

Making a website faster is a surprisingly easy thing to do; you only need to decide that you’re going to do it.

Oh and also you need to do it.

Having reviewed and improved the performance of many a web site, I’ve settled on a repeatable set of steps that apply universally. You may not be surprised to hear that these steps are the topic of this article.

Thanks Vlad Kutepov for taking this photo for me and putting it on Unsplash

The benchmark device

If I’m going to make a site faster, I must be able to reliably report on how fast it is. So for consistency I run the site locally, with the network throttled to ‘Fast 3G’ in Chrome DevTools (otherwise requests are too fast to measure). I don’t throttle the CPU for the practical reason that I’m going to be refreshing the site hundreds of times each day. If I have to wait 15 seconds each time I’ll set myself on fire and jump out a window before I finish making the site fast. And that’s bad for performance.

I’m not concerned with ‘real user metrics’; measuring a site’s performance in the real world is great, but it doesn’t help in the process of making it faster.

Instrumentation

‘Time to Interactive’ is the metric I’m most interested in, since TTI represents the point at which a user can start using the site. I’ll find the point in the code that marks the site becoming interactive and add console.log(performance.now()).

That’s my ‘instrumentation’.

I go into more detail in Measuring web performance; it’s really quite simple, if you’re interested.

Someone once said “The definition of insanity is doing the same thing over and over and expecting different results”. That person clearly never refreshed a webpage 5 times to measure load time.

There will always be a variance — 30% is not uncommon — so it’s important to be a peace with it. I wrote a little tool (scatter-bar.web.dev) to capture this inconsistency, without throwing away data by looking only at a median or (gasp) a mean.

Press enter or click to view image in full size

Work out the user split

Most performance advice I see on the Internet of Opinions is focused on first-time visitors. Presumably because it’s easy to think about and explain. But for many sites, improving the experience of repeat visitors helps more people, more of the time.

And so, the order in which I approach the following sections depends on the repeat visitor ratio of the site I’m working on. If 80% of page views are from browsers that have visited the site before, I’ll spend 80% of my time optimising for the repeat-visitor experience. By contrast, the current site I’m working on is mostly first-time visitors, so that’s where my focus is.

I will note in each section below who the primary beneficiaries are. It will be the first sentence of each section and it will get a bit annoying after a while.

Now, on with it, in ascending order of fruit altitude…

Turn on compression

This predominantly benefits first-time visitors.

My favourite thing to discover is a site that isn’t compressing the files they’re sending over the network. I can swoop in, inform them that a thing called ‘compression’ exists, then book tickets to the parade that they will throw in my honour.

It’s a proud moment, to be sure. But it’s a hollow pride, like when you ‘fix’ Nana’s computer just by turning the monitor the right way around (facing Nana).

Crank up the compression, to 11

This predominantly benefits first-time visitors.

My second favourite thing to see is gzipped assets coming across the network (plot twist!). These are almost certainly bigger than they need to be for two reasons: most people accept the default gzip compression level, and gzip isn’t the best compression method anyway.

What I recommend is Brotli compression turned up to 11. Some people will respond that higher compression will result in slower compression, which is true, but irrelevant. You see, it makes almost no difference to decompression. Static assets are compressed ahead of time, so higher compression has no downside.

People are often skeptical of the real world impact, so here’s some numbers that you can be fairly confident I didn’t just make up … if I take the 800 KB vendors.js file from Medium.com:

  • Gzip at level 6 gets it down to 214 KB
  • Brotli at level 11 gets it down to 173 KB

A ~20% reduction in bytes shipped for a low effort task. That’s nuts!

(It’s a low effort task because I don’t know how to do it so I ask someone else to do it for me.)

It’s quite funny, when you think about it: there’s literally a dial you can twist to “make my files smaller”, but most people don’t twist it all the way around.

Pro-tip: if you want to quickly check compression results for a given file, you can run Node in a terminal (by typing node and gently pressing enter) then type …

zlib.gzipSync(fs.readFileSync('./my-file.js')).length

… and from Node v11.7 onwards …

zlib.brotliCompressSync(fs.readFileSync('./my-file.js')).length

Next!

Never wait for the network again

This only benefits repeat visitors.

In the immortal words of Albert Einstein: Service Workers are frikken great.

I agree with Albert. Service Workers are the holy grail when it comes to performance, and explaining the benefits can be a bit of fun …

It is with a childlike glee that I will suggest to someone that repeat visitors don’t need to wait for code to load when visiting their website. They will say yes yes, we know about caching. I will go on to explain that even if the website has been updated, and a returning visitor doesn’t yet have the new code, they still won’t have to wait for the site to load. Not one little millisecond.

At this point, many people will enquire as to whether I was dropped on my head as a baby. When I reply in the negative, some will probe further, suggesting that perhaps something of significant heft was dropped on me. (This I can’t deny; an espresso machine was knocked off a shelf in Harvey Norman when I was zero. If fell straight into the pram and cracked me on the noggin. In a cruel twist, that very same espresso machine was brought home to haunt me from the kitchen counter for years to come, until I was old enough to throw an espresso machine out a window).

Let’s get back to Service Workers.

If you don’t yet know how they work, the magic part is that when a user visits a URL that they’ve already been to, they get the previous version of the site from cache, instantly. If a newer version of the site is available, it is downloaded in the background, and will be used for that user’s next visit.

At this suggestion, some will ask me “what if we want the user to have the new version immediately”. I will point out that they a) don’t really and b) currently have no mechanism in place to prevent a user from leaving a tab open for two days, so this is unlikely a genuine concern. (If it is a real requirement — and they’re not just playing devil’s avocado — it’s quite possible.)

There are tools that make implementing service workers a breeze. Google (the internet company) has created Workbox, a set of tools that do what the website says that they do, and Create React App supports service workers out of the box (out of the Workbox, if I may).

Lots and lots of files

This section only benefits repeat visitors. And has very little benefit if a site already has a Service Worker running.

If I see a site that concatenates JavaScript into a single bundle, I know there’s significant performance gains to be made. And it’s somewhat cute to see assets split into two, such as main.js and vendors.js. It’s like when a child draws a picture, and you know they tried hard so you say ‘good job’ and stick it up on the fridge even though it’s a bit shit.

This more-files-is better situation unfortunately requires explaining over and over that having fewer files is old advice that is now a performance hindrance. (In the rare event that a site isn’t using HTTP/2 already, turning that on must come before going further.)

I recommend that a site split their assets up into many many small files, ideally one per npm package and as small as is reasonable for application code. By splitting JavaScript into smaller files, a site can halve the number of bytes shipped in real world scenarios. It’s somewhat confusing to think about, so if you can’t see how this is possible, you can read about the ‘how’ and the ‘why’ in The 100% correct way to split your chunks with Webpack.

Unused code

The following optimisation techniques predominantly benefit first-time visitors, and are divided into the four types of unused code.

Never-used code

When a site uses third-party packages, there’s a chance that unused code is being included and shipped all the way into users’ hands.

To find instances of such wastage, I’ll start with something like webpack-bundle-analyzer (or the less-good source-map-explorer if I’m auditing an unejected CRA app), then go about the painstaking process of pointing out packages with purposeless parts.

Sometimes it’s as simple as typing import x from 'package/x'; instead of import {x} from 'package';. Sometimes it’s far more involved.

I’d like to share with you The Turf Story, to demonstrate that even though hunting for never-used code can be an arduous task and frequently fruitless, every now and then it will reveal something that makes it all worthwhile.

Let the story begin: importing anything from the @turf/turf package (you don’t need to know what Turf does) will give you all of Turf, no matter which import method you use. To avoid this, you need to install standalone npm modules. This results in a package.json containing this orderly mess:

"@turf/along": "^6.0.1",
"@turf/area": "^6.0.1",
"@turf/bbox": "^6.0.1",
"@turf/bbox-polygon": "^6.0.1",
"@turf/bearing": "^6.0.1",
"@turf/boolean-contains": "^6.0.1",
"@turf/boolean-crosses": "^6.0.1",
"@turf/buffer": "^5.1.5",
"@turf/center": "^6.0.1",
"@turf/centroid": "^6.0.2",
"@turf/clean-coords": "^6.0.1",
"@turf/clone": "^6.0.2",
"@turf/distance": "^6.0.1",
"@turf/explode": "^5.1.5",
"@turf/helpers": "^6.1.4",
"@turf/invariant": "^6.1.2",
"@turf/length": "^6.0.2",
"@turf/line-intersect": "^6.0.2",
"@turf/line-offset": "^5.1.5",
"@turf/line-slice": "^5.1.5",
"@turf/line-slice-along": "^5.1.5",
"@turf/meta": "^6.0.2",
"@turf/midpoint": "^5.1.5",
"@turf/nearest-point-on-line": "^6.0.2",
"@turf/point-to-line-distance": "^6.0.0",
"@turf/points-within-polygon": "^5.1.5",
"@turf/polygon-to-line": "^6.0.3",
"@turf/shortest-path": "^5.1.5",
"@turf/simplify": "^5.1.5",
"@turf/transform-scale": "^5.1.5",
"@turf/truncate": "^6.0.1",

Going through the codebase to discover which modules were used, installing the appropriate npm package and changing the references was painful, but saved 131 KB after compression. Yes you read that right. Installing those 29 packages individually, rather than importing them from the main Turf package, reduced the compressed size of a site by 131 KB.

The only way for me to discover something like this is by going through all the third-party packages in a site — from big to small until I feel murdery — and digging into what they load and why, often diving into their sauce to see how they include modules internally.

(I know it’s spelled ‘source’, but I like the image of diving into sauce.)

131 KB!

Too-big code

Sometimes developers use packages that are bigger than they need to be.

An easy one: wherever I see the moment package being used (which is 20 KB compressed), I will replace it with date-fns (and use only the required functions). Usually saving pretty close to 20 KB.

I’ll also look into any particularly large libraries that are not widely used in the codebase. A developer may have included Immutable JS (for memory about 20 KB) to use in only one place. So the question is: if that could be written from scratch in a few days, is that worth it to save maybe 15 KB?

Well? Is it, punk?

Not-used-by-all-users code

This third type of unused code refers to polyfills. If you’re supporting IE11, you’ll need about 25 KB of polyfills, give or take. To ensure that these bytes aren’t shipped to browsers that will never need them, I generally suggest a service called polyfill.io. This will load only the polyfills required by the browser making the request.

But this brings third-party risk to a site, so my approach depends on the situation (the circumstances in which I find myself, not the Jersey Shore star). For a site with a million hits-a-day, I implemented a bespoke solution for polyfills, to avoid third-party risk. But on a seven-hits-a-day site I worked on, I used polyfill.io because it’s so damn easy and I had bigger fish to fry.

(If you want to roll your own solution, I wrote many words about polyfills in Polyfills: everything you ever wanted to know, or maybe a bit less.)

Not-used-right-now code

For first time visitors, only loading the code that is required to get you to TTI can greatly reduce the load time.

There’s obvious things to implement like route-based code splitting (only loading the JavaScript/CSS required for the current page) so I get that out of the way first.

Then I’ll take a look at each page and work out what features aren’t required immediately. These might be hefty UI, or functionality that requires a large amount of JavaScript.

Once these features are identified (sometimes there are none), I work on creating separate bundles of JS/CSS that are only loaded when needed. This used to be a giant pain in the ass, but is pretty easy in 2019, and probably will be easy in the years that come after 2019, all the way up to 2024 (wink).

Dynamic import() — combined with a bundler that automatically splits code loaded by dynamic imports (Webpack, Rollup, Parcel) — makes loading code on demand a simple task. In React land, React.lazy and the <Suspense/> component (great band name) make it similarly easy to fetch the required code only when a component is about to be mounted.

Perhaps an example of something that sounds complicated but was actually pretty easy will entice the reticent among you to head down this path …

The site I most recently gave a performance tune up is a mapping app. It takes a client’s indoor map and layers it over a third-party world map (provided by the excellent Mapbox). The app can provide directions from one map to the other (e.g. from you home to your seat in a stadium via the best parking spot).

(If you own a stadium or a hospital or a small city and want these neato maps, my employer would probably like to hear from you.)

Before I started, everything on the page was loaded in one large chunk of code, which was a lot of code, and it delayed the Mapbox map from getting started until the entire application’s JavaScript had been downloaded, parsed, executed, and the DOM rendered.

After some performance tweaking (and some performance twerking), the loading sequence now goes like this:

  • Load an empty HTML file (no such thing as server-rendering a WebGL map)
  • Load the CSS required to render the shell of the app and Mapbox
  • Load the JS required to render the app shell and start Mapbox loading
  • At the same time, fetch the client map file from the server
  • In parallel with that fetch a list of locations to populate the map with
  • Once the locations have arrived, fetch the code required to show the search UI
  • Once the client map is returned and the page is idle, parse the map to prepare the routing engine for getting indoor directions (CPU intensive)
  • Once the routing engine is ready, make the directions UI available

I would call this a moderately complicated scenario, but it took about half a day to implement; it’s just a handful of dynamic imports, some React lazy loading, and a requestIdleCallback to delay the heavy CPU work. Webpack takes care of creating individual bundles of JavaScript and CSS for routes and modules and components and ensures that they’re only loaded when required.

What a time to be alive!

OK that was the last of the unused code activities. Now …

Use a Content Delivery Network

This predominantly benefits first-time visitors. See, it’s getting annoying isn’t it.

If requests for static files are making it all the way to a web server, there’s usually a quick win to be had by moving them to a CDN. Most people already do this these days, but that’s not always the end of the story.

Coming up with a plan for serving assets from a CDN is easy for static content (cache it) and easy for highly dynamic content (don’t cache it), but things get trickier in the space in between.

For example, I might be attempting to improve the load time of a shopping site’s home page that shows a periodically changing selection of products. This can be cached in a CDN (on a CDN?), but only until the selection of products changes. This will require a trigger to flush the CDN cache in response to a change in the product selection. It’s reasonably complicated to do this, but for heavily trafficked pages that only occasionally change, with a high first-time visitor ratio, this is worth the effort.

The same goes for caching API responses. Conventional wisdom says don’t do it. My wisdom says: if it’ll make the site faster, do it, but be careful.

The little things

Everything I’ve mentioned so far is about reducing the Time to Interactive (TTI) of a site, and this represents the biggest wins.

But there are some simple things that can be done to make modest improvements to the perceived speed of a site by rearranging the order in which things happen in the browser.

To start with: scripts never go in the <head>. I don’t care what the docs for Google Analytics or Mixpanel or New Relic say, scripts don’t go in the head. They go at the bottom of the body after all the HTML destined for your user’s eye holes.

Of course, I will add a <link rel="preload" /> tag to the head for each asset I plan to load on that page so the browser can start downloading/parsing the file, or if it’s already in cache, just start parsing the file.

These changes probably won’t affect TTI, but will — in many cases — reduce the First Contentful Paint and First Meaningful Paint times (the two where the user can look but not touch).

For some sites, on some pages, where it’s reasonable to guess where the user will go next (e.g. from a home page to an article page), I recommend <link rel="prefetch"> tags to start loading the resources required for that next page. If the likelihood of a visitor going to another page is very low and the asset sizes are very high, I won’t do it out of respect for a user’s data plan. But generally we’re talking about a few dozen additional KB that will then be cached, so it’s reasonable to prefetch away.

If I’m working on a site with third-party ads, I will have had an ad blocker turned on up until this point, but now I’ll turn it off because these shuffling efforts can make a big difference. Most ads are massive CPU hogs — as soon as you mount one it can take over the CPU for several seconds — because the people that make ads were all dropped as babies.

User interactions

Most of what I’ve laid out so far is very similar on all sites — I would go through exactly this list for any site that I do a performance audit on.

This section though is about finding performance issues unique to a site. There is no one-size-fits-all approach to this, so I’m just going to leave you with my general process, which results in a table of potential tasks.

  • Click my way through a site and perform all the actions
  • Create a list anything that feels slower than it should be (on a moderately powerful device)
  • For each problem area, dig around in the code to get a sense for how much that time could be reduced and how much effort would be involved in doing so
  • Find out how frequently each of the actions is performed

For example, when cropping a user profile photo, it might take 2 seconds to load the cropping UI. But I can see the code is doing something dumb, and reckon with two days’ effort that time could be halved. But, cropping a profile photo is a relatively rare activity, so perhaps this will become a task at the bottom of the backlog (after all, performance is a feature and should be prioritised against all the other features).

Was that example necessary? Probably not.

Backend performance

I’m not going to say anything about backend performance for two reasons:

  • It’s not my area of expertise and I don’t want to give away bad advice (I have no problem giving bad advice about the frontend)
  • It would double the length of this 14 minute blog post and really you’ve probably got things to do.

That’s all!

I hope you didn’t really expect a concise checklist.

Also, this is article number 100! Woot woot! I’m going to go celebrate by sitting in the dark for 90 minutes.

--

--