The Core Web Vitals are a set of user experience metrics that Google uses as part of it's search result rankings. But how easy is it to game them?
Setting up a test page
I created a test page where the largest element is a 5 MB image. We get a high Largest Contentful Paint (LCP) value, as the image takes a while to download and render.
When the image appears it pushes down the image attribution below it. This layout shift increases the Cumulative Layout Shift (CLS) metric.
Let's see how we can solve these issues.
Largest Contentful Paint
Let's review what LCP measures:
The Largest Contentful Paint (LCP) metric reports the render time of the largest image or text block visible within the viewport, relative to when the page first started loading.
The LCP element on the test page is the img
tag with the 5 MB picture. Can we convince the browser the element isn't actually visible?
If we set the image opacity to 0 and only fade the image in once it's been downloaded then the LCP will only update when the animation is complete.
To prevent the LCP from updating we set the animation duration to 1 day, or 86400 seconds.
<img
src="house.jpg"
style="width: 100%; opacity: 0;"
onload="this.style.animation = 'fadein 86400s forwards'"
/>
Our fadein animation then looks like this, instantly showing the image.
<style>
@keyframes fadein {
from {
opacity: 0;
}
0.000001% {
opacity: 1;
}
to {
opacity: 1;
}
}
</style>
The slow image is now no longer the LCP element. Instead, the LCP value is determined by the h1
tag that appears as soon as the page starts to render.
An alternative LCP trick
DevisedLabs demonstrates an alternative LCP hack using a very large image.
They insert an image overlay containing a transparent SVG at the top of the body tag. This image renders right away, and is the largest page element.
The pointer-events: none
CSS style ensures users can still interact with the underlying page.
Cumulative Layout Shift
The slow LCP metric is fixed now, but we still need to fix the layout shift that occurs when the image pushes down the image attribution.
A layout shift occurs any time a visible element changes its position from one rendered frame to the next.
Again we can use the opacity animation to make the p
tag "invisible":
setTimeout(() => {
const style = document.createElement("style");
style.innerHTML = `
*:not(img) {opacity: 0; animation: fadein 86400s forwards}
`;
document.documentElement.appendChild(style);
}, 200);
- we exclude the
img
from the CSS selector as the element still needs to be invisible when the image download finishes - we use
setTimeout
to delay adding the style tag as otherwise no LCP value would be recorded at all
Unfortunately showing and hiding the content causes a flicker. We can fix this by making the content nearly invisible from the start (but not totally invisible as that would prevent a contentful paint).
<style>
* {
opacity: 0.01;
}
</style>
Problem solved!
Alternative approach
Another way to prevent layout shifts is replacing the DOM element that gets pushed around with a new element containing identical HTML code. For example, you can overwrite the body HTML to regenerate the DOM nodes:
document.body.innerHTML = document.body.innerHTML;
You'd need to do this just before the image renders – running this code in the onload listener is too late. But that can be worked around by cloning the img
tag, removing the src
attribute from the original, waiting for the cloned tag to download the image, and then restoring the src
attribute and regenerating the DOM.
The downside of this approach is that interactive parts of the replaced content can break, as the new DOM nodes won't have the same event listeners as before.