Creating a fast website

by about JavaScript in Technology

What does it take to create a website that loads in under one second? Is the loading time the measure of speed?

There are a great many websites on the Internet that describe how to make a fast website. Most of them are regurgitations of the 2006 Yahoo Best Practices for Speeding Up Your Web Site, while others are rather nebulous in description. A lot of the advice you see online can be a bit dated by modern standards.

One of the more recent trendsetters in terms of making websites fast was the Google AMP project which had a set of rather strict rules how your website must be constructed to be included. AMP pages, however, often scored worse on performance metrics than optimized regular pages. In addition, to be eligible for AMP-based features in Google you would need to allow Google to cache your content. This earned them an anti-trust lawsuit (well worth a read) and Google has been moving away from the AMP requirement to feature a site in 2020. In fact, Kinsta reported that AMP actually hurt their search ranking.

However, there are lessons to be learned. Lessons that are partially incorporated into the PageSpeed Insights.

Measuring speed ▲ Back to top

When asking someone what makes a fast website most everybody would answer: “It loads fast.”

However, that’s not entirely accurate. Yes, we humans don’t like when the loading spinner or bar is working for too long, but then again, there are websites where the loading indicator stops fairly soon, but the website keeps jumping around and resizing as additional content, such as images, videos, etc. are loaded.

Google does a pretty good job of defining these metrics in their Core Web Vitals section. There are a couple that are interesting to us:

First Contentful Paint
This metric shows how long it takes for anything to appear in the browser window. This is not a good metric to measure against since plenty of things can go wrong after the initial content of the website is available.
Largest Contentful Paint
How long it takes to render the largest object on the page (e.g. an image or text block). This metric can indicate when the user can start consuming the content on the website.
First Input Delay
How long it takes for the user to be able to do something (e.g. scroll, click, etc.). This metric is usually hampered by extensive JavaScript. More on that later.
Cumulative Layout Shift
How much the website changes, for example due to loading images. This metric should be kept as small as possible as users will not be able to use the site if the screen keeps jumping up and down.

This post will work through the process of optimizing a website for these metrics.

The critical path ▲ Back to top

One of the lessons learned from the AMP project was the question of the critical path: what resources does the browser need to display the first screen of the website to the user?

Think of things like stylesheets, images, videos, etc. If these take a long time to show to the user the website will be perceived as slow. Note, that you don’t actually have to load everything, but the website has to appear stable on the initial load. You can still load higher resolution images and content further down the page after the fact.

Ok, so you have your list of items that you need to display the page? Great! Now, there are two numbers you want to pay attention to: how much data do you need to load and how many items are there?

The data part is obvious: the more data you need to load the slower the website will be. If you are loading a 200 MB video for your splash screen it could take a while. (I actually had to work on optimizing a page in the past that did this.)

The number of items is a bit trickier. You see, every time your browser has to fetch something it has to send a request to the server and wait for the response. Of course, your browser sends multiple requests in parallel, but it can’t figure out which CSS file to load until it has at least part of the HTML, and it can’t figure out what images the CSS embeds until it has at least loaded parts of that.

The number of requests adds up very quickly. It’s not uncommon to see websites that do 100+ requests on their first load. You can partially fix this problem by placing your content close to the end user, for example by using a Content Delivery Network, but it doesn’t entirely get rid of it. This is especially true if you are embedding a lot of stuff from third party services. Try to lazy-load those kinds of things as much as possible (see below).

Inlining ▲ Back to top

A good technique for reducing the number of requests is called inlining. Of course, you can reference an image in your website like this:

<img src="logo.svg" alt="Have you debugged.it?" />

Or you can inline it using a data URI:

<img src="data:image/svg+xml;base64,BASE-64-ENCODED-FILE-HERE" />
Not everything needs to be base 64!
Data URIs work without base64 encoding if you are not encoding binary data. Experiment and figure out what encoding type you need!

This is just one example on inlining, but you can also inline CSS using a <style> tag, JavaScript using a <script> tag, etc.

However, inlining has a major drawback: if you inline content directly into your HTML the user will have no benefit from caching. They will have to download the inlined content over and over and over again as they navigate your page.

There are multiple ways around this: you could inline images into your CSS, but not into the HTML, or you could inline only low-quality images into the HTML and then lazy-load high quality images later.

On this website we know most of our visitors only read a single article, so they wouldn’t benefit from caching. Therefore, we inlined almost everything. In fact, the initial page load is entirely done in a single request and only high quality images are loaded after the entire page has already loaded and rendered.

Optimizing CSS ▲ Back to top

When inlining CSS you should pay special attention to what you are inlining. Including the entire CSS for all pages you are adding a lot of rules that aren’t actually used.

However, there are tools out there that can help you. I, for one, prefer to have a simple solution, so I opted for feature detection. For example, I’m only loading the CSS rules for a grid if the page actually contains a grid.

Tools like the SCSS preprocessor can be a wonderful help to achieve modular CSS for each page type.

In our example, we are using Hugo to generate our pages and we did the following:

{{- $css := "" -}}
{{- $css = printf "%s%s" $css (partial "scss" (dict "file" "scss/common.scss") | safeCSS) -}}
{{- if in .Content "class=\"admonition" -}}
    {{- $css = printf "%s%s" $css (partial "scss" (dict "file" "scss/admonition.scss") | safeCSS) -}}
{{- end -}}

It looks a bit complicated due to the templating language, but the main point is that common.scss is always included, while admonition.scss is only included if the .Content variable includes the admonition class. There are, of course, more rules for additional features.

Lazy-loading images ▲ Back to top

The next optimization we can do is lazy-loading images. There are multiple techniques to do this, but since we are aiming for page stability (avoid content jumping around) we will use the Low Quality Image Placeholders or LQIP technique.

While there are a ton of JavaScript libraries (of course there are), you don’t need them. It’s just a few lines of code.

First, we create the HTML structure. Let’s start with a simple HTML structure:

<img src="data:image/webp;base64,BASE-64-ENCODED-LQ-IMAGE-HERE"
   alt="Alternative description of the image for screen readers."
/>

As you can see, the low-quality image is inlined here. Creating this image, however, is the first tricky part. Your first instinct could be to create a low resolution image (a few hundred pixels in size instead of the original size). You would then try to upsize the image by providing the height and width parameters:

<img src="data:image/webp;base64,BASE-64-ENCODED-LQ-IMAGE-HERE"
   alt="Alternative description of the image for screen readers."
   height="2000"
   width="1360"
/>

However, the problem then comes when you are trying to make the website responsive. You’d apply the following CSS rule:

img {
  max-width: 100%;
}

This will cause the image to be squished, keeping its original height while adapting to the width.

So, in order to create a responsive website the low quality image has to have the same dimensions as the original, or it must be sized with JavaScript. We wanted to make our website work without JavaScript we opted to create full-size low quality images in the WebP format with a quality option of just 5%. This creates a sufficiently small file to be inlined.

<div class="lqip">
  <img src="/path/to/hq/image.webp"
       alt=""
       aria-hidden="true"
       class="lqip__hq"
  />
  <img src="data:image/webp;base64,BASE-64-ENCODED-LQ-IMAGE-HERE"
       alt="Alternative description of the image for screen readers."
       class="lqip__lq"
  />
</div>

Now we can add the CSS rules:

.lqip {
  position: relative;
}
.lqip__hq {
  position: absolute;
  height:   100%;
}

These rules take the high quality image and place it on top of the low quality image. This almost gets us there, but we need one additional change. We need to make sure that the loading of the image only happens after the page has finished loading. We will modify the high quality image a bit:

<div class="lqip">
  <img src="data:image/webp;base64,BASE-64-ENCODED-SINGLE-PIXEL-IMAGE"
       data-src="/path/to/hq/image.webp"
       alt=""
       aria-hidden="true"
       class="lqip__hq"
  />
  <img src="data:image/webp;base64,BASE-64-ENCODED-LQ-IMAGE-HERE"
       alt="Alternative description of the image for screen readers."
       class="lqip__lq"
  />
</div>

The original src attribute will reference a single pixel transparent image for HTML validity. We’ll then add a tiny bit of JavaScript to take the data-src attribute and move it into the src attribute.

window.addEventListener("load", function (){
  for (let img of document.getElementsByClassName("lqip__hq")) {
    img.src = img.dataset.src
  } 
}

So far so good, this will produce a very nice loading effect in Chrome, but a rather ugly white flash in Firefox. Let’s extend the script a bit:

window.addEventListener("load", function (){
  for (let img of document.getElementsByClassName("lqip__hq")) {
    img.classList.add("lqip__hq--loading")
    img.addEventListener("load", function () {
      img.classList.remove("lqip__hq--loading")
    })
    img.src = img.dataset.src
  }
}

This will add a class to the high quality image while it’s loading. Now we can add a bit of CSS to make a nice fade in animation once the image has finished loading:

.lqip__hq {
  transition: opacity ease-in-out 0.6s;
  opacity:    1;
}
.lqip__hq--loading {
  opacity:    0;
}

Dynamic image resolutions ▲ Back to top

In the above optimization we are still loading a high resolution image, regardless of screen resolution or bandwidth. Sadly, we can’t optimize for bandwidth yet because the prefers-reduced-data feature is still experimental, but we can optimize for lower screen resolutions using the srcset and sizes attributes.

Together with the previous optimization the high quality image will look as follows:

<img src="data:image/webp;base64,BASE-64-ENCODED-SINGLE-PIXEL-IMAGE"
     data-sizes="
       /path/to/hq/image-480.webp 480w,
       /path/to/hq/image-720.webp 720w,
       /path/to/hq/image-1280.webp 1280w,
       /path/to/hq/image.webp 1600w
     "
     data-srcset="
       (max-width: 520px) 480px,
       (max-width: 760px) 720px,
       (max-width: 1320px) 1280px,
       1600px,
     "
     alt=""
     aria-hidden="true"
     class="lqip__hq"
/>

So, what’s going on here? First, we define data-sizes. This will later be copied to the sizes attributes by JavaScript as described before, together with data-srcset. The sizes attribute will contain a set of images for specific width. We do not specify the with in pixels, rather we use the w determination. These are widths that will be used by the srcset attribute. The srcset takes a look at the screen width using the max-width media selector and pairs the screen size up with a pixel size for images.

Tip:
As you can see, the screen size doesn’t match the image sizes exactly. In our case, we have a margin of 20 pixels left and right, which is why we are loading images that are slightly smaller.

Now we have our image sizes defined, let’s adapt our LQIP script accordingly:

window.addEventListener("load", function (){
  for (let img of document.getElementsByClassName("lqip__hq")) {
    img.classList.add("lqip__hq--loading")
    img.addEventListener("load", function () {
      img.classList.remove("lqip__hq--loading")
    })
    img.sizes  = img.dataset.sizes
    img.srcset = img.dataset.srcset
  }
}

That’s it! We optimized our images perfectly. Once the prefers-reduced-data media query becomes available we can even optimize images for connections with limited data.

Lazy-loading videos ▲ Back to top

One of the largest performance hogs I’ve seen during my career are videos from sites like YouTube. This is less noticeable when a page contains only one video, but can quickly add up. Include a dozen videos on your page and you may have a few seconds of load time on your hands. Since YouTube doesn’t work without JavaScript anyway, we can lazy-load these videos similar to images too. First, the HTML:

<iframe src="about:blank"
        data-src="https://youtube.com/embed/..."
        class="video">
</iframe>

The JavaScript part will be equally simple:

window.addEventListener("load", function (){
  for (let img of document.getElementsByClassName("video")) {
    img.src = img.dataset.src
  }
}

You can, and should, then make your YouTube embed responsive to adjust to screen sizes. There are tons of solutions out there.

External JavaScript (ads, trackers, etc) ▲ Back to top

Here comes the ugly part. When not working on a personal site, such as this one, there are often requirements to include external services. This, unfortunately, can greatly slow down, or occasionally even take down a website. Look at the example of Amazon S3 going down in 2017. While any server or service can be slow or go down, the chances of this happening are multiplying when adding several external services.

Furthermore, you now rely on the developers of said service for your own website performance. In my experience most of third party services' embed JavaScript code are extremely shoddy.

To mitigate the damage we can add the defer statement to script tags:

<script src="https://your-external-service" defer></script>

This will cause the script to load once the page has finished loading. Unfortunately, this does not always work as some third party scripts rely on being able to manipulate the page during the loading process.

Cache headers ▲ Back to top

So far we have talked about loading resources (images, stylesheets, etc) efficiently. When a user spends an extended period of time, clicks through a few pages, you don’t want them to download your high resolution images for every page. That’s where caching comes into play. When instructed, a browser can save these assets in a cache, so it doesn’t need to download them again.

The default behavior is to cache, but verify if the page is out of date with the server every time an asset is needed. Linked assets are only checked if the page itself is out of date.

How does this work?
The browser can send the If-Modified-Since header, for example. The server will ony send the file if it has been modified since the given time.

If we are certain that our assets aren’t going to change we can set the Cache-Control header. We can instruct the browser to never check with the server. These headers need to be set up in your webserver, or if you are using a CDN you can configure them there.

The full list of directives is documented really well on MDN. You may also want to read the detailed description of how caching works.

If you want a short list of things to set/unset for your static assets, here goes:

  • Set Cache-Control to max-age=31536000 to cache for a year for static assets.
  • Optionally, add s-maxage=31536000 for proxies.
  • Set Cache-Control to no-cache or a low max-age for dynamic or content pages.
  • Add no-transform to your Cache-Control header to prevent proxies from altering or compressing your content, for example Google Web Light
  • Unset the ETag header.
  • Unset the Pragma header.

Cacheable URLs ▲ Back to top

What happens when you set the cache headers, but we want to change a resource before the cache timer disappears?

Simple: we change the URL. It can be as much as appending a version to it:

<img src="/your-image.webp?v=5" alt="" />

That’s it, it’s that simple. Of course, some content management systems or static site builders automatically do this for you by renaming the file every time you change it.

Compression ▲ Back to top

Another optimization that’s easy to add is compression. In fact, it is enabled in most web servers, but you may want to tune it to include additonal files. In general, all text files, including SVG files, should be compressed since they drop the file size up to 80-90%. Search for GZIP compression in your server’s or CDN’s documentation.

Server and network speed ▲ Back to top

What we haven’t talked about yet is server and network speed. I have left these for the last point because the browser-side optimizations are the low-hanging fruit. Optimizing the server side is more difficult and sometimes even impossible.

Before you jump into it, think about your audience: are they global or local? If they are global you may want to use a content delivery network. I even built my own CDN because I was dissatisfied with the services at the time. (Nowadays Netlify is doing a pretty good job at serving this website.)

However, if your audience is local you may be able to get away with hosting it locally. If your audience is in a place like my birth country Hungary, you may notice that a number of internet providers don’t have enough international bandwidth and clog up in the evening hours. That means you must host locally for the best user experience. It all depends on your audience. You should measure how long it takes for your pages to load for your users and optimize accordingly.

Leaving the network behind and looking at the server speed, optimizations become even trickier. Content management systems can often be a performance hog. Many don’t let you plug in a CDN, or even if they do for dynamic content, serving HTML can still be slow. If your editors are technical enough, you may be able to switch to a static site generator such as Hugo, Jekyll, mkdocs, etc. However, such a switch involves a lot of work and may not be possible.

If nothing else helps, and you have a system administrator on hand, deploying a cache like Varnish may be your final solution.

Measure, measure, measure ▲ Back to top

As a final piece of advice I have this for you: test your optimizations. Modern browsers have a feature in their developer toolbar that lets you simulate slow network speeds. This will allow you to simulate how a page will load when someone is browsing on a subway train or via an airplane WLAN. It will show not only what parts are slow, but if there are any ugly glitches while loading.

Google PageSpeed Insights is also an excellent tool for figuring out if something is wrong with your website. If you need a local version Lighthouse in Google Chrome gives you the same metrics.