Magento Responsive Images and Preload
lead
Magento LCP Images established the measurement: on a stock 2.4.8-p5 Luma install the Largest Contentful Paint element is a product image, its download is a small part of the cost, and the two things that actually move the metric are when the browser discovers the image and how many pixels it is asked to fetch. Neither is fixed by a module. Both are template work.
This is that template work. Everything below was checked against a running 2.4.8-p5 with sample data rather than read off a schema — which was worth doing, because three of the things in it do not behave the way you would expect: srcset needs no template override, as="image" in layout XML works in production and throws in developer mode, and the method most guides call from the image template does not exist on that block.
The constraint from the other article still holds: the shop maintainer uploads one image and does nothing else. Nothing here asks the content team for a second file.
baseline: the two numbers Magento picked for you
Start with the sizing, because it ships wrong by default and the fix is the cheapest of the three. On the stock product page, Magento’s own image cache serves the gallery image at 700×700 into a box that renders at 382×382 on mobile:

Roughly 70% of those bytes are thrown away by the browser. Note what the fix is and is not: the file is already a reasonably compressed JPEG, so a better codec saves little here. What is missing is a srcset that lets the browser ask for the size it will actually paint.
Where that number comes from – and why nobody uploads it. Neither the 700x700 above nor the 240x300 the category grid uses is a merchant decision. They are declared in the theme’s etc/view.xml, and Magento derives every cached variant from a single uploaded file:
<!-- vendor/magento/theme-frontend-luma/etc/view.xml -->
<image id="category_page_grid" type="small_image">
<width>240</width>
<height>300</height>
</image>
<image id="product_page_image_medium" type="image">
<width>700</width>
<height>700</height>
</image>
This matters because the instinct when someone asks for four breakpoints is to upload four files, or to press the content team into maintaining a size per image role. Do not. Roles are pointers, not copies: Base, Small, Thumbnail and Swatch in Images and Videos all point at the same uploaded file, and each <image id> above says how that file should be rendered wherever the template asks for that id. One upload, as many derived sizes as you declare.
So adding a responsive breakpoint is a view.xml edit in your own theme, not an upload workflow:
<!-- app/design/frontend/Vendor/theme/etc/view.xml -->
<image id="category_page_grid_400" type="small_image">
<width>400</width>
<height>500</height>
</image>
The template then asks for it by id – $block->getImage($_product, 'category_page_grid_400') – and you emit the set of ids as srcset. Two operational notes: new uploads are resized on first request, so nothing needs doing for them, but existing products keep serving the old cache until you regenerate it, and a changed view.xml only takes effect after the frontend cache is cleared:
bin/magento catalog:images:resize
bin/magento cache:flush
That command walks the whole catalog and is slow on a large one – it belongs in a deploy step or an off-hours run, not in a request. The payoff is that the maintainer’s job stays exactly one upload per product, which is the constraint this whole article is written around.
Where this actually gets written. Not in a shell script that pre-renders files – Magento already resizes on demand from the view.xml ids. And, it turns out, not necessarily in a template override either.
Start by reading core’s image_with_borders.phtml, because it contains both the problem and the way out:
// vendor/magento/module-catalog/view/frontend/templates/product/image_with_borders.phtml
<img class="<?= $escaper->escapeHtmlAttr($block->getClass()) ?>"
<?php foreach ($block->getCustomAttributes() as $name => $value): ?>
<?= $escaper->escapeHtmlAttr($name) ?>="<?= $escaper->escapeHtml($value) ?>"
<?php endforeach; ?>
src="<?= $escaper->escapeUrl($block->getImageUrl()) ?>"
loading="lazy"
Two things are true here. loading="lazy" is hardcoded, with no condition and no config – that single line is why a stock category page lazy-loads its own LCP element. But immediately above it is a loop over getCustomAttributes(), and that is a real extension point: whatever array you pass as the third argument to getImage() is rendered onto the tag verbatim.
That third argument is the one most write-ups miss:
// AbstractProduct::getImage($product, $imageId, $attributes = [])
public function getImage($product, $imageId, $attributes = [])
{
return $this->imageBuilder->create($product, $imageId, $attributes);
}
So the responsive markup is built in the listing template, where the product is in scope, and needs no override of the image template at all:
// Vendor/theme/Magento_Catalog/templates/product/list.phtml
$position = 0;
foreach ($_productCollection as $_product):
// Each id is an <image id="..."> entry in the theme's etc/view.xml.
$srcset = [];
foreach (['category_page_grid_400', 'category_page_grid_600', 'category_page_grid_900'] as $variantId) {
$variant = $block->getImage($_product, $variantId);
$srcset[] = $variant->getImageUrl() . ' ' . (int) $variant->getWidth() . 'w';
}
$aboveFold = $position < 2; // first row on mobile; match your grid
$productImage = $block->getImage($_product, $imageDisplayArea, [
'srcset' => implode(', ', $srcset),
'sizes' => '(max-width: 600px) 45vw, (max-width: 1024px) 30vw, 240px',
'fetchpriority' => $aboveFold ? 'high' : 'auto',
]);
$position++;
Note that getImage() and getProduct() live on Magento\Catalog\Block\Product\AbstractProduct, which the listing block extends – not on Magento\Catalog\Block\Product\Image. Calling them from inside the image template does not fail loudly; DataObject::__call turns getImage() into getData('image') and hands back null, so the page renders with a silently empty srcset.
Verified on 2.4.8-p5, the category grid then emits:
<img class="product-image-photo"
srcset="...-400.jpg 400w, ...-600.jpg 600w"
sizes="(max-width: 600px) 45vw, (max-width: 1024px) 30vw, 240px"
fetchpriority="high"
src="..." loading="lazy" width="240" height="300" alt="Joust Duffle Bag"/>
Which leaves exactly one problem: that trailing loading="lazy". Custom attributes are emitted before it, and the HTML parser keeps the first occurrence of a duplicated attribute and ignores the rest. So adding 'loading' => $aboveFold ? 'eager' : 'lazy' to the array above genuinely works – Chrome reports img.loading === "eager" for the first tile, confirmed on the rendered page.
It works, and I would still not ship it. The markup carries two contradictory loading attributes, and the next person to read it has no way of knowing the duplication is deliberate. Take the thin override instead – one file, one changed line:
// Vendor/theme/Magento_Catalog/templates/product/image_with_borders.phtml
// ...unchanged, except:
loading="<?= $escaper->escapeHtmlAttr($block->getData('loading') ?: 'lazy') ?>"
Pass 'loading' => 'eager' for the first row as above, and every other image keeps core’s behaviour. The width and height still come from the block, so the aspect-ratio box survives and CLS stays where it was.
Prefetch and preload: getting the image discovered
📌 Which third parties are worth a hint. The candidates are the hosts that load on every page and sit early in the critical path. In practice that is three categories, and one code block covers them:
<!-- Consent tool: blocks interaction until it has loaded, so it goes first -->
<link rel="preconnect" href="https://cdn.cookiebot.com">
<!-- Payment SDK: only worth it on checkout, so scope it to that handle -->
<link rel="preconnect" href="https://js.stripe.com">
<!-- Analytics and tracking: lighter hint, and see the consent note below -->
<link rel="dns-prefetch" href="//www.google-analytics.com">
<link rel="dns-prefetch" href="//script.hotjar.com">
Budget guidance:
dns-prefetchresolves the hostname only. Cheap; 5-10 is fine.preconnectopens the TCP and TLS connection too – roughly 50-100 ms of connection cost each, held open speculatively. Three or four at most, reserved for hosts you are certain will be used.
A resource hint can be a data transfer. preconnect opens a real TCP and TLS connection, so the visitor’s IP reaches that third party before the consent banner is answered – the same mechanism that made dynamically loaded Google Fonts the subject of the LG Munchen I ruling in January 2022. A preconnect to your analytics vendor in <head> can quietly undo the consent flow the rest of the stack is careful about. dns-prefetch is milder, since the lookup goes to the visitor’s resolver, but the query still reaches nameservers the vendor often operates.
Hints for necessary hosts (the consent tool, the payment gateway on checkout) stand on the same footing as the scripts they accelerate. Hints for consent-gated vendors should be gated too – and since the full page cache serves one <head> to everyone, that has to happen client-side:
// From the CMP's "statistics accepted" callback, not from <head>
const hint = document.createElement('link');
hint.rel = 'dns-prefetch';
hint.href = '//www.google-analytics.com';
document.head.appendChild(hint);Not legal advice, and the answer varies by jurisdiction and by what your consent notice already says – but worth raising rather than assuming, for a win on the order of 100 ms.
Where these actually go in Magento
Every snippet above is a <head> entry, and Magento builds <head> from layout XML – so the question is which layout file, because the filename is the scope.
| Put it in | Applies to | Use for |
|---|---|---|
Magento_Theme::layout/default_head_blocks.xml (or default.xml) | every page on the site | dns-prefetch / preconnect for tools that genuinely load everywhere |
catalog_category_view.xml | category pages only | hints for the PLP LCP image |
catalog_product_view.xml | product pages only | hints for the gallery image |
checkout_index_index.xml | checkout only | the payment-gateway preconnect above |
DNS prefetch and preconnect need nothing but rel and src, so they are a one-line layout entry. The rel attribute is carried through verbatim — anything that is not src, src_type, content_type, ie_condition or order is passed straight into the rendered tag — so this emits <link rel="dns-prefetch" href="//js.stripe.com" />, confirmed on 2.4.8-p5:
<!-- app/code/Vendor/LcpPreload/view/frontend/layout/checkout_index_index.xml -->
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<head>
<link src="//js.stripe.com" rel="dns-prefetch" src_type="url"/>
</head>
</page>
Preloading the LCP image is the one case where layout XML lets you down, and it does so quietly. The head schema restricts the as attribute to an enumeration:
<!-- vendor/magento/framework/View/Layout/etc/head.xsd -->
<xs:attribute name="as">
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:enumeration value="font" />
<xs:enumeration value="script" />
<xs:enumeration value="style" />
</xs:restriction>
</xs:simpleType>
</xs:attribute>
image is not in that set, and a <link rel="preload"> without as is ignored by the browser. What makes this a trap rather than a plain limitation is when it bites. Layout XSD validation only runs when validation is switched on, and Layout\Merge::_validateMergedLayout() rethrows the failure only in developer mode:
// vendor/magento/framework/View/Model/Layout/Merge.php
if ($this->appState->getMode() === \Magento\Framework\App\State::MODE_DEVELOPER) {
throw $e;
}
So as="image" renders perfectly happily on a production-mode install — verified on 2.4.8-p5, it emits <link rel="preload" as="image" href="..." /> exactly as written — and then throws on the first developer-mode machine or CI job that merges the same layout. Working on production and failing in development is the worst way round for a bug to present.
Use a block instead. You need one anyway: the image URL is per-product and only known at render time, which no static layout entry can express.
A minimal module that emits it
Four files. The block resolves the same view.xml image id the template will render, so the preload and the eventual <img> request the identical URL – if they differ, you have downloaded the image twice.
app/code/Vendor/LcpPreload/
├── registration.php
├── etc/module.xml
├── Block/ProductImagePreload.php
└── view/frontend/
├── layout/catalog_product_view.xml
└── templates/preload.phtml
<!-- view/frontend/layout/catalog_product_view.xml -->
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceBlock name="head.additional">
<block class="Vendor\LcpPreload\Block\ProductImagePreload"
name="lcp.image.preload"
template="Vendor_LcpPreload::preload.phtml"/>
</referenceBlock>
</body>
</page>
// Block/ProductImagePreload.php - returns the same URL the gallery will use
class ProductImagePreload extends \Magento\Catalog\Block\Product\AbstractProduct
{
public function getImageUrl(): ?string
{
$product = $this->getProduct();
if ($product === null) {
return null;
}
return $this->getImage($product, 'product_page_image_medium')->getImageUrl();
}
}
Extending AbstractProduct is deliberate: it supplies both getProduct() and getImage(), so the block needs no constructor of its own. It also keeps Magento\Framework\Registry – @deprecated 102.0.0 – out of your code, while still resolving the product the way core does internally.
<?php /** @var \Vendor\LcpPreload\Block\ProductImagePreload $block */ ?>
<?php if ($url = $block->getImageUrl()): ?>
<link rel="preload" as="image" fetchpriority="high"
href="<?= $escaper->escapeUrl($url) ?>">
<?php endif; ?>
Do not ship CSS with this module. A <css src="..."/> entry added to default_head_blocks.xml – or to any module’s default.xml – loads on every page of the store, checkout and customer account included, whether or not that page has anything to do with images. Magento merges it into the bundled stylesheet, so it will not show up as an extra request in the network tab; it just quietly grows the CSS every page must parse. Given that stylesheets were already 73-77 KiB of the measured pages, a few "small" module stylesheets is how a theme ends up shipping a third of its CSS to visitors who never see the components it styles. If a module genuinely needs CSS, scope it to the layout handle that needs it (catalog_product_view.xml), never the default handle.
4. Preconnect (DNS + TCP + TLS):
<!-- Establish connection early for critical third-party (Google Fonts, etc.) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- More expensive than dns-prefetch but fully opens connection -->
<!-- Use sparingly (max 3–4 preconnect tags) -->
Real-world example (product page):
<head>
<!-- Critical resources (preload) -->
<link rel="preload" as="image" href="product-hero-1200px.jpg">
<link rel="preload" as="font" href="primary-font.woff2" type="font/woff2" crossorigin>
<link rel="preload" as="style" href="critical-css.css">
<!-- Third-party optimization -->
<link rel="dns-prefetch" href="//cdn.example.com">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Next-page optimization (prefetch) -->
<link rel="prefetch" href="next-product-hero.jpg">
<link rel="prefetch" href="related-products.css">
</head>
Timing impact:
Without preload/prefetch:
0ms HTML starts loading
50ms CSS downloads, renders
100ms Font starts loading
150ms Image starts loading (LCP ~1800ms)
With preload/prefetch/dns-prefetch:
0ms HTML + Image + Font all start in parallel (preload)
50ms CSS renders (critical-css only)
150ms Font ready + Image ready (LCP ~600ms) ← 3x faster!
DNS to CDN already resolved (-100ms on image request)
tradeoff: every template you override is one you carry
Theme overrides are the part of a Magento build that ages. Core’s file changes in a patch release, your copy silently keeps the old version, and you inherit whatever bug the change was fixing. So the question worth asking on each piece above is not "does it work" but "how much core am I now carrying".
Measured against that, the approach here is cheap on purpose:
srcset,sizesandfetchpriorityneed no override at all. They go throughgetImage()‘s attributes argument, which is a supported extension point, not a copied file.loadingcosts exactly one line. That is the only thing core hardcodes past the custom-attribute loop. A one-line diff is trivial to re-apply against a new version; a rewritten template is not.- Only override templates that render an LCP element – the category grid and the product gallery. Everything below the fold is already right: core’s blanket
loading="lazy"is the correct answer there, and touching it is pure downside. - Put sizes in
view.xml, not in markup. Widths declared as data survive a template re-diff. Widths pasted into asrcsetstring do not.
The recurring cost is one small diff per upgrade, against 420 ms of resource load delay on the page that decides the metric. That is worth paying.
working_example: the whole thing, wired together
Every piece has appeared above in isolation. This is the order they go in, and what owns each one – deliberately short, because if you find yourself writing a big generic <picture> block you have probably taken the wrong path.
| # | Step | Where it lives |
|---|---|---|
| 1 | Declare the widths the theme renders | your theme’s etc/view.xml |
| 2 | Convert formats on disk, serve by Accept | the optimizer module and nginx rule from the companion article |
| 3 | Emit srcset from those ids, and eager-load the first row | list.phtml, via getImage()‘s third argument; one-line override of image_with_borders.phtml for loading |
| 4 | Preload the product-page LCP image | a block in head.additional |
| 5 | Confirm the LCP element is what you think | the observer snippet in verification, below |
Note what is not in the list: a <picture> element. It is the reflex answer for multi-format delivery and it is the wrong one here, because content negotiation already picks the format at the server. Adding <source type="image/avif"> on top duplicates in markup what nginx does from one Vary: Accept header, and doubles the number of URLs every template has to build. Use <picture> when you need art direction – a different crop on mobile, not merely a different format or size.
Keeping CLS at zero while you do it
Always specify width/height (or aspect ratio):
<!-- WRONG: No dimensions, layout shift when image loads -->
<img src="product.jpg" alt="Product">
<!-- RIGHT: Dimensions reserved -->
<img src="product.jpg" alt="Product" width="600" height="450">
<!-- CSS aspect ratio (modern browsers) -->
<img src="product.jpg" alt="Product" style="aspect-ratio: 4/3;">
<!-- Container aspect ratio (safest) -->
<div style="aspect-ratio: 4/3; overflow: hidden;">
<img src="product.jpg" alt="Product" style="width: 100%; height: 100%; object-fit: cover;">
</div>
Magento already does this for catalog images – width and height come from the view.xml entry, which is why the measured pages scored CLS 0 before any of this work. The risk is undoing it: an override that stops emitting those attributes, or CMS and page-builder content where no view.xml entry applies, is where image-driven layout shift actually comes from.
verification: did the markup actually do anything?
Three checks, and the order matters – each one rules out a different failure.
# 1. Is the LCP image still being lazy-loaded?
# Re-run Lighthouse and open "LCP request discovery". Both boxes should
# now be ticked: not lazy, and fetchpriority applied.
# 2. Is the browser picking a sensible candidate from srcset?
# DevTools > Network > Img, throttled to a mobile viewport. Compare the
# downloaded width against the CSS box. Anything more than roughly 2x is
# a sizes attribute that does not match the layout.
// 3. Is the LCP element the one you optimised? Paste in the console.
new PerformanceObserver((list) => {
const e = list.getEntries().at(-1);
console.log('LCP', Math.round(e.startTime), 'ms —', e.element);
}).observe({ type: 'largest-contentful-paint', buffered: true });
Run the third one first, before changing anything. Teams routinely optimise a hero banner for a week before discovering the LCP element was a category description rendering a webfont, and no amount of image work was ever going to move it.
Pass criteria: the LCP image carries neither loading="lazy" nor a late discovery, no image transfers at more than roughly twice its displayed CSS pixel width, every <img> still carries width and height, and CLS has not moved off zero.
Related reading
- Magento LCP Images: Measure First, Then Convert — the measurement half: where the LCP time actually goes, and the format pipeline that needs no templates at all.
- Magento 2 Core Web Vitals: From Fails to Passes — the pillar both of these sit under.
- Magento INP and CLS: Fixing the Two Hard Vitals — reserving space is the other half of keeping CLS at zero.
- Magento Font Optimization: Self-Hosted, Non-Blocking — the same preload argument, for the other resource that blocks first paint.
- Magento CSP Header Size — what happens to your policy as third-party hosts multiply.
Sources & References
- MDN: responsive images —
srcset,sizesand when<picture>is the right tool. - MDN:
rel=preconnectandrel=dns-prefetch— what each hint does on the wire, which is what the consent question turns on. - web.dev: optimize LCP — the four subparts, and which techniques move which one.
- Magento 2.4.8-p5 source:
View/Layout/etc/head.xsd,View/Model/Layout/Merge.php,module-catalog/view/frontend/templates/product/image_with_borders.phtml.
Disclaimer
Every Magento behaviour described here was verified against a stock 2.4.8-p5 install with sample data in production mode, in August 2026. Core templates and schemas change between patch releases: re-check image_with_borders.phtml and head.xsd against your own version before assuming any of it still holds. Timings are from a local container and are useful as ratios, not as absolute numbers.