-
Notifications
You must be signed in to change notification settings - Fork 101
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Preload image URLs for elements with external background images #1697
base: trunk
Are you sure you want to change the base?
Conversation
@felixarntz This is getting close! I'm excited about this one since it unlocks a very common use case which we've seen especially among page builders. This unlocks potential optimization of the |
I've just updated the description with findings on how this impacts Elementor. In short, I'm seeing a ~20% improvement in LCP on both desktop and mobile! |
…n validate background-image URL
c872c1b
to
f2959cd
Compare
// This needs to be captured early in case the user later resizes the window. | ||
const viewport = { | ||
width: win.innerWidth, | ||
height: win.innerHeight, | ||
}; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added this here to capture the initial width/height when the page loads. Later when about to submit the metrics for storage, it checks to see if the viewport width or height has changed, and then it aborts. This is important because if the viewport window size changes during the life of the page, all bets are off as to what is the expected LCP element.
|
||
/** | ||
* Gets the script to lazy-load videos. | ||
* | ||
* Load a video and its poster image when it approaches the viewport using an IntersectionObserver. | ||
* | ||
* Handles 'autoplay' and 'preload' attributes accordingly. | ||
* | ||
* @since 0.2.0 | ||
*/ | ||
function image_prioritizer_get_lazy_load_script(): string { | ||
$script = file_get_contents( __DIR__ . sprintf( '/lazy-load%s.js', wp_scripts_get_suffix() ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request. | ||
|
||
if ( false === $script ) { | ||
return ''; | ||
} | ||
|
||
return $script; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function was just moved to helper.php
.
}; | ||
|
||
export type InitializeCallback = ( args: InitializeArgs ) => void; | ||
export type InitializeCallback = ( args: InitializeArgs ) => Promise< void >; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is now consistent with the FinalizeCallback
.
|
||
/** | ||
* Gets the script to lazy-load videos. | ||
* | ||
* Load a video and its poster image when it approaches the viewport using an IntersectionObserver. | ||
* | ||
* Handles 'autoplay' and 'preload' attributes accordingly. | ||
* | ||
* @since 0.2.0 | ||
*/ | ||
function image_prioritizer_get_lazy_load_script(): string { | ||
$script = file_get_contents( __DIR__ . sprintf( '/lazy-load%s.js', wp_scripts_get_suffix() ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request. | ||
|
||
if ( false === $script ) { | ||
return ''; | ||
} | ||
|
||
return $script; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function was just moved from hooks.php
.
extensionInitializePromises.push( | ||
extension.initialize( { | ||
isDebug, | ||
webVitalsLibrarySrc, | ||
} ) | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This needs to account for an extension (erroneously) not implementing initialize
as an async function, in which case it will not return a promise. It should check for a Promise
return value.
extension.finalize( { | ||
isDebug, | ||
getRootData, | ||
getElementData, | ||
extendElementData, | ||
extendRootData, | ||
} ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This needs to account for an extension (erroneously) not implementing finalize
as an async function, in which case it will not return a promise. It should check for a Promise
return value.
Fixes #1584
This captures the URL for the background image of the LCP element which is defined in a CSS stylesheet and not in an inline
style
attribute. A new client-side extension module from Image Prioritizer is introduced to implement this. A new root property is added to the URL Schema calledlcpElementExternalBackgroundImage
which includes not only theurl
of the background image but also thetag
name,id
,class
for the LCP element that has the background image. When theImage_Prioritizer_Background_Image_Styled_Tag_Visitor
iterates over tags in the document, it checks to see if there is a matching tag (and thus the original element with the background image is still present). If so, and if all URL Metrics in the group are in agreement on that being the LCP element with that same background image, then the URL for the background image is added as afetchpriority=high
preload link for that viewport group. The requirement that all gathered URL Metrics be in agreement helps ensure that if a randomized background image is used (e.g. as can be done in core's header image), then no preloading will be done (as it is likely the wrong image will be preloaded).Twenty Thirteen Example
Given the Twenty Thirteen theme which has a CSS background image in the header and some images in the post content, on desktop the header is the LCP element whereas on mobile the first image is the LCP element:
With the changes in this PR, the CSS background image gets a
fetchpriority=high
preload link for desktop, whereas the first image in the content gets a preload link for mobile:The impact of preloading the background image on mobile is reflected in the network log, where without the optimization the
circle.png
image is loaded with an initial low priority long after the other assets on the page have started loading:In contrast to when the image is preloaded, it is the initial resource loaded and has an initial high priority:
Here are the before/after metrics testing with
benchmark-web-vitals
(with GoogleChromeLabs/wpp-research#164) without any page caching:Test setup
I created a site in Local and set the theme to Twenty Thirteen. I then created a post which had a 3-columns block with the center column being wider. I put an image in each column, so the middle image was larger. I visited the URL in a desktop viewport and mobile viewport multiple times to ensure the URL Metrics were fully populated. I then created a text file called
preload-bgimage-urls.txt
which contained:I then ran 100 iterations before/after on desktop and mobile:
So on desktop, the LCP-TTFB is improved by 8.8%. On mobile, the LCP-TTFB is improved by a lesser degree by 0.87% because WordPress was already adding
fetchpriority=high
to the first image, so the marginal improvement is gained by the preload link.Elementor Example
As another test, I created a site with Elementor and the Hello Elementor theme. I used the Ceramic Studio "website kit" to create a page, and I added a mobile-specific image to the hero's second container:
Elementor implements the images here as background images pulled from an external CSS file (
http://localhost:10053/wp-content/uploads/elementor/css/post-67.css?ver=1732401999
):The element being targeted is:
Running the same tests as before with 100 iterations on both desktop and mobile, before and after the optimizations, yields the following results (again where mobile is 360x800 and desktop is 1920x1080):
The LCP-TTFB on desktop is improved by 18.19% and on mobile it is improved by 21.57%! 🎉 (What is surprising to me as well is that TTFB is reduced when the optimizations are applied, which doesn't make any sense since the HTML Tag Processor spends cycles doing work.)
It's important to note that this page doesn't just have CSS background images. Further down the page outside the viewport of both desktop and mobile, there are three
IMG
tags in another section:Elementor is adding
fetchpriority=high
to thisIMG
even though it is not even displayed in any initial viewport:The Elementor code responsible is the
maybe_add_fetchpriority_high_attr()
method which appears to be heavily inspired by what WordPress core does.Here's a diff of the page (with Prettier formatting). Note how Image Prioritizer is adding responsive preload links for the two different background images while at the same time removing
fetchpriority=high
from theIMG
that Elementor added it to. In addition, Image Prioritizer is addingloading=lazy
andsizes=auto
to all of these images since none of them appear in the initial viewport on desktop or mobile:Network log without the optimizations up until the LCP element's background image is loaded:
Compared with after the optimizations applied:
Note how the LCP element's background image is now loaded as early as possible with initial
high
priority, whereas without the optimizations the image is loaded very late and has an initial priority oflow
.Takeaway
This represents an critical performance advancement for optimizing LCP in WordPress because on the web a
DIV
is the second most common LCP element afterIMG
. Since images account for the LCP type 82% desktop and and 72% on mobile, it's likely that most of theDIV
LCP elements represent background images. Additionally, page builders like Elementor and Divi leverage external background images extensively, including separate background images for desktop and mobile.Todo