← Insights

Shopify hero image size: what you upload, and what the browser gets

The hero is the LCP element on most themes. Why the upload and the download are different sizes, what decides when the request starts, and a ten-minute check.

10 min read

Contents — 6 sections

The hero is the largest thing on the first screen, so it is the clock

Largest contentful paint is the time at which the largest image, text block or video in the viewport finishes rendering, counted from the moment the reader asked for the page. Google's threshold for a good LCP is 2.5 seconds or less at the 75th percentile of page loads, per web.dev, and the same article lists what qualifies: img elements, video posters, and elements with a background image loaded through url(). On a Shopify homepage the first section's image is the largest thing in the viewport by a wide margin. On a product page it is the first gallery image. Shopify's own performance guide says it in one line: images are typically the LCP element.

So when the hero image arrives is when the page is judged to have loaded. Everything else on the first screen is text, and text renders in the time it takes the font to arrive.

The question "what size should my Shopify hero image be" has two answers that get confused for one. The first is the file you upload: its pixel dimensions and its shape. The second is the file the browser downloads, which is a different file with a different size, produced on the fly by Shopify's CDN according to instructions in the theme. Most advice on this topic gives an upload size and stops. The upload is the smaller half of the problem.

What you upload sets the ceiling and the shape, not the download

A theme does not serve the image you uploaded. It asks the CDN for a resized copy through the image_url filter, and the CDN makes one at the width the theme names. The filter reference sets the limits: the width or height parameter goes up to 5,760 pixels, and regardless of what is asked for, an image is never resized larger than its original dimensions. That second rule is the one that matters for the upload. It sets a ceiling. A hero uploaded at 1,200 pixels wide will be served at 1,200 pixels wide to a 1,440-pixel desktop with a two-times display, which is 2,880 device pixels of screen, and it will look soft on exactly the screens your photographer's work was meant for.

So the upload needs to be at least as wide as the widest candidate the theme will ask for. In Dawn, Shopify's reference theme, the image banner section requests image_url: width: 3840 and lists candidate widths of 375, 550, 750, 1100, 1500, 1780, 2000, 3000 and 3840. The top of that list is a 1,920-pixel desktop at two times. Upload 3,840 pixels wide for a full-width hero and the CDN has everything it needs; upload wider and the extra pixels are stored and never sent. Themes that are not Dawn use their own list, and the way to read yours is to open the rendered page, inspect the hero img and read the srcset attribute: the numbers ending in w are the widths the theme asks for.

The shape is the other thing the upload decides. The image_tag filter writes width and height attributes from the image's own dimensions, per its reference, and the browser uses that ratio to reserve the space before a byte of the image arrives. That reservation is what keeps the hero from pushing the page down when it lands — Shopify's layout shift note walks the six steps that happen without it — and Google's threshold for cumulative layout shift is 0.1, per web.dev. The ratio also decides what the hero looks like on a phone. A 16:9 landscape image at 390 pixels wide is 219 pixels tall, which is a strip, not a hero, and a theme that fixes the section height and crops into it will crop the subject out. A 4:5 portrait at the same width is 488 pixels tall. If the desktop and mobile crops need to differ, the fix is a picture element with a mobile source, so the phone downloads one image and not both; Shopify documents the pattern and the mistake it replaces, which is two img tags toggled with display: none, where the browser cannot know one is hidden and downloads both.

The format you upload matters less than people expect. The CDN detects which formats the browser accepts and picks one for size and quality — the image_url reference names WebP and AVIF — so a JPEG upload is served as WebP or AVIF to a browser that takes them. WebP alone runs 25 to 35 percent smaller than the JPEG it replaces, per web.dev. Upload a photograph as a JPEG at a high quality setting, and upload a PNG only when the image needs transparency. A photograph stored as a PNG is the two-megabyte upload that starts most of the conversations we have about speed.

The bytes the browser downloads are decided by srcset, sizes and the display

Given the candidates, the browser picks one. The rule is in Shopify's responsive images guide: read the sizes attribute to learn how wide the image will be laid out, multiply by the display's pixel density, and download the smallest candidate that covers it. For a full-width hero with sizes="100vw" on a 390-pixel phone at three times, that is 1,170 device pixels, and from Dawn's list the browser takes the 1,500 candidate. The same phone at two times needs 780 and takes 1,100. A 1,440-pixel desktop at two times needs 2,880 and takes 3,000.

That is what the phone downloads, and it is why the answer to "how big should the hero be" is not one number. It is the 1,500-pixel candidate for most phones and the 3,000 for most laptops, in a format the CDN chose, at a quality the CDN chose. The theme's sizes attribute is the lever, and it is where themes go wrong. If sizes says 100vw and the hero is laid out at half the viewport, every device downloads a candidate twice as wide as it needs. If sizes is missing, the browser assumes the image is the full viewport wide; Shopify's guide puts the consequence plainly, that the browser defaults to the largest image and mobile pays for it. And Shopify's guidance on the list itself is four to six widths, because every extra candidate is another file the CDN has to have cached at the edge.

What that candidate weighs is the number most people mean when they ask about size, and there is no published threshold for it, so here is ours. The candidate a phone receives should be under 200 KB. The finding we write down most often on a teardown is the other end of that: a hero exported from a design file as a PNG at two megabytes. Re-exported as a JPEG photograph at the same visual quality, the same image lands under 200 KB once the CDN has converted it, and the re-export is a ten-minute change. Lighthouse's own bar is lower still: its responsive images audit flags any image whose rendered size, pixel density included, is at least 4 KiB smaller than the file it received, and its image delivery insight uses the same 4 KiB floor for compression and format savings.

Then the finding that changes what to fix first. The optimise LCP guide splits LCP into four parts — time to first byte, resource load delay, resource load duration and element render delay — and gives the shape of a well-built page: around 40 percent in the first byte, around 40 percent in the download, and under 10 percent in each of the two delays. Then it says something most image advice never mentions: Google's research shows the download portion tends not to be the bottleneck for most sites. The bytes are the part everyone works on. The delays are the part that is usually costing the time.

When the request starts matters more than how big the file is

Resource load delay is the gap between the HTML arriving and the browser asking for the hero. On a well-built page it is close to zero, because the browser's preload scanner finds the src and srcset while it is still parsing the document and sends the request before any stylesheet or script has run. Three things in Shopify themes break that.

Lazy loading the hero. loading="lazy" tells the browser to wait until layout has confirmed the image is near the viewport, which is after the stylesheets have loaded and the page has been laid out. web.dev's lazy-loading guidance is direct: never lazy-load images likely to be in the viewport when the page loads, especially LCP images. The study behind that advice, on HTTP Archive data with real-user LCP from the Chrome User Experience Report, found the median page without browser-level lazy loading had a 75th-percentile LCP of 2,922 milliseconds against 3,546 for the median page with it, and the authors are careful to call it correlational. Shopify's rule adds the Shopify-specific detail: image_tag sets loading="eager" for sections one to three and lazy from section four on, and eager whenever section.index is nil, which it is in the theme editor. A theme that overrides that with its own section.index <= 3 test lazy-loads the hero in exactly the contexts the default protects, because a comparison against nil is false in Liquid. The worse version is a JavaScript lazy loader that hides the real URL in data-src until a script runs; the scanner sees nothing to fetch.

A CSS background image. The scanner does not read stylesheets. A hero set with background-image is discovered after the HTML is parsed, the stylesheet is found, downloaded and parsed, and only then does the request go out — Shopify lists the five steps and the rule that follows, which is an img tag positioned to fill the section instead. web.dev's LCP article counts a url() background as a valid LCP element, so the metric still measures it; the browser just starts late.

No priority hint. Even when the hero is in the HTML, the browser starts every image at low priority and only raises the ones in the viewport once layout has run. web.dev's fetch priority article describes the mechanism and the partial fix in Chrome 117, where the first five large images start at medium; fetchpriority="high" on the hero starts it at high from the first byte of HTML. It goes on one image per page. Shopify's note says the same and adds the caution that more than one hint interferes with the browser's own ordering. Dawn sets it only when section.index == 1: put another section above the banner and the hint moves with the index, not with the image.

Preloading is the fourth thing, and it is usually the wrong one. image_tag: preload: true sends a Link header with rel=preload, imagesrcset and imagesizes, which reaches the browser before the body does and, through Shopify's Early Hints support, sometimes before the HTML finishes rendering; the image filter guide shows the full first-section pattern with it switched on. But Shopify sends at most ten preload headers per response, fills them first with the render-blocking scripts and stylesheets it finds in the head, and image preloads sort last, so the preload guidance warns that on a head with ten render-blocking resources the hero preload is the first thing dropped. Preload discovers what the scanner would otherwise miss; fetchpriority raises what the scanner already found. An img in the HTML with the priority hint needs the second and rarely the first.

Element render delay is the other delay, and it has one common cause on Shopify: a fade-in. Chrome excludes an element at opacity: 0 from LCP, so an image that has fully downloaded but is waiting for a reveal class or a scroll-triggered animation does not count until the animation lets it. Shopify's page on it puts hero reveal animations alone at 650 to 900 milliseconds of LCP, and names the two-minute fix: Online Store, Themes, Customize, Theme settings, Animations, and turn off page transitions and reveal-on-scroll.

Put together, the hero on the first section of a template looks like this, which is the shape Shopify's own guidance arrives at:

{%- comment -%} sections/hero.liquid — first section in the template {%- endcomment -%}
{%- assign hero_widths = '750, 1100, 1500, 2000, 3000, 3840' -%}
{% unless section.index > 1 %}
  {{ section.settings.image
    | image_url: width: 3840
    | image_tag:
        loading: 'eager',
        fetchpriority: 'high',
        widths: hero_widths,
        sizes: '100vw',
        class: 'hero__image'
  }}
{% else %}
  {{ section.settings.image
    | image_url: width: 3840
    | image_tag:
        loading: 'lazy',
        widths: hero_widths,
        sizes: '100vw',
        class: 'hero__image'
  }}
{% endunless %}

The test is section.index > 1 and not == 1 for the nil reason above: in the editor, in a static section and in a Section Rendering API response the index is nil, and unless nil > 1 falls to the eager branch, which is the safe side. The CSS positions .hero__image to fill the section, with max-width: 100% and height: auto so the reserved ratio holds.

Check it in ten minutes, on the page that takes the traffic

Run PageSpeed Insights on the homepage and on the best-selling product page, mobile first, because on Shopify's own figures mobile is 60 to 75 percent of storefront traffic. Write down LCP. Then open the insights below the score and find LCP request discovery: it names the LCP element and runs three checks — fetchpriority=high is set, the image is discoverable from the HTML, and it is not lazy-loaded. A hero that fails any of the three has a load-delay problem before it has a bytes problem.

Then look at the file itself. Open the page in Chrome, open DevTools, and in the Network panel filter to Img and reload. Turn on the Priority column and, in the panel's settings, Big request rows: the Network reference explains that the Size column then shows transferred bytes above uncompressed, and the Priority column shows the initial priority below the final one. The hero row tells you four things. What it weighed over the wire. Whether it started at High or was raised to it later, which is the fetchpriority question answered by the browser rather than the theme. How far down the waterfall it began, which is the load-delay question. And in the URL, the width= parameter, which is the candidate you were given; compare it with the hero's laid-out width times the display's pixel ratio, and if it is much larger the sizes attribute is wrong.

Right-click the hero and inspect it. Read loading, fetchpriority, srcset, sizes, width and height off the element. A data-src where src should be, or a style with background-image, is the finding.

For the split between delay and download, the Performance panel's live metrics view shows the LCP element and, on hover, the four sub-parts, per the panel reference. Or paste the observer from web.dev's LCP article into the Console before reloading:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('LCP candidate:', Math.round(entry.startTime), 'ms', entry.element);
  }
}).observe({ type: 'largest-contentful-paint', buffered: true });

The last entry logged is the LCP element and its time. If it is not the hero, the hero was hidden, lazy or late, and something smaller won by default.

If the theme sets preload: true, check the document's response headers for a link header carrying the image; if it is absent, count the render-blocking resources in the head, because the ten-slot budget is the usual reason. And one thing that is not on any panel: the same image on a physical phone, over mobile data, because the lab test runs a fixed throttle and the number that decides your ranking comes from real devices.

Fix it in this order, and it takes an afternoon

First, the theme setting: page transitions and reveal animations off. Two minutes, no code, and on a theme that ships with them on it is often the largest single change available.

Second, the upload. If the hero is a PNG photograph or narrower than 1,500 pixels, re-export it as a JPEG at 3,840 wide, in the crop the section actually uses, and replace it in the section's image picker. Ten minutes. If the mobile crop needs to differ, that is the picture pattern and it moves to the third step.

Third, the theme code. The hero image_tag with loading: 'eager', fetchpriority: 'high', a sizes value that matches the layout, four to six widths, and a section.index > 1 test rather than == 1. If the hero is a background image, replace it with the positioned img. If there is a JavaScript lazy loader, move data-src back to src everywhere and add loading="lazy" to the images below the fold instead. An hour or two for a developer who knows the theme; the picture element for a separate mobile crop adds an hour.

Fourth, preload, only if after the third step the hero still starts late in the waterfall, and only after checking the link header actually carries it. Test it on and off.

Fifth, PageSpeed Insights again, both pages, mobile. LCP under 2.5 seconds is the target; the three discovery checks passing and the hero at High from the start are the proof.

This is the first of the three heaviest things in section two of the store audit checklist, and the image line in why your Shopify store is slow; on a product page the same rules apply to the first gallery image, which is also what pushes the add-to-cart button below the fold, as the sticky add to cart post measures. If you would rather have all of it measured, ranked against everything else on the store and written up, that is the Store Teardown: €750, five days, and the document is yours whatever you decide to do with it.

Own your growth.Start with the teardown.

Start with a Teardown. Read it, then decide whether you want us to fix what it finds.