Introduction#

Images are usually the biggest obstacle to website performance. Although Hugo generates fast static sites by nature, unoptimized images can completely cancel out this advantage. I decided to investigate this using my own website as a case study.

Analysis in Chrome DevTools clearly showed the source of the problem. Images made up 96% of the total transfer, adding up to nearly 15 MB. I identified three large PNG files that caused most of this traffic and increased the load time to over 4 seconds. For mobile users, this meant a long wait and wasted data.

In this article, we will go through the complete optimization process in Hugo. I will show you how to significantly reduce file size and speed up page loading without losing visual quality. Instead of manually editing files in image editors, we will use Hugo’s built-in image processing tools.

Problem Diagnosis: Analysis Before Optimization#

Let’s start with hard data. A quick look at the HAR file in Chrome DevTools left no doubt. We were sending nearly 15 MB of image data, and the transmission alone took over 4 seconds. After a deeper analysis, I identified four main reasons for this.

First, the file format was wrong for the content. I was using PNG for screenshots. While PNG is great for simple graphics with sharp edges, it is very inefficient for photos or gradients. The lack of lossy compression causes files to be unnecessarily large.

The second issue was serving images in full resolution. Files weighing 6 or 7 MB suggest a 4K resolution, likely over 2800 pixels wide. Meanwhile, the container on my site displays graphics at a maximum width of 1200 pixels. As a result, we were sending pixels to the browser that the user couldn’t see anyway.

The third “sin” was the lack of lazy loading. All graphics were downloaded immediately when the page first rendered. Even images deep within the article, outside the visible screen area, burdened the connection at the critical startup moment.

Finally, there was the lack of responsiveness. I wasn’t using the srcset attribute, which meant mobile devices were treated the same as desktops. A phone with a small screen had to download the same 7 MB giants as a desktop computer. In total, these four mistakes cost us nearly 15 MB of wasted transfer on just three images.

The Solution: Hugo Image Processing#

Hugo has a built-in image processing mechanism via Hugo Pipes. This means we don’t have to manually convert every graphic in an image editor. The entire process can be automated directly in Hugo templates. Let’s see how this works in practice.

Moving Images to the Assets Directory#

Hugo Image Processing works only on files located in the assets directory, not in the static directory. This is a key difference in Hugo’s architecture, and it’s worth understanding why.

Files in the static directory are copied unchanged to the public folder when the site is built. Hugo does not process them in any way. On the other hand, files in the assets directory are available to Hugo Pipes, which means we can resize, convert, and optimize them automatically during the build process. Therefore, the first step is to move all images from the static folder to the appropriate structure in assets.

Creating a Shortcode for Responsive Images#

Now, we create a shortcode file at layouts/shortcodes/image.html. Thanks to this, every image placed in a markdown file will be automatically optimized, scaled, and prepared in several versions during the site build. This mechanism allows us to generate the srcset attribute and implement lazy loading without manually editing every single case.

{{ $src := .Get "src" }}
{{ $alt := .Get "alt" | default "Image" }}

{{/* Validate src parameter */}}
{{ if not $src }}
  <p class="error">Error: image src parameter is required</p>
{{ else }}
  {{ $img := resources.Get $src }}

  {{ if $img }}
    {{/* Define sizes based on original image width */}}
    {{ $originalWidth := $img.Width }}
    {{ $sizes := slice }}

    {{/* Only generate sizes that are smaller than or equal to original */}}
    {{ if ge $originalWidth 800 }}{{ $sizes = $sizes | append 800 }}{{ end }}
    {{ if ge $originalWidth 1200 }}{{ $sizes = $sizes | append 1200 }}{{ end }}
    {{ if ge $originalWidth 1600 }}{{ $sizes = $sizes | append 1600 }}{{ end }}

    {{/* If image is smaller than 800px, use original width */}}
    {{ if lt $originalWidth 800 }}{{ $sizes = slice $originalWidth }}{{ end }}

    <picture>
      {{/* WebP source - good compression, wide support */}}
      <source type="image/webp" srcset="
        {{- range $i, $size := $sizes -}}
          {{- $resized := $img.Resize (printf "%dx webp q80" $size) -}}
          {{- $resized.RelPermalink }} {{ $size }}w
          {{- if ne $i (sub (len $sizes) 1) }}, {{ end -}}
        {{- end -}}"
        sizes="(max-width: 800px) 100vw, 1200px">

      {{/* Fallback image */}}
      {{ $fallbackSize := 1200 }}
      {{ if lt $originalWidth 1200 }}{{ $fallbackSize = $originalWidth }}{{ end }}
      {{ $fallback := $img.Resize (printf "%dx q80" $fallbackSize) }}

      <img
        src="{{ $fallback.RelPermalink }}"
        alt="{{ $alt }}"
        loading="lazy"
        decoding="async"
        width="{{ $fallback.Width }}"
        height="{{ $fallback.Height }}">
    </picture>
  {{ else }}
    <p class="error">Image not found: {{ $src }}</p>
  {{ end }}
{{ end }}

Optimizing Cover Images: Custom Partial#

Most Hugo themes display cover images using a single snippet of code. This is usually {{ partial “cover.html” . }}. To optimize these key page elements, we don’t need to edit every view individually. We just need to create our own, improved version of this file.

We create a layouts/partials/cover.html file, which will overwrite the theme’s default behavior. Thanks to this mechanism, every post cover — whether on the homepage or in the article itself — will automatically go through our optimization process. This is a perfect example of the “define once, use everywhere” principle, which saves a lot of time in project maintenance.

{{- $cover := false -}}
{{- $autoCover := default $.Site.Params.autoCover false }}

{{- if index .Params "cover" -}}
  {{/* Remove leading slash if exists */}}
  {{- $coverPath := strings.TrimPrefix "/" .Params.Cover -}}
  {{- if .Resources.GetMatch $coverPath }}
    {{- $cover = .Resources.GetMatch $coverPath -}}
  {{- else -}}
    {{- $resource := resources.Get $coverPath -}}
    {{- if $resource -}}
      {{- $cover = $resource -}}
    {{- else -}}
      {{/* Fallback: use absolute URL if resource not found */}}
      {{- $cover = absURL .Params.Cover -}}
    {{- end -}}
  {{- end -}}
{{- else if $.Site.Params.AutoCover -}}
  {{- if (not .Params.Cover) -}}
    {{- if .Resources.GetMatch "cover.*" -}}
      {{- $cover = .Resources.GetMatch "cover.*" -}}
    {{- end -}}
  {{- end -}}
{{- end -}}

{{- if $cover -}}
  {{- $coverType := printf "%T" $cover -}}
  {{- if ne $coverType "string" -}}
    {{/* $cover is a resource - use Hugo Image Processing */}}

    {{/* Define sizes based on original image width (no upsampling!) */}}
    {{- $originalWidth := $cover.Width -}}
    {{- $sizes := slice -}}

    {{/* Only generate sizes that are smaller than or equal to original */}}
    {{- if ge $originalWidth 800 }}{{ $sizes = $sizes | append 800 }}{{ end -}}
    {{- if ge $originalWidth 1200 }}{{ $sizes = $sizes | append 1200 }}{{ end -}}
    {{- if ge $originalWidth 1600 }}{{ $sizes = $sizes | append 1600 }}{{ end -}}

    {{/* If image is smaller than 800px, use original width */}}
    {{- if lt $originalWidth 800 }}{{ $sizes = slice $originalWidth }}{{ end -}}

    <picture class="post-cover">
      {{/* WebP source - good compression, wide support */}}
      <source type="image/webp" srcset="
        {{- range $i, $size := $sizes -}}
          {{- $resized := $cover.Resize (printf "%dx webp q80" $size) -}}
          {{- $resized.RelPermalink }} {{ $size }}w
          {{- if ne $i (sub (len $sizes) 1) }}, {{ end -}}
        {{- end -}}"
        sizes="(max-width: 800px) 100vw, 1200px">

      {{/* Fallback image - universal support */}}
      {{- $fallbackSize := 1200 -}}
      {{- if lt $originalWidth 1200 }}{{ $fallbackSize = $originalWidth }}{{ end -}}
      {{- $fallback := $cover.Resize (printf "%dx q80" $fallbackSize) -}}

      <img
        src="{{ $fallback.RelPermalink }}"
        class="post-cover"
        alt="{{ .Title | plainify | default " " }}"
        title="{{ .Params.CoverCredit | plainify | default "Cover Image" }}"
        loading="lazy"
        decoding="async"
        width="{{ $fallback.Width }}"
        height="{{ $fallback.Height }}">
    </picture>
  {{- else -}}
    {{/* $cover is a string URL - fallback to simple img tag */}}
    <img src="{{ $cover }}"
      class="post-cover"
      alt="{{ .Title | plainify | default " " }}"
      title="{{ .Params.CoverCredit | plainify | default "Cover Image" }}"
      loading="lazy">
  {{- end -}}
{{- end -}}

Visual Safety and Content Implementation#

Optimizing the files themselves isn’t everything. We also need to ensure that the browser correctly displays our new, lighter graphics.

When we manipulate how images load, preserving their natural proportions — or aspect ratio — is key. If your stylesheet contains general rules for the img element, it’s worth adding a small fix in the static/style.css file. The goal is to force an automatic height while maintaining maximum width.

img {
  display: block;
  max-width: 100%;
  height: auto; /* ← IMPORTANT: force to keep aspect ratio */
  border: 8px solid var(--accent);
  border-radius: var(--radius);
  padding: 8px;
  overflow: hidden;
}

This simple trick prevents image distortion when the container changes its size, for example on mobile devices. It’s a small detail that saves a lot of frustration when testing responsiveness.

The final piece of the puzzle is changing how we embed images in our posts. Until now, we used standard Markdown syntax.

Before (markdown):

![Image desc](/img/image-1.png)

Now, to use all the magic of Hugo Pipes, we switch to our new shortcode.

After (shortcode):

{{/**<image src="img/image-1.png" alt="Image desc"> */}}

Thanks to this change, instead of a static file link, Hugo will substitute the entire picture or img tag structure with the appropriate srcset attributes and next-generation formats. It takes a moment to get used to when writing, but the performance gain is worth it.

What Happens Behind the Scenes?#

When I run a build like hugo --minify, all the “magic” happens because images go through the Hugo Pipes pipeline, which is built-in asset processing during page generation. This isn’t an external script or manual work in an editor, but part of the build process, controlled from templates and shortcodes.

  1. Resizing to Reasonable Dimensions

The first step is resizing the image to a size that actually makes sense on the page, while maintaining the aspect ratio. In practice, this means I don’t serve “raw” 4K if the layout will never show more than about 1200 px width.

  1. WebP Conversion

The next thing is format conversion, for example from PNG to WebP, usually with quality set around 80. As a result, I get a significantly lighter file, and visually, in most cases, the difference is hard to spot during normal viewing.

  1. Responsive Images

Instead of one file, I generate several size variants, for example 800 px, 1200 px, and 1600 px, and then expose them as srcset. This allows the browser to choose the best version for the real screen and device DPI, instead of always downloading the “biggest monster”.

  1. Lazy Loading

At the shortcode stage, I add loading=“lazy” so that graphics outside the first screen don’t start downloading immediately. This is a simple change, but it gives a real effect: a user who doesn’t scroll down the page doesn’t pay with data for content they won’t see.

  1. Asynchronous Decoding

Similarly, I set decoding=“async” so that image decoding interferes less with page rendering. In practice, the page has a better chance to “wake up” faster, and images load in the background when the browser has the capacity.

Image Optimization Results#

The numbers speak for themselves. Let’s compare the sizes of the three main graphics before and after implementing our process. Replacing the PNG format with modern WebP and adjusting the resolution brought spectacular results.

FileSize Before (PNG)Size After (WebP, q80)Reduction
Image 16.83 MB155 KB~97.7%
Image 25.93 MB102 KB~98.2%
Image 31.53 MB88 KB94.3%
Favicon190 B190 B0% (no change)
TOTAL~14.29 MB~345 KB~97.6%

For a typical page loading these three graphics, we reduced the transfer from nearly 15 MB to just 350 KB. That’s a 98% saving on every page view. Most importantly, the load time dropped from over 4 seconds to a fraction of a second, which is key for User Experience (UX). ​

Verifying Files After Build#

If you want to see exactly what Hugo generated, just run the build command and look into the public directory.

# Build the site with image processing
hugo --minify

# Check sizes of generated images
ls -lh public/img/

# Example output:
# image-1_hu_393c60f826dfd825.webp    1.2M  (was 7.16MB PNG)
# image-1_hu_3943bfca655b8414.webp    800K  (800px version)
# image-1_hu_f3273b924940ab4b.webp    1.8M  (1600px version)
# image-1_hu_a4439ec021e41ca7.png     1.5M  (PNG fallback)

In the output folder, you will notice something interesting. Instead of a single file, you will find several versions of the same image with different resolutions (for example, 800px and 1600px versions) and a fallback file.

Also, pay attention to the filenames. Hugo automatically adds a string of random characters, known as a hash or fingerprint. Thanks to this mechanism, browsers know when a file has changed and needs to be downloaded again, which effectively solves caching issues (cache busting).

Testing and Verification#

Implementation is one thing, but we need to be sure everything works according to plan. Here is how you can easily verify the results of our work.

  1. Check if Images are Generated

We perform the first test directly in the terminal. After building the site with the command hugo --minify, we look into the resources directory.

# After: hugo --minify
ls -lh resources/_gen/images/img/

# Should show:
# - Many .webp files (different sizes)
# - .png files (fallback)
# - Hashes in filenames (fingerprinting)

You should see many files there. Besides the originals, there will be WebP versions in various sizes and PNG files serving as a fallback. Notice the strange strings of characters in the filenames – that’s fingerprinting, which guarantees that users always see the latest version of the graphic.

  1. Check HTML in the Browser

Next, we open our site in a browser and look “under the hood” using DevTools. Find the <picture> element.

# DevTools → Elements → Find `<picture>`
# Should show:
# <picture class="post-cover">
#   <source type="image/webp" srcset="...">
#   <img src="..." loading="lazy">
# </picture>

If everything went well, you will see <source> tags with the srcset attribute pointing to WebP files, and a standard <img> tag with the loading=“lazy” attribute. This is proof that the browser is receiving a complete set of instructions on how to handle graphics optimally.

  1. Test in Different Environments

It’s also worth checking behavior in different browsers. Chrome and Firefox should load lightweight WebP files without issues. If you have access to older Apple hardware (Safari below version 14), you should see the classic PNG file there – that’s our fallback mechanism in action. On a phone, check if a smaller version of the image (e.g., 800px) is downloaded instead of the full desktop resolution. ​

Troubleshooting#

Finally, here are a few common traps you might fall into during implementation.

Problem: “Image not found”#

The most common error is the one I mentioned at the beginning: looking for images in the wrong place. If Hugo “yells” that it can’t see a file, make sure you moved it from the static folder to assets. Remember: static is only copied, assets is processed.

Problem: No New Images After Rebuild#

Sometimes Hugo might not notice changes in source files and use the old cache. If you don’t see new versions of graphics despite code changes, it’s worth clearing the cache.

# Clean build
rm -rf resources public
hugo --minify --gc

This will force Hugo to recalculate all resources.

Problem: Distorted Proportions#

If images seem stretched or squashed, the culprit is usually CSS. Make sure your styles for the img tag include the height: auto rule. Without it, the browser might try to fit the image into a rigid container, ignoring its natural proportions.

Summary#

Image optimization is undoubtedly the single most profitable change you can introduce in your Hugo project. The numbers we obtained speak for themselves and show the scale of the problem we often ignore.

Results by the Numbers#

We reduced the total size of transferred data from almost 15 MB to just 345 KB. That’s a drop of over 97%. The load time decreased from over 4 seconds to less than 700 milliseconds. For a mobile user, this is the difference between instant access to content and frustrating waiting.

Why Did It Work?#

The secret lies in combining several mechanisms. The WebP format provides drastically better compression than classic PNG. Scaling images to actual dimensions (for example, 1200 px instead of the original 4000 px) eliminates sending unnecessary pixels. Responsive images and lazy loading ensure we only download what is currently needed on a specific screen. And best of all — thanks to Hugo, all of this happens automatically during the site build.

Business Value#

Faster loading isn’t just a matter of technical satisfaction. It translates directly into better User Experience and a lower bounce rate. Less transfer means lower hosting costs, and a mobile-friendly site gains favor with Google, ranking better thanks to good Core Web Vitals scores.

If you have time to optimize just one thing on your Hugo site — start with images. Thanks to the built-in Image Processing tools, implementation is simpler than you think, and the effect is immediate.

Additional Hugo Image Processing Capabilities#

Hugo offers even more image optimization features that I haven’t tested yet, but are worth knowing about:

Blur Placeholder (LQIP)#

Hugo can generate tiny, blurred versions of images to serve as placeholders while loading. more

Smart Crop#

Hugo detects faces and points of interest in an image, automatically cropping to the most important part. Useful for automatically generating thumbnails and social media cards. more

Image Filters#

Hugo supports filters: GaussianBlur, Grayscale, Sepia, Brightness, Contrast, Gamma, ColorBalance, Saturation. more

These features are available in Hugo and can be added in the future for an even better user experience.

Sources and Further Reading#