Investigating… a High LCP for Nike.com

The first in a series of posts that investigate problematic performance KPIs in live commercials websites.

Table of Contents

Intro

In a recent Today’s Readings, I spotlighted Speedcurve’s Page Speed Benchmarks dashboard as a great resource for comparing synthetic performance data for leading websites from around the world.

The dashboard allows us to click through synthetic performance data for the top sites across various industries, from various geolocations, and even differing connection speeds. Speedcurve’s introductory article does a very thorough job of explaining how the tests are setup and conducted.

While playing around, I became curious about some of the performance metrics I was seeing…

Notably, when viewing USA > Retail > LCP with a Fast connection, I see that both Nike and Wish record a high LCP for some reason (Costco actually reports the highest LCP of the group, but that is related to a layout shift):

Screenshot of Speedcurve's Benchmark Dashboard
Screenshot of Speedcurve's Benchmark Dashboard

And, being the curious little bugger that I am, I just had to find out if these issues also existed for real users and, if so, what the cause might be, and whether or not it could be improved…

In this article I will be looking at Nike.com.

⇧ Table of Contents

Exploring the Issue

The Speedcurve dashboard reported an LCP of 4.36s for Nike, well above the ideal 2.5s:

Screenshot of the Nike synthetic test results, showing an LCP of 4.36s
Screenshot of the Nike synthetic test results, showing an LCP of 4.36s

My own tests used the latest Chrome with browser cache disabled and network speed throttled to Fast 3G; after 10 page loads, they returned an average LCP of 3.34s. Still well above the desired 2.5s threshold.

So what might be causing this? The LCP asset is a large hero image:

Screenshot of the Nike.com homepage, showing a large hero image that fills the entire screen
Screenshot of the Nike.com homepage, showing a large hero image that fills the entire screen

But that alone shouldn’t cause slow speeds.

Checking the DevTools Network panel, I see the image is being served as an AVIF (good) and weighs only 40.3kb (very good), so I don’t think that’s the issue:

Screenshot of the DevTools > Network panel, showing the hero image statistics
Screenshot of the DevTools > Network panel, showing the hero image statistics

(But take note of the value in the “Initiator” column, we’ll come back to that in a little bit.)

Inspecting the hero image, I see that it is not being served as a responsive image (based on the data attributes, it looks like JS handles that, more on that later too):

Screenshot of the hero image HTML
Screenshot of the hero image HTML

The combination of react-dom.production as the Initiator and the data-landscape-url and data-portrait-url attributes made me wonder how the page loaded with JS disabled, meaning, what the browser initially receives. And here is what it gets… Nothing:

Screenshot of the Nike.com homepage, showing no hero image loads if JS is disabled
Screenshot of the Nike.com homepage, showing no hero image loads if JS is disabled

That’s because the raw HTML that the browser initially receives for the hero image has no initial src attribute value:

Screenshot of the hero image HTML, with JS disabled, showing there is no value for the `src` attribute
Screenshot of the hero image HTML, with JS disabled, showing there is no value for the `src` attribute

The issue with this is that, without a src, the browser has nothing to start downloading. Instead, it has to wait for some JS to download, parse, and execute, which might have to wait for other JS that is already queued to do the same thing, then the JS can determine which data URL should be used and apply that to the img element, and only then can the browser start to download the hero image.

Once the URL is applied to the src, the image downloads fairly quickly:

Screenshot from the DevTools > Network panel, showing the hero image download timing
Screenshot from the DevTools > Network panel, showing the hero image download timing

In this specific page load experience, the browser waited 4.07s before even trying to request the download, then had to wait 11.36ms before the request could be sent, and then another 514.48ms for the actual download.

And once the image arrived in the browser, there is CSS that delays its appearance further by initially setting the opacity to 0, then slowly fading it into view:

Screenshot of the CSS for the carousel image, showing an initial `opacity: 0` and 500ms fade-in transition.
Screenshot of the CSS for the carousel image, showing an initial `opacity: 0` and 500ms fade-in transition.

This all adds up to the user not seeing the hero image for probably 3-6s, depending on their connection speed and device power.

So, is there anything that we can do to improve this?

Well, let’s see…

⇧ Table of Contents

Improving the Issue

A couple of issues jump out at me, some more problematic than others…

  • The biggest issue for me is that the hero img element has no initial src, and relies on JS to get one (that’s the Initiator value that I mentioned earlier, in this case React). This causes a huge delay.
  • Also, there are numerous CSS, JS and other images in the DOM before the hero image, so even if the above was fixed, the hero image still would not be requested until all of those other files were requested.
  • Another related issue is that the hero component uses duplicate HTML: there is one entire carousel for the “portrait” images, and a complete duplicate carousel for the “landscape” images (although all of the images in both carousels have both sets of data attributes). Additional JS is used to determine which should be visible, and to switch back and forth, as necessary. This also requires JS monitoring and activity, which is an added tax on the user’s browser.
  • Next, browsers natively download images with a low priority, and that includes this hero image. But we can “request” a higher priority for important images, using fetchpriority. This might, or might not, be okay, depending on what else the page has to “do”. But the hero image, which will become the LCP, might benefit from a higher priority to encourage a sooner download.
  • Finally, that initial opacity: 0 and slow fade-in for the carousel images delays the LCP even further. I understand the desired aesthetic, so that would be another consideration.

So how do we tackle these issues?

Well, the first thing I would do is remove the duplicate HTML, leaving only a single carousel, and use a single picture element for each slide, each with separate source elements for each image size, using media attributes to determine which image is served on which screen size.

That would convert something like this:

<!-- First the portrait carousel slide... -->
<div orientation="portrait" ...>
  ...
    <img data-qa="image-media-img" alt="Nike. Just Do It" class="_32IPZERI _3jm9Bm_E" src="" data-landscape-url="https://static.nike.com/a/images/f_auto,cs_srgb/w_1920,c_limit/e53977c9-d957-4758-89a9-f37e8f4565ce/nike-just-do-it.png" data-portrait-url="https://static.nike.com/a/images/f_auto,cs_srgb/w_1536,c_limit/aad691a6-4b66-4f88-9955-752a23c1f31f/nike-just-do-it.png" data-image-loaded-class="guL_1FMX"/>
  ...
</div>
<!-- Then the landscape carousel slide... -->
<div orientation="landscape" ...>
  ...
    <img data-qa="image-media-img" alt="Nike. Just Do It" class="_32IPZERI _3jm9Bm_E" src="" data-landscape-url="https://static.nike.com/a/images/f_auto,cs_srgb/w_1920,c_limit/e53977c9-d957-4758-89a9-f37e8f4565ce/nike-just-do-it.png" data-portrait-url="https://static.nike.com/a/images/f_auto,cs_srgb/w_1536,c_limit/aad691a6-4b66-4f88-9955-752a23c1f31f/nike-just-do-it.png" data-image-loaded-class="guL_1FMX"/>
  ...
</div>

Into something like this:

<picture>
    <source width="828" height="1240" media="(max-width: 599px)" srcset="https://static.nike.com/a/images/f_auto,cs_srgb/w_1536,c_limit/aad691a6-4b66-4f88-9955-752a23c1f31f/nike-just-do-it.png">
    <source width="1920" height="933" media="(min-width: 600px)" srcset="https://static.nike.com/a/images/f_auto,cs_srgb/w_1920,c_limit/e53977c9-d957-4758-89a9-f37e8f4565ce/nike-just-do-it.png">
    <img src alt="Nike. Just Do It" class=""/>
</picture>

This not only eliminates the duplicate HTML, which reduces the overall document size, but also the need for JS to toggle the two carousels back-and-forth. This also simplifies the DOM, leaving less for the browser to maintain in memory and repaint with layout shifts.

This further lets the browser decide which image size to display, freeing JS from that task as well, which lets the browser just show the correct image immediately, rather than wait for JS to get setup and make that decision.

By also removing the fade-in CSS, we get the hero image in front of the user much sooner.

After making just those changes, you can already see a vast improvement below. On the left is the current Nike.com home page; on the right is that same page, with the above changes:

Comparison video of the current Nike.com home page hero carousel versus a version with the duplicate HTML merged into a single `picture` element and the fade-in CSS removed.

The updated page renders the LCP in 2.62s instead of the live page’s 4.92s; that’s nearly half the time!

But 2.62s is still above the “green” Core Web Vital threshold, so, can we do more?

I mentioned above that the browser still cannot request that LCP image until it sees it in the DOM, which currently only happens after numerous requests for CSS, JS and other image files.

So I also added a preload link in the head, and added a fetchpriority="high" to that preload link, like this:

<link rel="preload" as="image" type="image/png" fetchpriority="high" media="(max-width: 599px)" href="https://static.nike.com/a/images/f_auto,cs_srgb/w_1536,c_limit/aad691a6-4b66-4f88-9955-752a23c1f31f/nike-just-do-it.png">
<link rel="preload" as="image" type="image/png" fetchpriority="high" media="(min-width: 600px)" href="https://static.nike.com/a/images/f_auto,cs_srgb/w_1920,c_limit/e53977c9-d957-4758-89a9-f37e8f4565ce/nike-just-do-it.png">

(Note that you need a separate preload link for each image size, and each needs a media attribute/value that matches those on the picture‘s source elements.)

With this addition to the HTML, we now have the following:

Comparison video of the current Nike.com home page hero carousel versus a version with the duplicate HTML merged into a single `picture` element, the fade-in CSS removed, and `preload` links added with `fetchpriority=”high”`.

And look at that! The LCP now loads at 1.36s, more than one full second lower than the desired CWV recommendation, and roughly 3.6s faster than the current live page!

Now, these are not small changes to make to an existing template that is already tested and functioning as desired, so only the good folks at Nike.com could decide if these improvements are doable and worth the squeeze. But a 3.6s LCP improvement seems like it might be worth a shot, no? :-)

⇧ Table of Contents

Summary

There are very, very few “right” and “wrong” ways to do things in web development. Most things have arguably “better” and “worse” ways of doing things, but most often, each way has pros and cons, and those have to be weighed against one another, and should ideally be tested, to confirm what is the best way to do something for that specific situation.

So, while this article provides what I think is an improvement to the user’s experience, I have absolutely no idea what is happening within the walls of Nike.com, so therefore I cannot say “this is how it should be done”.

I am merely highlighting options that could improve an aspect of the page load. Whether or not they can, or should, be implemented within Nike.com’s process, is something for that team to look into and decide.

Again, the point of this article was to a) satisfy my curiosity, and b) demonstrate how someone might investigate, and try to improve upon, a performance issue. And I think I have done that. Thanks for joining me!

⇧ Table of Contents

Resources

⇧ Table of Contents

Happy optimizing,
Atg

2 Responses to Investigating… a High LCP for Nike.com

  1. Luke says:

    I’ve run into this kind of scenario many times but I’ve not seen it laid out so simply and it’s amazing how such (relatively!!) small changes can make such a big impact in page load

    • Thanks, @Luke!
      Yes, the preload link, with added fetchpriority, really gives a great headstart…
      If I was working for Nike, I would try a series of tests, one for each change, to measure level of effort vs. benefit.
      I guess I kind of did by testing updating the HTML before adding the preload, but kinda curious how just the preload without the changed HTML/CSS would do…

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

To respond on your own website, enter the URL of your response which should contain a link to this post's permalink URL. Your response will then appear (possibly after moderation) on this page. Want to update or remove your response? Update or delete your post and re-enter your post's URL again. (Find out more about Webmentions.)