HomeWeb Development

How to make a fast-loading blog

Posted May 04, 2019 by Alhan Keser and updated Mar 07, 2020
Views are my own.

Part of the reason I created this blog is to practice page speed optimization skills. This post is a list of steps I took in order to optimize this blog for speed.

Here is the end outcome of the various optimizations I've completed below:

Lighthouse report for this blog.
Lighthouse report for this blog.

First, the basics:

Laravel: this blog was built using the Laravel php framework relying on basic blade syntax (no JavaScript framework on public-facing content).

Digital Ocean: this blog is hosted on the most basic, yet powerful Digital Ocean droplet (1 GB Memory / 25 GB Disk / NYC3 - Ubuntu 18.04 x64).

MySQL: using a basic MySQL 8.0.12 database.

Tailwind CSS: using the Tailwind CSS framework to produce scalable front-end styles, in conjunction with optimization utilities to cut down on code volume.

Are there other frameworks and hosting providers out there that can be just as performant? Of course. These just happen to be the ones I am familiar with and comfortable using. The Laravel ecosystem makes it very easy to hook up GitHub, Forge and Digital Ocean.

Server Setup:

Enable gzip compression. GZip compression reduces the size of the files being transferred from the server to the browser. I believe this may have been enabled by default on my Digital Ocean droplet. How To Add the gzip Module to Nginx on Ubuntu 14.04.

Enable serving of Webp images. Webp is an image compression method that reduces image sizes without hurting image quality. I am not completely sure if I needed to do this, but I went through the steps outlined here to make it possible to serve up Webp images: How To Create and Serve WebP Images to Speed Up Your Website

Enable browser caching for static assets. Browser caching doesn't do much for the first-time load of pages, but it's good for subsequent page loads and visits. I enabled caching using this guide: How to Implement Browser Caching with Nginx's header Module on Ubuntu 16.04.

[FAILED] Enable opcache. From what I understand, opcache pre-processes your php code so that the final result of your code is served up, thus reducing load time. I wanted to use it, but it worked a little too well: once my code was cached, I couldn't get the cache to ever bust. It was unbustable. So I had to abandon opcache altogether. Here is the guide I used: Configure & tune opcache on php7

[FAILED] Use memcached to cache data. Laravel is made to work well with a database caching system like memcached, but I was never able to get it to work for me. Instead, I relied on the default 'file' method in Laravel, which is likely sub-optimal, but also effective. This is the guide I used to try to setup memcached: How To Install and Secure Memcached on Ubuntu 16.04

Back-End:

Cache database queries. Unless there's been an update to your content, there's no good reason to query the database for each page load. Laravel has an easy-to-use caching API, which I've implemented using the default file cache driver (since I couldn't get memcached to work). It seems to do the trick as compared to not caching. Take a look at an example of really simple caching inside of a controller:

$posts = Cache::remember('posts-published', 22*60, function() {
     return Post::where('published', 1)->get()->sortByDesc('created_at');
});

Eager load relationships to further reduce database queries. On the homepage, there are multiple blog posts. While the database query to get the list of articles has been cached as seen above, I also need to fetch the name of the category that the article belongs to. That information is not cached and would typically result in database queries being made to get the name of each category. I've bypassed that by using 'eager' loading of relationships as described in the Laravel docs and demonstrated below:

return Post::where('published', 1)->with('category','featuredImage')->get()->sortByDesc('created_at');

Cache everything else. This may have been overkill and redundant, but I did it anyway, since this was a process of exploration. In addition to caching data, I used the laravel-responsecache package found here. Then cached views and the config file. Again, this was probably redundant.

Crop and compress images into the optimal sizes and formats. While cropping every image and creating multiple versions can be laborious, there are automated ways to achieve the ideal compression and dimension for all images at the time of upload to your back-end. What I've used in this blog is the Intervention Image library, which makes it easy to resize and compress images in any number of sizes/formats while maintaining aspect ratio and minimum quality. I've selected three sizes and two formats, resulting in six versions of each image being created every time I upload one through my custom back-end. Embed code is then auto-generated to let the browser decide the best format and dimension to use:

<div data-id="image_1" class="post-image mt-4 mb-4">
  <picture>
    <source media="(max-width:380px)" type="image/webp" data-srcset="/storage/images/posts/1/google-lighthouse-page-performance-report-for-this-blog_1_1543661353_sm.webp">
    <source media="(max-width:480px)" type="image/webp" data-srcset="/storage/images/posts/1/google-lighthouse-page-performance-report-for-this-blog_1_1543661353_md.webp">
    <source type="image/webp" data-srcset="/storage/images/posts/1/google-lighthouse-page-performance-report-for-this-blog_1_1543661353_lg.webp"> 
    <source media="(max-width:380px)" type="image/jpeg" data-srcset="/storage/images/posts/1/google-lighthouse-page-performance-report-for-this-blog_1_1543661353_sm.jpg">
    <source media="(max-width:480px)" type="image/jpeg" data-srcset="/storage/images/posts/1/google-lighthouse-page-performance-report-for-this-blog_1_1543661353_md.jpg">
    <source type="image/jpeg" data-srcset="/storage/images/posts/1/google-lighthouse-page-performance-report-for-this-blog_1_1543661353_lg.jpg">
    <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mOc+R8AAjcBmvywMWoAAAAASUVORK5CYII=" data-style="" data-src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mOc+R8AAjcBmvywMWoAAAAASUVORK5CYII=" alt="Google Lighthouse page performance report for this blog" class="lazy" style="width: 100%; height: 300px;">
  </picture>
  <div class="text-xs">Google Lighthouse page performance report for this blog</div>
</div>

Front-End:

Use system fonts. Now that most machines come pre-installed with great fonts, we might as well use them! I tried pulling in Google Fonts, but making the http request was costing at least a few hundred ms in load time for something that wasn't adding much in terms of value. Instead, I went with the set of system and web fallback fonts as provided, out-of-the-box, with Tailwind:

font-family: system-ui,BlinkMacSystemFont,-apple-system,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;

Use style tag in head. To avoid yet another http request, I embedded css in the head rather than referencing a separate file, all the while benefiting from the compiling provided by laravel-mix. To do so, I simply copied the contents of the exported, minimized css into the style tag. This is what it looks like using the Laravel blade syntax:

In webpack.mix.js:


var tailwindcss = require('tailwindcss');
mix.postCss('resources/assets/css/tailwind.css', 'public/css', [
  tailwindcss('./tailwind-config.js'),
]);

On any public blog page, inside of style tags:

@php echo Storage::disk('public')->get('/css/tailwind.css'); @endphp

Remove unused styles. While Tailwind is a great CSS framework for quickly building a site, you're unlikely to use all of the styles it offers, even after customizing the tailwind-config.js file in such a way as to reduce unnecessary styles from being generated in the first place. To greatly reduce the amount of styles that were being added to each page load, I used the recommended solution by Tailwind, PurgeCSS. The character count inside the aforementioned style tag went down from 163717 to 4769!

In webpack.mix.js:

let glob = require("glob-all");
let PurgecssPlugin = require("purgecss-webpack-plugin");

class TailwindExtractor {
  static extract(content) {
    return content.match(/[A-Za-z0-9-_:\/]+/g) || [];
  }
}
if (mix.inProduction()) {
  mix.webpackConfig({
    plugins: [
      new PurgecssPlugin({
        paths: glob.sync([
          path.join(__dirname, "resources/views/layouts/blog.blade.php"), 
          // and more...
        ]),
        extractors: [
          {
            extractor: TailwindExtractor,
            extensions: ["html", "js", "php", "vue"]
          }
        ]
      })
    ]
  });
}

Lazy load off-screen images. Loading all images on a long page is a waste of bandwidth. What we care most about are images that are immediately visible within the viewport. To avoid loading all images, immediately, I used the lazy image loading library, yall.js. To reduce the amount of displacement created by the loading of an image as a user scrolls down a page, I've included a placeholder image with default dimensions.

Anything without a data- attribute gets replaced by corresponding values that do have a data- attribute when the image in question is lazy loaded:

img src="data:image/png;base64,my-place-holder-image...=" data-src="my-image-to-lazyload.png" style="width: 100%; height: 300px;" data-style=""  alt="Description" class="lazy" 
Google Chrome has since built this into their browser, but I am yet to get it to work while also using both dimension- and type-based responsiveness.

Don't use a JavaScript framework. I appreciate the value of js frameworks as much as any self-respecting web developer, but that doesn't mean they should be used all of the time. In the case of this blog, there was not much value to be gained by relying on a JavaScript framework such as React or Vuejs, so I didn't use one on the public-facing pages. I do use Vuejs in the admin area, to help with blog post editing. If I were to use a js framework on the public-facing pages, I would implement it with server-side rendering enabled... but that seems like so much work for something that php does quite well.

Load scripts in footer. While there aren't a lot of scripts being loaded for this blog, those that are being loaded (yall.js and Google Analytics) are in the footer, where they belong.

[Work in progress] Implement AMP. While I have not completed the implementation of Accelerated Mobile Pages (AMP), I did do the basics. It's unclear at this point what, if any, gains this particular blog will get by doing so since it is already relatively fast-loading. AMP may be more beneficial to a site with a lot more content that is in more dire need of mobile speed optimization. To implement AMP, I created a special query parameter that, in turn, loads the AMP-specific styles, and JavaScript.

In the head tag:

rel="amphtml" href="https://alhan.co/any-page?amp=1"
@isset($amp) 
(amp boilerplate goes here )

Related: