Preloading assets with Webpack 5

Problem to solve

Imagine you are working on a custom-made single-page application project that uses Webpack 5 for bundling source files and assets. You have index.html which is supposed to stay simple as much as possible. Because you want your app to be optimized you are going to use some sort of image processing modules during the build stage. In order to improve the user experience, you decided to preload hero images because they will be loaded at the start each time anyway. The problem is that after every build, those files will have a different hash in their name, so you can't hardcode those names in the index.html. The second problem is that you can't add links to page head using javascript because at the moment of processing in index.js it will be (relatively) late to preload.

HTML markdown preload vs preload added using javascript inside <script> tags. You can see that the first image started loading earlier.
HTML markdown preload vs preload added using javascript inside <script> tags. You can see that the first image started loading earlier.

Background

Using <link rel="preload"> browsers can be informed that the given resource should be prefetched without executing it, allowing an application to apply it at the appropriate time. If you include preload rule in the head section, the user agent will prefetch resources immediately without waiting for their declarations in CSS and JS files. The preloading technique is mostly used for critical CSS/JavaScript files or Hero Images to improve First Contentful Paint (FCP) and Time To Interactive (TTI) scores. You can also use it dynamically by appending <link> element to <head> section when needed to slightly improve UX. By using this technique I managed to remove the flash of hero images on some devices/internet speeds on page load.

Waterfall view with preloaded resources
Waterfall view with preloaded resources

Same website waterfall without preloading:

Without preloading images are being fetched after js processing which causes image flash.
Without preloading images are being fetched after js processing which causes image flash.

Solution

Easy way:

Just add this code somewhere in your app:

const link = document.createElement('link');
link.as = 'image';
link.href = 'url to image';
link.rel = 'preload';
document.head.appendChild(link);

This will create preload link for you but you are going to rely on javascript processing time (e.g. index.js or critical.js), which in my case was too late -  about approximately 100ms. A quick test that I have made shows a result of around 40ms difference between <script> tag with example code (scheduled javascript) right under <link rel="preload">... markup.

<head>
...
<link rel="preload" href="https://i.picsum.photos/id/817/1000/1000.jpg?hmac=y5ivmrua_o4PgFQKazVmhLGxfyJsKUjrks5CS-Ap6U0" as="image">
<script>
  var link = document.createElement('link');
  link.setAttribute('as', 'image');
  link.setAttribute('href', 'https://i.picsum.photos/id/16/1000/1000.jpg?hmac=1ghuu4sHf1rJ1x9b3QS1P8EikOS1XEB_A7zkwbWUt7Y')
  link.rel = 'preload';
  document.head.appendChild(link);
</script>

Hard way - generate preload links at build stage using webpack:

If you are using HtmlWebpackPlugin in your Webpack setup, then the HtmlWebpackInjectPreload plugin might be a solution for you. This extension will add <link> markup after all HTML-related tasks are done (including loading assets). Unfortunately, that means you cannot pass original file names to it but since filename hash is needed only for caching purposes, you can have customs id's in them and detect those files using ReGex later. Not the most elegant solution but it does the trick. You just need to remember to give your processed images identifier.

Example file name setup in responsive-loader config:

module: {
  rules: [
    {
      test: /.(jpe?g|png|webp)$/i,
      use: {
        loader: 'responsive-loader',
        options: {
          adapter: require('responsive-loader/sharp'),
          name: '[name][width]-[hash:7].[ext]',
        },
      },
    },
  ],
},

HtmlWebpackPlugin + HtmlWebpackInjectPreload setup:

plugins: [
  ...
  new HtmlWebpackPlugin({
    title: 'Development',
    template: './src/index.html',
    scriptLoading: 'defer',
  }),
  new HtmlWebpackInjectPreload({
    files: [
      {
        match: /(logo120)+.+(.webp)$/,
        attributes: { as: 'image' },
      },
      {
        match: /(jam100)+.+(.webp)$/,
        attributes: { as: 'image', media: '(max-width: 100px)' },
      }
    ],
  }),
  ...
],

Result:

<head>
  ...
  <link rel="preload" href="/jam100-f9016f5.webp" as="image" media="(max-width: 100px)">
  <link rel="preload" href="/logo120-b6dff09.webp" as="image">
  ...
</head>

Final thoughts

I am satisfied with this (webpack plugin) approach but I wouldn't call it the perfect one. The biggest drawback is messing up the webpack config file and the requirement to keep some kind of identifier in the file name. For now, it will work for me. Share in the comments section below if you know a better solution 😄

The author of the blog saluting the user with an ice ax in his hand