31 Aug 2026 · 3 min read
Case study: the hero banner was never the largest contentful paint
Wrapping the hero image in an Alpine x-if meant it wasn't the LCP node, and fetchpriority landed on the wrong element. A small template change moved the metric.
Field notes — a real production bug, project name withheld. Symptom → investigation → fix → lesson.
The symptom
A Hyva storefront's homepage hero banner was the obvious largest contentful paint, yet Lighthouse kept reporting the LCP element as something else, and the score sat below target. The banner image was present, but it wasn't being treated as the priority paint — and on mobile there wasn't even a dedicated mobile image, so the desktop asset was being squeezed into the small slot.
Investigation
The hero item template wrapped the image in an Alpine x-if:
<template x-if="item.showImage">
<picture class="block w-full h-full">
<source media="(max-width: 767px)" :srcset="item.mobileSrcset">
<img
:alt="item.alt"
class="w-full h-full object-center object-cover"
:src="item.image"
:fetchpriority="item.fetchPriority"
:loading="item.loading"
>
</picture>
</template>
Two problems. First, an element inside <template x-if> is not in the initial DOM — Alpine creates it after hydration. The browser therefore can't treat it as the LCP candidate at first paint; the LCP node becomes whatever static element happens to be largest at that moment, which is usually a smaller, less important block. Second, fetchpriority and loading were bound but the wrapping <picture>/<template> meant the priority hint applied to an element the parser couldn't count on at paint time. There was also no real mobile <img> — only a <source> inside a <picture> that the layout didn't reliably honor, so mobile got the desktop image at the wrong size.
Root cause
Alpine deferring the LCP image. Any image you want counted as the largest contentful paint must be in the static, server-rendered HTML, not gated behind an x-if that materializes it after JavaScript runs. Wrapping it in a template moved the paint target and robbed it of priority.
The fix
Render the image directly (no x-if wrapper) so it is part of the initial HTML, apply the priority and loading hints to the <img> itself, and add a real mobile <img> that only shows on small screens:
<div class="w-full h-full">
<img
:alt="item.alt"
class="w-full h-full object-center object-cover"
:class="item.desktopImageClass"
:src="item.image"
:fetchpriority="item.fetchPriority"
:loading="item.loading"
>
<template x-if="item.image_mobile">
<img
:alt="item.alt"
class="w-full h-full object-center object-cover md:hidden"
:src="item.image_mobile"
:fetchpriority="item.fetchPriority"
:loading="item.loading"
>
</template>
</div>
The decorative top image was also moved out of its own x-if into a stable element toggled with x-show (which keeps it in the DOM and just flips visibility), so it no longer risked shifting layout after load:
<img
:src="item.topImage"
alt="<?= $escaper->escapeHtmlAttr(__('Background Image')) ?>"
class="absolute ... z-[3]"
x-show="item.hasTopImage"
>
Lesson
LCP and Alpine don't mix by default — anything you wrap in x-if is invisible to the first paint and can't be the largest contentful paint. If an image must be the LCP element, render it in the static HTML and put fetchpriority="high" and loading="eager" on it directly. Use x-show (not x-if) for decorative images you want present but hidden, to avoid post-hydration layout shift. And always ship a real mobile <img> for above-the-fold hero slots; a desktop <source> inside a <picture> wrapped in a template is not a mobile image. Measure with Lighthouse before and after — the move from "some other element" to the hero <img> is the whole win.