Introduction#

After cleaning up CSS styles and optimizing images, I focused on the JavaScript layer. At first glance, the situation looked innocent. The site was loading two files: a script from the theme and my own code for handling the color theme toggle. Together, they weighed less than three kilobytes. With such a small size, optimizing here might seem like overkill.

However, the real challenge was hidden in the HTTP headers. I noticed that both files had a one-year cache lifetime. Unfortunately, their names lacked a unique identifier, or a “fingerprint.” This created a serious risk. It meant that after deploying any fixes, users would still use the old version of the code saved in their browsers.

The scenario was simple and dangerous. I could fix a bug in the js code and push the changes to the server, but the audience would not see them. The browser would simply serve them the outdated file from the cache. In a production environment, we cannot count on the user guessing to force a full page refresh.

Diagnosing the problem#

Analysis in developer tools confirmed my concerns. I had two separate files with a total weight of nearly eight kilobytes before compression. Cache set for a year is great for performance, but terrible without file versioning. Every code modification became problematic because the mechanism meant to speed up the site was actually blocking update delivery.

Implementing the solution#

I decided to kill two birds with one stone. Instead of maintaining two separate files, I combined them into one optimized bundle that includes a unique content hash in its name.

Comparing the state before and after the changes, the improvement is clear. I reduced the number of server requests by half. Thanks to better minification and file merging, the weight of transferred data dropped by over thirty percent.

However, the most important gain is architectural. Thanks to the unique hash in the filename, the cache became safe. Every change in the code automatically changes the script’s URL, which forces the browser to download the new version. Additionally, I gained a higher level of security through subresource integrity verification. The solution is therefore not only lighter but, above all, more predictable to maintain.

Step-by-step implementation#

Let’s start by preparing the environment. For Hugo Pipes to process our scripts, we need to change their location. Hugo has clear rules here. Files placed in the /static folder are simply copied one-to-one. Therefore, the first step I took was moving the files to the /assets directory. This is the only place where the engine can modify and optimize our code.

Next, I looked at my theme’s structure. I noticed that script loading happens in the file responsible for the page footer. To take control of the file merging process, I had to override this part. So, I created my own local version of footer.html. Thanks to this, my changes will have priority over the theme’s default settings.

# We create a theme override
touch layouts/partials/footer.html

Now let’s move to the most important part, which is the configuration in the new file. The code below handles the logic for merging and optimizing resources.

<footer class="footer">
  <div class="footer__inner">
    {{ if $.Site.Copyright }}
      <div class="copyright copyright--user">
        <span>{{ $.Site.Copyright | safeHTML }}</span>
    {{ else }}
      <div class="copyright">
        <span>© {{ now.Year }} Powered by <a href="https://gohugo.io">Hugo</a></span>
    {{ end }}
      </div>
  </div>
</footer>

{{- $menu := resources.Get "js/menu.js" | js.Build -}}
{{- $code := resources.Get "js/code.js" | js.Build -}}
{{- $bundle := slice $menu $code | resources.Concat "bundle.js" | minify | fingerprint -}}
  
<script type="text/javascript" src="{{ $bundle.RelPermalink }}" integrity="{{ $bundle.Data.Integrity }}"></script>

<!-- Extended footer section-->
{{ partial "extended_footer.html" . }}

I will now explain exactly what is happening in this code. The whole process relies on a few key functions. First, I use the resources.Get command to fetch files from the assets directory. Then, using js.Build, I process the scripts provided by the theme.

The next stage is creating a single list of files using the slice function. These are the elements we glue together into one piece using the resources.Concat command.

To ensure performance, we subject the resulting file to minification. We remove unnecessary spaces and shorten variable names. At the very end, we generate a unique hash using the fingerprint function. This is necessary for the cache busting mechanism and security. Thanks to the integrity attribute, the browser is certain that the loaded code has not been swapped in the meantime. It is also worth noting the defer attribute, which ensures that loading the script does not block the rendering of the rest of the page.

Deployment verification#

Writing the code is only half the battle. Now we must check if the build process actually generates what we expect. I ran a production build with the minify flag to see the final result.

hugo --minify --gc

# Check if the bundle was created with a hash
ls -lh public/bundle.min.*.js
# Should show: bundle.min.0893e8471a48215d547719fd68f2fef204b076c01da7eb4883ee07be5809f463.js

The first thing I noticed was the filename. The presence of a long string of characters after bundle.min confirms that fingerprinting worked correctly. Next, I looked into the generated HTML code to ensure the paths were replaced correctly.

grep "bundle.min" public/index.html

Here, we are specifically looking for the integrity attribute. This is our guarantee of security.

<script src="/bundle.min.abc123def456.js" integrity="sha256-..." defer></script>

Finally, I performed a standard “smoke test”. I started hugo serve and manually clicked through key interface elements: the dropdown menu, the copy button in code blocks, and the theme switcher. In short, I checked if the functionalities provided by JavaScript still work.

Optimization results#

Let’s look at the hard data. I compared the metrics before and after implementing the changes. The values might seem small, but on the scale of the whole service, they make a difference.

MetricBeforeAfterChange
JS File Count21-50%
Transfer (GZIP)2.8 KB1.8 KB-36%
Size (raw)7.7 KB4.0 KB-48%
HTTP Requests21-50%
MinificationPartialFullYES
FingerprintingNONEYESYES
SRI integrityNONEYESYES
Cache safetyLOWHIGHYES

Why are these numbers important?#

Looking at the table, I would like to draw your attention to three key aspects that really impact the quality of our application.

First: Cache safety#

This is the most important change for me. Thanks to fingerprinting, every change in the JS code—even the smallest one—generates a completely new filename. What does this mean in practice? We can set a very aggressive caching time in the browser, even for a year. We don’t have to worry that the user will see an old version of the script because, with a new deployment, the HTML will simply point to the new file. This way, we eliminate the classic “it works on my machine, clear your cache” problem.

Second: Size reduction#

I managed to lower the transfer size by over 30%. We saved almost half the weight of the unpacked code. This is mainly because now we also minify theme-switcher.js, which was previously copied directly from the static directory. Fewer bytes mean faster loading, especially on mobile devices.

Third: SRI Integrity#

We introduced the Subresource Integrity mechanism. If for some reason the file on the server were swapped or corrupted, the browser would detect it thanks to the checksum and block the code execution. This is an additional layer of security that we get essentially for free.

A note on script order#

There is one technical trap I must mention. The slice function we used to combine files respects the order of arguments. If your scripts have dependencies for example, the theme switcher script uses functions defined in the menu,you must keep the correct order.

{{- $bundle := slice $menu $code ... -}}
         order        ↑      ↑
                      1      2 

In my case, the modules were independent, so the order was arbitrary. However, if you see “X is not defined” errors in the console, check first if the base library is loaded before the script that uses it.

Summary#

JavaScript optimization in Hugo is a textbook example of a “low effort, high impact” task for me. The whole operation took me about 15 minutes.

In return, we gained stability and performance. The end user gets a page that loads scripts in 0 milliseconds from the cache on subsequent visits. As developers, we gain peace of mind and certainty that after deployment, no one will report a bug resulting from outdated code in the cache. Additionally, thanks to SRI, we raised the security level.

If you haven’t implemented fingerprinting in your Hugo projects yet, I definitely recommend adding it to your next sprint.

Sources#