Improving Google Pagespeed Score with Hugo's Academic Theme

When I first migrated my personal site from Wordpress to Hugo one of my goals was to make the site fast. Because Hugo serves my pages as static content, there is no waiting for things like servers looking up content in a database. And while my Google Pagespeed score jumped right away, I wanted to get as close to 100 as I could, especially on mobile. Site speed, and user experience in general, has become more important in ranking in Google, and really has become table stakes. If I’m going to go to the trouble of generating content, I might as well do my best to make sure that user experience is optimized.

Initial Approach

The first time I tackled this process, I just worked through the Google Pagespeed Insights tool’s recommendations, tackling the opportunities with the greatest potential impact first. I made great progress by hacking my way through the template files and was able to lock in a desktop score of 100 and mobile score of 97.

97 Mobile Speed Score Audit Results

I then found SpeedMonitor.io which tracks my score over time and emails me whenever my scores drop to a threshold I’ve set. Which now happens every day:

Speed Score Over Time

The two sustained drops, at May 21 and August 15, correspond to the two times I updated to the latest version of the Academic theme. Due to issues I had while attempting the upgrades, I ended up creating fresh copies of the kickstart project and just repopulating with my existing content. Along the way, I did not bring over those speed optimizations, which would just get blown away in future updates.

A (Hopefully) Better approach

I now know how to build locally using the theme as a submodule. I also know how to override the theme files instead of hacking them directly. This should make applying future theme updates much easier AND allow me to preserve any theme customizations, such as this pagespeed optimization. Just in case, I’ll document the process here in case I ever have to do it again.

Baseline for Academic 4.4

After upgrading to the latest version of the theme, my initial mobile speed score is 89 and my desktop score is 98. Pretty good, thanks to 19 passed audits.

  • Properly size images
  • Defer offscreen images
  • Minify CSS
  • Minify JavaScript
  • Remove unused CSS — Potential savings of 17 KB
  • Efficiently encode images
  • Serve images in next-gen formats — Potential savings of 14 KB
  • Enable text compression
  • Preconnect to required origins
  • Server response times are low (TTFB) — Root document took 30 ms
  • Avoid multiple page redirects
  • Preload key requests
  • Use video formats for animated content
  • Avoids enormous network payloads — Total size was 460 KB
  • Uses efficient cache policy on static assets — 3 resources found
  • Avoids an excessive DOM size — 463 elements
  • User Timing marks and measures
  • JavaScript execution time — 0.8 s
  • Minimizes main-thread work — 2.0 s

I think some of this is due to the theme itself being optimized over time, but a thanks to me optimizing images the first time I undertook this project. The only opportunity recommended is to eliminate render-blocking resources, which took me some time to figure out last time. I should be able bang this out pretty quickly. Here are the resources I need to address:

URLSizePotential Savings
…css/academicons.min.css(cdnjs.cloudflare.com)2 KB930 ms
…css/all.css(use.fontawesome.com)14 KB1,380 ms
…3.2.5/jquery.fancybox.min.css(cdnjs.cloudflare.com)3 KB930 ms
…styles/github.min.css(cdnjs.cloudflare.com)1 KB780 ms
/css?family=…(fonts.googleapis.com)1 KB930 ms
/css/academic.min.dd62924….css(mitchmclachlan.com)29 KB1,380 ms

I used the scripts from GiftOfSpeed to defer. Last time, I just hacked the template directly, in this case, themes/academic/layouts/partials/footer.html. This time I’ll override by creating this folder structure at the root of my project:

  • layouts
  • _default

…and then copying in the baseof.html file from the same location in the themes/academic directory. As you can see, the template file structure has changed slightly since my first optimization project. I will also need to find where these files are being referenced today so that I can see how they are being called with Go. Here’s how the call is structured in themes/academic/layouts/partials/site_head.html:

{{ if and (fileExists (printf "static/css/vendor/%s" ($scr.Get "vendor_css_filename"))) (fileExists (printf "static/js/vendor/%s" ($scr.Get "vendor_js_filename"))) }}
  {{ $scr.Set "use_cdn" 0 }}
  <link rel="stylesheet" href="{{ printf "/css/vendor/%s" ($scr.Get "vendor_css_filename") | relURL }}">
{{ else }}
  {{ $scr.Set "use_cdn" 1 }}
  {{ printf "<link rel=\"stylesheet\" href=\"%s\" integrity=\"%s\" crossorigin=\"anonymous\">" (printf $css.academicons.url $css.academicons.version) $css.academicons.sri | safeHTML }}
  {{ printf "<link rel=\"stylesheet\" href=\"%s\" integrity=\"%s\" crossorigin=\"anonymous\">" (printf $css.fontAwesome.url $css.fontAwesome.version) $css.fontAwesome.sri | safeHTML }}
  {{ printf "<link rel=\"stylesheet\" href=\"%s\" integrity=\"%s\" crossorigin=\"anonymous\">" (printf $css.fancybox.url $css.fancybox.version) $css.fancybox.sri | safeHTML }}

  {{/* Default to enabling highlighting, but allow the user to override it in .Params or site.Params.
       Use $scr to store "highlight_enabled", so that we can read it again in footer.html. */}}
  {{ $scr.Set "highlight_enabled" true }}
  {{ if isset .Params "highlight" }}
    {{ $scr.Set "highlight_enabled" .Params.highlight }}
  {{ else if isset site.Params "highlight" }}
    {{ $scr.Set "highlight_enabled" site.Params.highlight }}
  {{ end }}
  {{ if ($scr.Get "highlight_enabled") }}
    {{ $v := $css.highlight.version }}
    {{ with site.Params.highlight_style }}
      {{ printf "<link rel=\"stylesheet\" href=\"%s\" crossorigin=\"anonymous\" title=\"hl-light\">" (printf $css.highlight.url $css.highlight.version .) | safeHTML }}
      {{ printf "<link rel=\"stylesheet\" href=\"%s\" crossorigin=\"anonymous\" title=\"hl-dark\" disabled>" (printf $css.highlight.url $css.highlight.version .) | safeHTML }}
    {{ else }}
      {{ if eq ($scr.Get "light") true }}
        {{ printf "<link rel=\"stylesheet\" href=\"%s\" crossorigin=\"anonymous\" title=\"hl-light\">" (printf $css.highlight.url $css.highlight.version "github") | safeHTML }}
        {{ printf "<link rel=\"stylesheet\" href=\"%s\" crossorigin=\"anonymous\" title=\"hl-dark\" disabled>" (printf $css.highlight.url $css.highlight.version "dracula") | safeHTML }}
      {{ else }}
        {{ printf "<link rel=\"stylesheet\" href=\"%s\" crossorigin=\"anonymous\" title=\"hl-light\" disabled>" (printf $css.highlight.url $css.highlight.version "github") | safeHTML }}
        {{ printf "<link rel=\"stylesheet\" href=\"%s\" crossorigin=\"anonymous\" title=\"hl-dark\">" (printf $css.highlight.url $css.highlight.version "dracula") | safeHTML }}
      {{ end }}
    {{ end }}
  {{ end }}
  {{ if or (eq site.Params.map 2) (eq site.Params.map 3) }}
  {{ printf "<link rel=\"stylesheet\" href=\"%s\" integrity=\"%s\" crossorigin=\"anonymous\">" (printf $css.leaflet.url $css.leaflet.version) $css.leaflet.sri | safeHTML }}
  {{ end }}

  {{ if eq site.Params.search.engine 2 }}
    {{ printf "<link rel=\"stylesheet\" href=\"%s\" integrity=\"%s\" crossorigin=\"anonymous\">" (printf $css.instantsearch.url $css.instantsearch.version) $css.instantsearch.sri | safeHTML }}
    {{ printf "<link rel=\"stylesheet\" href=\"%s\" integrity=\"%s\" crossorigin=\"anonymous\">" (printf $css.instantsearchTheme.url $css.instantsearchTheme.version) $css.instantsearchTheme.sri | safeHTML }}
  {{ end }}

{{ end }}

The important bit for our first optimization is this line:

{{ printf "<link rel=\"stylesheet\" href=\"%s\" integrity=\"%s\" crossorigin=\"anonymous\">" (printf $css.fontAwesome.url $css.fontAwesome.version) $css.fontAwesome.sri | safeHTML }}

Which is wrapped in an if/else statement. For now, I’ll create a stripped down version of this block and have only my fontawesome line in there for now. I’ll also include variable assignments that are referenced in that block. This will all be inserted right above the closing </body> tag. That looks like this:

  {{ $scr := .Scratch }}
  {{ $css := site.Data.assets.css }}
  {{ if and (fileExists (printf "static/css/vendor/%s" ($scr.Get "vendor_css_filename"))) (fileExists (printf "static/js/vendor/%s" ($scr.Get "vendor_js_filename"))) }}
    {{ $scr.Set "use_cdn" 0 }}
    <link rel="stylesheet" href="{{ printf "/css/vendor/%s" ($scr.Get "vendor_css_filename") | relURL }}">
  {{ else }}
    {{ $scr.Set "use_cdn" 1 }}
    {{ printf "<link rel=\"stylesheet\" href=\"%s\" integrity=\"%s\" crossorigin=\"anonymous\">" (printf $css.fontAwesome.url $css.fontAwesome.version) $css.fontAwesome.sri | safeHTML }}
  {{ end }}

</body>

Lastly, I need to copy over the source of this code, the theme’s site_head.html into the root level layouts/partials/ override folder, and comment out the font awesome line:

{{/* printf "<link rel=\"stylesheet\" href=\"%s\" integrity=\"%s\" crossorigin=\"anonymous\">" (printf $css.fontAwesome.url $css.fontAwesome.version) $css.fontAwesome.sri | safeHTML */}}

My local dev server builds fine, but let’s deploy these changes and see what happens to the production site…

Mobile Speed Score Jumped by 4 points!

BUT I’m not doing anything fancy here. I’m just loading that css at the very end of the file. Now that I know that my overrides work, I’ll apply the GiftOfSpeed script:

  {{ $scr := .Scratch }}
  {{ $css := site.Data.assets.css }}
  {{ if and (fileExists (printf "static/css/vendor/%s" ($scr.Get "vendor_css_filename"))) (fileExists (printf "static/js/vendor/%s" ($scr.Get "vendor_js_filename"))) }}
    {{ $scr.Set "use_cdn" 0 }}
    <link rel="stylesheet" href="{{ printf "/css/vendor/%s" ($scr.Get "vendor_css_filename") | relURL }}">
  {{ else }}
    {{ $scr.Set "use_cdn" 1 }}
    <script type="text/javascript">
      var giftofspeed = document.createElement('link');
      giftofspeed.rel = 'stylesheet';
      giftofspeed.href = '{{ printf "%s" (printf $css.fontAwesome.url $css.fontAwesome.version) | safeHTML }}';
      giftofspeed.type = 'text/css';
      giftofspeed.setAttribute('crossorigin','anonymous');
      giftofspeed.setAttribute('integrity','{{ printf "%s" $css.fontAwesome.sri | safeHTML }}');
      var godefer = document.getElementsByTagName('link')[0];
      godefer.parentNode.insertBefore(giftofspeed, godefer);
    </script>
  {{ end }}


</body>

Success. Everything built properly and fontawesome is being loaded correctly and my mobile speed score crept up to 94. I will quickly do the same for my other resources.

After I moved 4 CSS links to the giftofspeed script and uncommented the links in the head but wrapped them in a noscript tag, I was able to achieve a mobile seed score of 96!

Mobile Speed Score Jumped to 96!

I’m really close to being back where I was 3 months ago on mobile speed score. HOWEVER, it’s not all sunshine and rainbows. While I optimized to a metric, I also just hurt usability for actual visitors. Why? Well even though my site loads faster now, the content presentation has taken a hit, including Flash of Unstyled Content. I’ll come back next and clean that up with some critical styles. One other thing that needs to be addressed is that the theme itself has a fade in transition. While it may be aesthetically pleasing, it does artificially delay the presentation. The first time I did this exercise, I found where that was set and disabled it.

Optimizing Posts: Don’t Use Disqus

The above has been focused on the home page and global performance. However, most of my content is blog posts. I’ve been using SpeedMonitor to track a couple of posts over time as well. Here’s one of my first posts:

Comments Good. Disqus Bad.

This particular post has not been optimized yet. Honestly, it hasn’t even been finished yet. So it has never had a great mobile speed score due to an extraneous video embed and lots of images. However, you can see a big drop on June 11. Prior to that, the page had a mobile speed score in the low 60s. Since, it’s averaged a score in the upper 30s. So what’s going on? That’s when I enabled Disqus for commenting. There are plenty of resources out there explaining why you should avoid Disqus. By switching to Commento, I increased my mobile pagespeed score for this post from 41 to 88. Since this page is already getting decent organic search traffic, I’m interested to see the potential impact of this change.

Critical Path CSS

Critical Path CSS is styles you load up front while all your other style loading is being deferred. The idea is that you load all the styles you need for the initial page view so there is no flash. It’s not perfect, but here’s my critical path CSS as of now; there’s probably some blog post styles I need to work on.

:root{font-size:20px;-webkit-transition:none!important;-moz-transition:none!important;-ms-transition:none!important;-o-transition:none!important}body{margin:0;font-family:sans-serif}.search-results{transform:scale(0);-webkit-transform:scale(0);background-color:#fff;bottom:0;left:0;right:0;top:0;overflow:scroll;position:fixed;visibility:hidden;z-index:-99}.pt-0,.py-0{padding-top:0!important;padding-bottom:0!important}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}.container{max-width:1140px}.container{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.search-header{position:-webkit-sticky;position:sticky;top:70px;background-color:#fff;padding-top:2rem;padding-bottom:1rem}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}.mb-3,.my-3{margin-bottom:1rem!important}.justify-content-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.no-gutters{margin-right:0;margin-left:0}.row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.no-gutters>.col,.no-gutters>[class*=col-]{padding-right:0;padding-left:0}.col-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col,.col-1,.col-10,.col-11,.col-12,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-lg,.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto,.col-md,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto,.col-sm,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto,.col-xl,.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{position:relative;width:100%;min-height:1px;padding-right:15px;padding-left:15px}.search-header h1{margin:0;line-height:1}.h1,h1{font-size:2.5rem}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-bottom:.5rem;font-family:sans-serif;font-weight:500;line-height:1.2;color:inherit}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.navbar{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between;padding:.5rem 1rem}.navbar-light{background:#fff!important;box-shadow:0 .125rem .25rem 0 rgba(0,0,0,.11)}.navbar{min-height:70px!important}.navbar-light{font-family:Roboto,sans-serif;font-weight:400;line-height:1.25;text-rendering:optimizeLegibility}.navbar>.container,.navbar>.container-fluid{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between}.navbar-light .navbar-brand{font-weight:700;font-size:1.2em;color:#2b2b2b}.navbar-brand{display:inline-block;padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;line-height:inherit;white-space:nowrap}.navbar-brand,.navbar-nav li.nav-item a.nav-link{height:inherit;line-height:50px;padding-top:10px;padding-bottom:10px}a{color:#007bff;text-decoration:none;background-color:transparent;-webkit-text-decoration-skip:objects}a,h3.article-title a:hover{color:#2962ff;text-decoration:none}.navbar-toggler:not(:disabled):not(.disabled){cursor:pointer}.navbar-light .navbar-toggler{color:rgba(0,0,0,.5);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler{border-color:transparent}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}.navbar-toggler{padding:.25rem .75rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem}.navbar-toggler{color:#34495e!important}button,select{text-transform:none}button,input{overflow:visible}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button{border-radius:0}.fa,.fas{font-weight:900}.fa,.far,.fas{font-family:"Font Awesome 5 Free"}.fa,.fab,.fal,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;text-rendering:auto;line-height:1}.collapse:not(.show){display:none}@media (min-width:992px){.collapse:not(.show){display:flex}}.navbar-collapse{-ms-flex-preferred-size:100%;flex-basis:100%;-ms-flex-positive:1;flex-grow:1;-ms-flex-align:center;align-items:center}*,::after,::before{box-sizing:border-box}.ml-auto,.mx-auto{margin-left:auto!important}.navbar-nav{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}dl,ol,ul{margin-top:0;margin-bottom:1rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}.dropdown-menu,nav#navbar-main li.nav-item{font-size:16px}.navbar-collapse{-ms-flex-preferred-size:100%;flex-basis:100%;-ms-flex-positive:1;flex-grow:1;-ms-flex-align:center;align-items:center}.ml-auto,.mx-auto{margin-left:auto!important}.navbar-nav{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}dl,ol,ul{margin-top:0;margin-bottom:1rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}.dropdown-menu,nav#navbar-main li.nav-item{font-size:16px}.navbar-light .navbar-nav>li.nav-item>a.active,.navbar-light .navbar-nav>li.nav-item>a.active:focus,.navbar-light .navbar-nav>li.nav-item>a.active:hover{color:#2962ff;font-weight:700;background-color:transparent!important}.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav>.nav-item>.nav-link,.navbar-light .navbar-nav>.nav-item>.nav-link:focus,.navbar-light .navbar-nav>.nav-item>.nav-link:hover{white-space:nowrap;color:#34495e;font-weight:600}.navbar-brand,.navbar-nav li.nav-item a.nav-link{height:inherit;line-height:50px;padding-top:10px;padding-bottom:10px}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.5)}.navbar-nav .nav-link{padding-right:0;padding-left:0}.nav-link{display:block;padding:.5rem 1rem}a{color:#007bff;text-decoration:none;background-color:transparent;-webkit-text-decoration-skip:objects}a,h3.article-title a:hover{color:#2962ff;text-decoration:none}.home-section{background-color:#fff;padding:50px 0 110px 0;animation:none!important}.article{animation:none!important}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}@media (min-width:992px){.col-lg-4{max-width:33.333333%}}@media (min-width:992px){.col-lg-8{max-width:66.666667%}}#profile{text-align:center;padding:30px 10px;position:relative}#profile .portrait{width:200px;height:200px;margin:0 auto;border-radius:50%;object-fit:cover}img{vertical-align:middle;border-style:none}img,video{height:auto;max-width:100%;display:block}#profile .portrait-title h2{font-size:1.75em;font-weight:300;color:#000;margin:20px 0 10px 0}#profile .portrait-title h3{font-size:1rem;font-weight:300;color:rgba(0,0,0,.54);margin:0 0 10px 0}.h1,h1{font-size:2.5rem;margin:0 0 10px 0}p{margin-top:0;margin-bottom:1rem;line-height:1.5rem;font-family:sans-serif}@media (min-width:768px){.col-md-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}}@media (min-width:768px){.col-md-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}}ul.network-icon{display:inline-flex;flex-direction:row;flex-wrap:wrap;justify-content:center;list-style:none;padding:0}ul.ul-edu{list-style:none}.h3,h3{font-size:1.75rem;margin:0 0 .5rem 0}ul.ul-interests li{font-size:.9rem}ul.ul-edu li .description p.course{font-size:.9rem}ul.ul-edu li .description p.institution{font-size:.75rem;color:rgba(0,0,0,.6)}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}ul.ul-edu li .description p{margin:0}@media (min-width:992px){.navbar-expand-lg .navbar-nav{-ms-flex-direction:row;flex-direction:row}}@media (min-width:992px){.navbar-expand-lg .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}}.ml-auto,.mx-auto{margin-left:auto!important}.navbar-brand,.navbar-nav li.nav-item a.nav-link{height:inherit;line-height:50px;padding-top:10px;padding-bottom:10px}@media (min-width:992px){.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}}@media (min-width:992px){.navbar-toggler{display:none}}

I’ve put this in a file called critical_path.css in a new folder assets/css in the site’s root folder, and then let the builder know of it’s existence by referenceing it in _default/params.toml in this line:

plugins_css = ["critical_path"]

Defer Offscreen Images

I [used this post] (https://varvy.com/pagespeed/defer-images.html) to tackle the “defer offscreen images” audit suggestion. There were three steps:

  1. Add the script to the baseof.html override
  2. Copy the figure.html into a new layouts/shortcodes dir at the project’s root directory
  3. Convert the src attributes to data-src and set the placeholder from the above blog post as the src

Updated Results

Latest result for my home page from SpeedMonitor.io: