Introduction#

We often focus on optimizing JavaScript or images, while treating CSS styles as somewhat secondary. We assume that since individual files weigh just a few kilobytes, they’re not a problem. But that’s a false assumption. The way we deliver these files to the browser can have a crucial impact on how quickly users see the finished page.

When I looked into Chrome’s developer tools, I noticed a specific problem on my site. My page was loading fifteen separate CSS files. Combined, this generated a delay of around one point six seconds. Interestingly, the data size wasn’t the issue at all. GZIP compression was working correctly and reducing file weight by over sixty percent. The real bottleneck turned out to be the sheer number of HTTP requests. The browser was wasting time establishing connections instead of downloading content.

In this post, I’ll show you how to solve this problem in Hugo by going through three optimization stages.

First, we’ll tackle advanced minification configuration, which will let us save additional bytes. Second, we’ll implement CSS bundling. Instead of many files, we’ll serve one, which drastically reduces network overhead. Finally, we’ll cover fingerprinting, a mechanism that enables safe and long-term caching of resources in the browser.

Problem: CSS Analysis Before Optimization#

To understand the scale of the problem, I looked deep into the HAR file generated by Chrome DevTools. The numbers stood out immediately. On the homepage, the browser was making a total of thirty-four HTTP requests. Significantly, fifteen of them were just for style sheets. Most came from the site theme, and one was an external resource from Google Fonts.

Even though these files were extremely light—weighing only eleven kilobytes in total after GZIP compression—downloading them took far too long. The average time for a single file was over one hundred milliseconds, which added up to a total of around one point six seconds.

When I looked at the detailed list of files, like buttons.css, header.css, or menu.css, I noticed a repeating pattern. Regardless of whether a file weighed two kilobytes or just five hundred bytes, the time overhead was almost identical. This clearly pointed to three key architectural problems.

First, an excessive number of requests. Each of the fifteen requests carries the cost of establishing a connection, sending headers, and the so-called “TCP slow-start.” It is worth remembering that although the HTTP/2 protocol offers multiplexing, which theoretically helps in such situations, the overhead remains significant. In my case, these small delays accumulated to over one and a half seconds.

Second, a lack of caching strategy. I noticed that the server was not sending proper cache headers, and the files did not have unique signatures, known as fingerprinting. This means that on every visit, the browser had to download the same styles again instead of reaching into the cache.

The third point was conservative minification. Hugo, in its default configuration, minifies files safely but not optimally. I saw potential here to “shave off” an additional dozen or so percent from the file size through more advanced settings.

So, the diagnosis was simple: the bottleneck was not bandwidth, but the mechanics of data transfer itself. We had fifteen small files blocking page rendering for over one and a half seconds.

Level 1: Advanced Minification Configuration#

The first step I took was to look at how Hugo handles file minification. By default, when we use the minify flag, Hugo applies a fairly conservative configuration. It works safely, but it leaves a lot of room for improvement. So, I decided to override the default settings to squeeze out maximum performance.

We start the changes in the main configuration file. The snippet below defines more aggressive compression rules for different file types.

Configuration in hugo.toml#

# Minification settings for optimal performance
[minify]
  minifyOutput = true

  [minify.tdewolff]
    [minify.tdewolff.html]
      keepComments = false
      keepWhitespace = false
      keepEndTags = true
      keepQuotes = false
      keepDefaultAttrVals = true
      keepDocumentTags = true

    [minify.tdewolff.css]
      precision = 2

    [minify.tdewolff.js]
      keepVarNames = false
      precision = 2
      version = 2022

    [minify.tdewolff.json]
      precision = 2

    [minify.tdewolff.svg]
      keepComments = false
      precision = 0

    [minify.tdewolff.xml]
      keepWhitespace = false

What Do These Settings Give Us?#

Let me explain the logic behind these changes, because the devil is in the details.

For HTML, I decided to completely remove comments and unnecessary whitespace. Interestingly, we also disable keeping quotes where possible. Browsers are great at interpreting attributes without them, and we save valuable bytes.

When it comes to CSS and JavaScript, number precision is key. Instead of the default long decimal expansions, we round values to two decimal places. To the human eye on the screen, the difference is unnoticeable, but for file size, it matters a lot. Additionally, in JavaScript, we allow variable names to be shortened.

I made a small exception for SVG. Here, setting precision to zero in the context of Hugo means no rounding. We want to keep the full precision of vectors so that graphics do not lose quality.

Optimizing the Build Process#

It is also worth enabling build statistics generation. It is a small change in the configuration, but very useful when we want to audit exactly what went into our package later.

[build]
  writeStats = true

Deployment Flags#

Configuration is only half the battle. The other half is how we run our build. In my production pipeline, I use a set of two flags.

hugo --minify --gc

The --minify flag activates those aggressive settings we defined earlier. From my observations, for a typical blog, this reduces the size of HTML and CSS code by another twenty to thirty percent.

The second flag, --gc or Garbage Collection, takes care of our project’s hygiene. It cleans the resource directory of unused files that might remain from previous versions. This is especially important in CI/CD processes because it guarantees environment consistency and prevents cache folders from “bloating.”

The effect of these changes was immediate. The size of the HTML itself dropped for me from about ten to eight kilobytes, and the CSS became much lighter.

Level 2: CSS Bundling#

The main problem we identified earlier—CSS fragmentation—needs a radical solution. Many themes, including the popular Terminal, load each style module (buttons, code, fonts) as a separate resource by default. It looks something like this:

<link rel="stylesheet" href="/css/buttons.min.abc123.css">
<link rel="stylesheet" href="/css/code.min.def456.css">
<link rel="stylesheet" href="/css/fonts.min.ghi789.css">
<!-- ... 12 więcej plików = 15 total -->

As we already established, despite the benefits of the HTTP/2 protocol, each of these requests generates overhead. The data was clear: fifteen small files “cost” the browser over one and a half seconds. It is time to change that.

The Solution: Hugo Pipes Bundling#

Hugo has a powerful built-in mechanism called Hugo Pipes. It allows us to process resources on the fly. Our goal is to collect all these loose files and glue them into one optimized package, or “bundle.”

Importantly, we will not edit the theme’s source files. That is a bad practice that makes future updates difficult. Instead, we will only override the file responsible for the page header.

Step-by-Step Implementation#

Let’s start by copying the head.html file from the theme to our project directory so we can edit it safely.

Next, we open this file and replace the section that loads styles. Instead of a loop generating dozens of links, we insert the logic below.

{{ $css := resources.Match "css/*.css" }}
{{ $bundledCSS := $css | resources.Concat "css/bundle.css" | minify | fingerprint }}

{{/* Preload for faster initial render */}}
<link rel="preload" href="{{ $bundledCSS.Permalink }}" as="style">
<link rel="stylesheet" href="{{ $bundledCSS.Permalink }}">

It is also worth considering a hybrid approach. We often want theme styles (which rarely change) to be in one bundle, and our own custom fixes in a separate one. This makes development easier, although it adds one extra request. The decision is yours, but here is how to do it:

{{/* Bundle theme CSS */}}
{{ $themeCss := resources.Match "css/*.css" | resources.Concat "css/bundle.css" | minify | fingerprint }}
<link rel="preload" href="{{ $themeCss.Permalink }}" as="style">
<link rel="stylesheet" href="{{ $themeCss.Permalink }}">

{{/* Your custom CSS (easier to edit separately) */}}
{{ $customCss := resources.Get "style.css" | minify | fingerprint }}
<link rel="stylesheet" href="{{ $customCss.Permalink }}">

What Exactly Happens “Under the Hood”?#

This short piece of code does a lot of work that is worth understanding. First, resources.Match scans the resource directory and catches all files with the .css extension. The Concat function combines them into one physical file named bundle.css, taking care to keep the correct order (which is crucial for cascading styles). Next comes minify. Here, those aggressive compression rules we set in the first step are applied. The key element is fingerprint. It adds a unique hash of the content to the filename. This is our insurance policy against cache problems. Finally, we use preload, signaling the browser: “hey, this file is critical, download it first.” This directly translates to content appearing on the screen faster (FCP).

Fingerprinting and the Magic of Caching#

Thanks to the fingerprint function, the final name of our file looks something like this:

bundle.min.b8ee5840c5ea050eecdf3b702643ce8213a5b7388d2aa71f87c043d4a1474c4e.css

The mechanism is brilliant in its simplicity. As long as you don’t change a single comma in the styles, the filename (and therefore the hash) remains the same, and the browser uses the version stored in the cache. However, if you change the color of just one button, Hugo will generate a new hash.

The result? A new filename forces the browser to download a fresh version, while the old version simply stops being used. So, we can safely set a very long cache expiration time (even a year!) and be sure that users will always see the current version of the page.

Verification#

Finally, like any good engineer, we need to check if it actually works. Run the build and check the result.

hugo --minify --gc

# Check size of bundle CSS
ls -lh public/css/bundle*.css

# Check HTML how many css links contain
grep -o '<link[^>]*css[^>]*>' public/index.html

If, after grepping the HTML file, you see only one link to CSS instead of fifteen—congratulations. You have just slimmed down your site by removing unnecessary requests.

CSS Optimization Results#

It is time to check the results. I went back to Chrome DevTools, cleared the browser cache, and ran the profiling again. I could see the speed difference immediately. However, the hard data impressed me the most.

Let’s compare the situation before and after the changes:

Status Before Optimization (Baseline)#

Total HTTP requests: 34
CSS files: 15
CSS size (uncompressed): 29 KB
CSS size (GZIP): 11 KB
Total CSS time: 1,635ms
Cache headers: None

Status After Changes (Bundle + Minify)#

CSS files: 3 (bundle.css + style.css + Google Fonts) (↓ 80%)
CSS size (uncompressed): 29 KB (unchanged)
Main bundle: 19.7 KB (uncompressed), time: 58ms
style.css: 7.2 KB, time: 51ms
Google Fonts: 2.5 KB, time: 26ms
Total CSS time: 135ms (↓ 92%! ⚡)
Cache headers: public, max-age=31536000, immutable

Analysis of Key Metrics#

When I look at these numbers, I see three main conclusions. It is worth remembering them when optimizing any web project.

  1. Time Is Not Just File Size

This is the most important lesson from this experiment. Notice that the total CSS size stayed almost the same at 29 KB. Despite this, the loading time dropped from 1.6 seconds to just 135 milliseconds. That is a 92% reduction. We proved that the bottleneck was not bandwidth. It was the overhead of handling many connections.

  1. Less Means Faster (Request Reduction)

We reduced the number of style requests by 80%, from 15 to 3. This move “unloaded” the whole page and freed up space in the request queue. Thanks to this, the browser can download other resources like images or JS scripts faster. It does not have to waste time “juggling” small CSS files.

  1. Stability Thanks to Cache Headers

The last but equally important point is headers. Before, they were missing. Now, thanks to max-age and immutable, the browser knows it can safely keep these files for a year. A user who returns to my site tomorrow or next month will not download a single byte of CSS. The styles will load instantly from the disk.

To sum up: with minimal configuration work in Hugo, we managed to eliminate one of the biggest site slowdowns. This is an example of optimization with a very high Return on Investment (ROI) for your time.

Traps to Watch Out For#

Implementing bundling sounds great on paper, but in practice, you might hit a few “reefs” that I had to navigate around myself. Here are the two most common problems and proven ways to handle them.

Problem 1: Random CSS Order (Broken Styles)#

It might happen that after merging files, your site looks strange. Buttons lose their colors, and the layout falls apart. Why? Remember that in CSS, order matters (it is Cascading Style Sheets, after all).

When we use the resources.Match function, Hugo grabs files matching the pattern, but not necessarily in the order we care about (often alphabetically). If an overrides file (overrides.css) loads before the base file, the styles won’t work.

Solution#

If the order is critical, give up on automatic matching (Match). Instead, define the queue manually by creating a so-called slice. This gives you 100% certainty that normalize.css will always be first, and utilities.css will be last.

{{ $cssFiles := slice
  (resources.Get "css/normalize.css")
  (resources.Get "css/base.css")
  (resources.Get "css/components.css")
  (resources.Get "css/utilities.css")
}}
{{ $bundledCSS := $cssFiles | resources.Concat "css/bundle.css" | minify | fingerprint }}

Problem 2: Google Fonts Outside the Bundle#

During bundle verification, you might notice that font styles are missing. This happens if you use external sources like Google Fonts.

The Hugo Pipes mechanism operates exclusively on local files located in the assets directory. Hugo does not automatically download content from external servers (like fonts.googleapis.com) during the build process, so external links are ignored by the resources.Concat process.

Solution#

You have two paths. You can download the font files to your disk and serve them locally (which is great for privacy and GDPR), or the simpler way leave them as a separate request. In the second case, we simply separate the logic for local CSS and external fonts.

<!-- Bundle internal CSS -->
{{ $bundledCSS := ... }}

<!-- External fonts separately -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fira+Code&display=swap">

Summary#

CSS optimization in Hugo is a classic example of the Pareto principle a relatively small effort delivered disproportionately large benefits. Let’s look at the hard data from production: we reduced the number of style requests by 80% (from 15 to 3), and their loading time shortened by 92%—from 1.6 seconds to just 0.14 seconds.

Why Did It Work?#

The success of this optimization rests on several solid engineering pillars:

  1. HTTP Overhead Reduction – Fewer requests mean less time wasted on TCP handshakes and sending headers.

  2. Effective Minification – Aggressive configuration allowed us to remove every unnecessary byte.

  3. Fingerprinting and Cache – Thanks to file hashing, we could safely implement an immutable cache policy. The browser now knows the resource hasn’t changed, so it doesn’t even ask the server for an update.

  4. Preloading – Explicitly indicating loading priority improved the First Contentful Paint metric.

Long-Term Value#

What do these changes mean in practice for users?

For new visitors, the site loads noticeably smoother, especially on mobile devices where every extra connection on a weak signal is costly.

However, returning users see the real magic. Thanks to correct cache headers (max-age=31536000), styles are loaded directly from the device’s disk in 0 ms. After the first visit, the network stops being a bottleneck for the visual layer.

In short: CSS bundling in Hugo is typical “low-hanging fruit.” It is an optimization that is easy to implement, safe to maintain, and gives an immediate, measurable jump in performance. If you haven’t done this in your projects yet it is definitely worth it.

Sources and Further Reading#