Post
Your hero image is your LCP score
Open any page that fails Core Web Vitals and there is a good chance the culprit is a single JPEG. The Largest Contentful Paint element — the thing Google times at 2.5 seconds for a passing grade, measured at the 75th percentile of real visits — is typically a hero image, a large text block, or a video. On a marketing page it is nearly always the image.
The depressing part is how mechanical the fix is. A 2025 HTTP Archive analysis found that over 60% of image-heavy sites still serve one image size to every device, meaning phones download files three to eight times larger than their screens can display. That is not a hard engineering problem. It is a build-step that nobody added.
So let us add it.
The one-liner that does most of the work
{{ $set := imageSrcSet "hero.jpg" (dict "widths" (slice 480 768 1200 1920) "format" "webp") }}
<img src="{{ $set.Default.URL }}"
srcset="{{ $set.SrcSet }}"
sizes="(min-width: 64rem) 60vw, 100vw"
width="{{ $set.Default.Width }}"
height="{{ $set.Default.Height }}"
fetchpriority="high"
alt="Waterfall in a granite gorge">
Four variants get generated at build time, in WebP, from one source file. What each attribute is actually doing:
srcset hands the browser a menu. sizes tells it how wide the image
will be rendered at each breakpoint — without that, the browser assumes the
full viewport width and quietly picks the largest candidate, which undoes the
whole exercise. If your layout is genuinely full-bleed everywhere, SSG's
image_sizes_attr config (default 100vw) covers the generated markup;
anywhere else, write the value yourself, because only you know the layout.
width and height are not decoration. They let the browser reserve the
box before the bytes arrive, which is the difference between a CLS of 0.0 and a
layout that jumps as the photo lands. The result object hands you the real
post-resize numbers, so there is no excuse to guess.
fetchpriority="high" is the cheapest LCP win available and is worth
setting on exactly one image per page — the hero. Add <link rel="preload">
for it if the image is discovered late, for instance from CSS.
Pick 3–5 widths based on file-size steps rather than the device widths of the month, and stop at 2× DPR. Chasing 3× triples your build output to serve displays that will downscale the result anyway.
Crops are a content decision, not a CSS decision
object-fit: cover is fine until the interesting part of the photograph is not
in the middle. The pipeline offers three answers, in increasing order of
opinion:
{{/* fit: largest size inside the box, aspect preserved */}}
{{ $thumb := imageResize "team.jpg" (dict "width" 480 "height" 320 "mode" "fit") }}
{{/* fill: exact dimensions, cropped, anchored where you say */}}
{{ $card := imageResize "team.jpg" (dict "width" 480 "height" 320 "mode" "fill" "anchor" "north") }}
{{/* focal point: crop stays centred on 0..1 coordinates you choose */}}
{{ $face := imageCrop "team.jpg" (dict "width" 400 "height" 400 "focusX" 0.32 "focusY" 0.2) }}
fill with "anchor" "north" is the one that saves group photos, because
faces live at the top and centre-cropping decapitates people. A focal point is
what you want when the subject is somewhere specific and you can be bothered to
say where — frontmatter is a reasonable home for those two numbers.
Filters compose in declared order, which matters more than it sounds:
{{ $i := imageFilter "photo.jpg" (slice
(dict "name" "grayscale")
(dict "name" "contrast" "amount" 1.1)
(dict "name" "sharpen" "amount" 0.3)
) (dict "format" "webp" "quality" 82) }}
Sharpening before downscaling produces crunch; sharpening after produces detail. The pipeline will not reorder your operations to be helpful.
Why the filenames look like that
Every generated file is named <base>.<hash10>.<ext>, where the hash is
sha256(source bytes + normalized operations + processor version).
This is the boring part that pays rent. The same source with the same operations produces the same filename on your laptop, in CI, and after you delete the whole cache — so a CDN can cache it forever, and a rebuild that changed nothing changes nothing. Edit the photograph and the hash moves, which is cache invalidation you did not have to think about. Concurrent identical requests are processed once.
The flip side is that an unused variant lingers in the cache after you stop
referencing it. --images-gc deletes what the finished build no longer
mentions; --images-gc-dry counts first if you are nervous.
The AVIF conversation
Here is the part where an honest post beats a feature list. AVIF now clears 95% browser support and runs 20–30% smaller than WebP, which makes it the obvious default in 2026 — and SSG does not encode it. There is no portable, CGO-free AVIF encoder in Go, and adding a C dependency would cost the single-static-binary property that makes the tool worth using.
That is a real trade-off, not a rounding error. What to do about it:
- WebP is not a consolation prize. It sits at 97%+ support with roughly 25–34% savings over JPEG and decodes fast. Most of the win, none of the toolchain.
- Let the CDN do it. Cloudflare Polish, and the equivalent on every other
image CDN, converts on the edge based on the request's
Acceptheader. Your build stays pure Go; the last hop serves AVIF to browsers that asked for it. - Or hand-roll
<picture>for the one image that matters, with an AVIF file you produced elsewhere and a WebP fallback from the pipeline. One hero is worth the manual step; four hundred thumbnails are not.
Two more constraints worth knowing before they surprise you: WebP encoding
shells out to cwebp, so a build box without it gets a descriptive error
rather than a silent JPEG — install webp in CI. And animated GIFs error out
instead of being flattened into a still frame, because a silently non-animated
GIF is a bug you find in production.
What you get for free
EXIF metadata — including GPS coordinates — is stripped, because outputs are re-encoded pixels rather than copied files. Orientation is normalised first, so that upright-on-your-phone, sideways-on-the-web classic does not happen. Sources above 80 megapixels or 20,000 pixels per side are refused, which is a decompression-bomb guard rather than an opinion about your photography.
The hero on this site's homepage is the whole pipeline in three lines: two WebP variants at 1920 and 900 pixels, laid under a gradient scrim so the headline keeps 16.3:1 contrast against a photograph. The logo beside it goes through the same code but comes out as PNG, because it has an alpha channel and PNG needs no external encoder.
None of this is clever. It is a build step that runs while you are doing something else, and it is the difference between a 2.5-second LCP and an apology to your SEO consultant.
Sources: Core Web Vitals 2026 thresholds · responsive images guide with the HTTP Archive figures · AVIF browser support in 2026 · image optimisation for LCP