Making my Site Load Faster

...without sacrificing design

July 12, 2018

EDIT 2020: Updated to switch from webpack to rollup, and added pre-rendered math.

Recently, it became time for me to update my website - the old one was not optimized for mobile, which is now the dominant “casual browsing” method. I use Jekyll to generate my website, so I found a great bootstrap-4 based template, and with a bit of tinkering, moved my site over.

There was one problem, however. My old website always loaded in half a second, no matter how fast my internet connection. It was about 400kB of data in total, including all CSS and javascript. This new site took several seconds to display a page over a slow connection, with 5 round-trips before showing text, downloading nearly 3MB of fonts, icons, CSS and javascript.

This = Utter load of garbage

My website is a very basic blog and landing page. There is no reason for someone to wait more than a second to read a stupid blog post! When I browse, if a site shows any resistance to being read, whether through a “subscribe” popup, or deciding that it requires 50MB of CSS and javascript for me to read a paragraph about something I am vaguely curious about, I simply click the back button and go elsewhere.

On the other hand, I don’t want my site to look like it was made in 1998, nor do I want to spend hours manually writing CSS and javascript to make it as compact as possible. I just want to make slight modifications to an existing template, and call it a day.

The middle ground I chose was to include all the libraries, fonts and javascript I want, including bootstrap, jquery, mathjax, google fonts, and fontawesome, and then to use existing tools to automatically optimize everything as much as possible, giving me a sweet spot: a site that still loads fast, but loses nothing over the original large template.

The Elephant In The Room: FontAwesome

I use fontawesome icons for the links at the bottom of the site (scroll down to see). However, it was by far the most taxing part of the website, single-handedly increasing the site’s loading time, and being 2MB by itself (!!). In keeping with the goal of not sacrificing anything, I keep fontawesome, but I include only the icons I need directly in my page’s javascript.

By using a bundler (rollup), and the following code, I was able to remove the fontawesome dependency:

import { library, dom } from "@fortawesome/fontawesome-svg-core";
import {
faCircle,
faKey,
faBars,
faLink,
} from "@fortawesome/free-solid-svg-icons";
import {
faTwitter,
faGithub,
faLinkedin,
} from "@fortawesome/free-brands-svg-icons";

library.add(faCircle, faTwitter, faGithub, faLinkedin, faKey, faBars, faLink);
dom.watch();
rollup.config.js
const path = require("path");
import { terser } from "rollup-plugin-terser";
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
export default {
input: "./_js/main.js",
output: {
file: "./assets/bundle.js",
format: "iife",
name: "bundle",
globals: {
jQuery: "jQuery",
},
},
plugins: [resolve(), commonjs(), terser()],
external: ["jQuery"],
};

This, along with switching to the slim version of jquery, by itself nearly did the job:

Way better

Minify & Pre-Compress ALL THE THINGS!

This is already pretty good. But we can do better. Once Jekyll generates the website, there are a bunch of html pages, css and js files that are served by my web server. There are 3 easy avenues for optimization here:

  • My website has a bunch of css, but it only uses a small subset of it. It would be nice to have an automated tool that looks through the generated html files, and removes all unused rules from my css files!
  • The generated html, javascript, and css files can be minified - there is a bunch of unnecessary whitespace that eats up a (tiny) amount of extra space. Might as well get rid of it.
  • My server, Caddy, has the option of gzip-compressing files as they are served to decrease file sizes. It also has the option of serving pre-compressed data. This allows me to compress each file once using the highest settings of the brotli compression algorithm, which while computationally expensive, leads to significantly smaller file sizes.

The above 3 are all achieved using purgecss, html-minifier, and the brotli compressor. My website therefore gained a build script that is run after Jekyll generates all html files:

#!/bin/bash
echo "-> Purging CSS"
purgecss -c purgecss.config.js --o _site/assets
echo "-> Minifying HTML"
html-minifier --file-ext html --input-dir ./_site --output-dir ./_site --minify-css --minify-js --remove-comments --collapse-whitespace --conservative-collapse --case-sensitive --no-include-auto-generated-tags
echo "-> Pre-Compressing assets"
FILES=`find _site/ -name '*.html' -o -name '*.js' -o -name '*.css' -o -name '*.txt' -o -name '*.xml' -o -name '*.ipynb'`
for f in $FILES; do
brotli -q 11 --keep --force $f
done
purgecss.config.js
module.exports = {
content: [
"_site/index.html",
"_site/*/index.html",
"_site/*/*/*/*/*/index.html",
"_site/*/*/*/*/**/index.html",
"_site/posts/*/index.html",
],
css: ["_site/assets/main.css"],
extractors: [
{
extractor: class {
static extract(content) {
return content.match(/[A-z0-9-:\/]+/g) || [];
}
},
extensions: ["html", "js"],
},
],
};

This reduced the amount of data transferred by 25%, down to 245kB, and shaved about a second off the load time:

Less than 400kB transferred! Beat the old site!

Preload Fonts

One big issue with my site is that it uses custom fonts that need to be downloaded. This can be particularly jarring for a visitor since the website seems loaded, and then fonts on text suddenly change. I hate it when this happens on other websites, so it’s important to minimize this issue here!

These fonts are defined in my website’s css file, so to display the site, a browser needs 3 consecutive round-trips. One to load the html, one to load the css, and then the final to load the fonts. The final optimization here will get rid of this last trip, by telling the browser about the fonts directly in the html file header, allowing it to start loading them while it is loading the css:

<link
rel="preload"
href="/assets/fonts/open-sans-v15-latin-800.woff2"
as="font"
type="font/woff2"
crossorigin
/>

<link
rel="preload"
href="/assets/fonts/open-sans-v15-latin-300.woff2"
as="font"
type="font/woff2"
crossorigin
/>

<link
rel="preload"
href="/assets/fonts/lora-v12-latin-italic.woff2"
as="font"
type="font/woff2"
crossorigin
/>

<link
rel="preload"
href="/assets/fonts/lora-v12-latin-regular.woff2"
as="font"
type="font/woff2"
crossorigin
/>
Uhh... this is worse?

What happened here? This should be better, but it is worse! This is actually because the fonts are loaded before other scripts now, which don’t affect the page as much visually. The above are all simulated using Chrome’s “fast 3G” throttling setting, which mimics a slow internet connection. With a fast internet connection, the round-trip time dominates here, and the site does load a bit faster.

Furthermore, even when on a slow connection where it does take more time in total, the fonts pop in a tiny bit faster, which makes the actual user experience slightly better, as seen here (left is with preload, right is without):

It is that half-second that makes all the difference.

Pre-Render Math

New in Jun 2020

The above optimizations served my website well for 2 years. Since then, however, a new version of the math-rendering library MathJax came out. I switched over to the SVG version, which renders equations as SVG pictures, and allowed me to add special CSS to make the rendered equations mobile-friendly (i.e: the equations fit the screen on the phone!). This, however, came with a big caveat:

MathJax is 2/3 of the entire website!

When decompressed, my site was once again over 2MB! Way too big for a stupid blog! Of the 706kB compressed resources transferred to load the site, 523kB is the MathJax library. Furthermore, google’s “lighthouse” report gave bad news:

I don't know much, but I'm guessing fixing the orange/red parts will help.

It is clear, MathJax has got to go. Instead of including MathJax javascript, I will render the math during the website build stage. Thankfully, the new version of MathJax has a sample node script that does just this. After fixing a couple little issues, I added a math-embedding step to the beginning of my site’s optimization script:

echo "-> Embedding Math"
node -r esm ./embed_math.js ./_site --ignore causality/visualize/index.html --ignore myfile.html
Math Embedding Script
#! /usr/bin/env node

const { mathjax } = require('mathjax-full/js/mathjax.js');
const { TeX } = require('mathjax-full/js/input/tex.js');
const { SVG } = require('mathjax-full/js/output/svg.js');
const { liteAdaptor } = require('mathjax-full/js/adaptors/liteAdaptor.js');

const { RegisterHTMLHandler } = require('mathjax-full/js/handlers/html.js');
const { AssistiveMmlHandler } = require('mathjax-full/js/a11y/assistive-mml.js');

const { AllPackages } = require('mathjax-full/js/input/tex/AllPackages.js');

const glob = require('glob');
const fs = require('fs');

require('mathjax-full/js/util/entities/all.js');

const argv = require('yargs')
.demand(1).strict()
.usage('$0 [options] ./myfolder')
.options({
packages: {
default: AllPackages.sort().join(', '),
describe: 'the packages to use, e.g. "base, ams"'
},
fontCache: {
default: 'local',
describe: 'cache type: local, global, none'
},
ignore: {
default: [],
describe: 'files to ignore when processing',
type: 'array'
}
})
.argv;

const adaptor = liteAdaptor();
AssistiveMmlHandler(RegisterHTMLHandler(adaptor));
const tex = new TeX({
packages: argv.packages.split(/\s*,\s*/),
inlineMath: [
["$", "$"],
["\\(", "\\)"],
],
});

const files = glob.sync(argv._[0] + '/**/*.html').filter(file => !argv.ignore.includes(file.substring(argv._[0].length + 1, file.length)));

files.forEach(file => {
const svg = new SVG({ fontCache: argv.fontCache });

const htmlfile = fs.readFileSync(file, 'utf8');
const html = mathjax.document(htmlfile, { InputJax: tex, OutputJax: svg });
html.render();

if (Array.from(html.math).length == 0) {
adaptor.remove(html.outputJax.svgStyles);
const cache = adaptor.elementById(adaptor.body(html.document), 'MJX-SVG-global-cache');
if (cache) adaptor.remove(cache);
}

fs.writeFileSync(file, adaptor.doctype(html.document) + adaptor.outerHTML(adaptor.root(html.document)), 'utf8');

});

This lets me remove the resulting files’ dependency on MathJax entirely. However, it also makes writing posts pretty annoying since I’d need to run the build script each time I make a change, rather than just running jekyll serve --watch. To fix this, I included the MathJax javascript while developing, but excluded it in the final build by using the Jekyll environment:

 {% if jekyll.environment == "development" %}
<script>
MathJax = {
tex: {
inlineMath: [
["$", "$"],
["\\(", "\\)"],
],
},
svg: {
fontCache: "global",
},
};
</script>
<script
type="text/javascript"
id="MathJax-script"
async
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"
>
</script>
{% endif %}

And with that, when debugging my website, I just use jekyll serve --watch, and when building, I use JEKYLL_ENV=production jekyll build.

Full Build Script
JEKYLL_ENV=production jekyll build

echo "-> Embedding Math"
node -r esm ./embed_math.js ./_site --ignore causality/visualize/index.html --ignore myfile.html

echo "-> Purging CSS"
purgecss -c purgecss.config.js --o \_site/assets

echo "-> Minifying HTML"
html-minifier --file-ext html --input-dir ./\_site --output-dir ./\_site --minify-css --minify-js --remove-comments --collapse-whitespace --conservative-collapse --case-sensitive --no-include-auto-generated-tags

echo "-> Pre-Compressing assets"
FILES=`find _site/ -name '*.html' -o -name '*.js' -o -name '*.css' -o -name '*.txt' -o -name '*.xml' -o -name '*.ipynb'`
for f in $FILES; do
brotli -q 11 --keep --force $f
done

Let’s see how much that helped:

Wow!

Yeah, less than half the load time! The lighthouse report is likewise quite happy with the result:

That's more like it.

Conclusion

And with that, my new website is actually lighter in terms of resources transferred than my old one, but it has phones as a first-class citizen. Not to mention that it looks much cleaner!