LCP and low-entropy images

As of Chrome 112, the Largest Contentful Paint (#LCP) ignores low-entropy images.

First and foremost, what is image entropy? Simply put, image entropy measures the amount of information or disorder in an image. In other words, it’s a way to measure how much randomness or information is in a picture by looking at how different its pixels are. Low entropy means that an image has many patterns, is predictable or looks similar in many places, and is not very complex.

Calculating LCP for low-entropy images does not make sense as usually it contains very little, meaningful information.

By the way, if you’re after the scientific explanation of entropy, here you go: H = -Σ (p_i * log2(p_i)), where H is the entropy, Σ represents the sum over all possible pixel values (i) and p_i is the probability of a pixel having the value i in the image.

You’re probably curious - is there a way for you to determine if an image has low entropy? You can do a bits per pixel (bpp) calculation using JavaScript; the following snippet can be executed from the browser’s console:

console.table(
[...document.images]
.filter(
(img) => img.currentSrc != '' && !img.currentSrc.includes('data:image')
)
.map((img) => [
img.currentSrc,
(performance.getEntriesByName(img.currentSrc)[0]?.encodedBodySize * 8) /
(img.width * img.height),
])
.filter((img) => img[1] !== 0)
);

Kudos to Joan León's webperf snippets

You'll see the results immediately if you run this against any site. However, if you want to add this script to your application, you’ll quickly realise that the response will be just an empty list. Why is that?

Well, it turns out that document.images returns a collection of images in the current HTML document. If you’re in a situation where the images get loaded dynamically, then the above code will not work, and you need to extend it quite a bit using the MutationObserver:

function observeAndConvertImages(callback) {
const divImagesMap = new Map();

const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
for (const addedNode of mutation.addedNodes) {
if (addedNode instanceof HTMLDivElement) {
const divImages = addedNode.getElementsByTagName('img');
if (divImages.length > 0) {
divImagesMap.set(addedNode, Array.from(divImages));
for (const image of divImages) {
if (!image.complete) {
image.addEventListener('load', () => {
if (allImagesLoaded()) {
callback(getAllImages());
}
});
}
}
}
}
}
}
}
});

observer.observe(document, { childList: true, subtree: true });

function allImagesLoaded() {
for (const images of divImagesMap.values()) {
for (const image of images) {
if (!image.complete) {
return false;
}
}
}
return true;
}

function getAllImages() {
const allImages = [];
for (const images of divImagesMap.values()) {
allImages.push(...images);
}
return allImages;
}
}

observeAndConvertImages((allImages) => {
console.table(
[...allImages]
.filter(
(img) => img.currentSrc != '' && !img.currentSrc.includes('data:image')
)
.map((img) => [
img.currentSrc,
(performance.getEntriesByName(img.currentSrc)[0]?.encodedBodySize * 8) /
(img.width * img.height),
])
.filter((img) => img[1] !== 0)
);
});

This code will now work both in the console and within your JS files, even if you load the images dynamically.

I should also note that loading images dynamically (e.g. via the Fetch API) is a big red flag, especially if the image loaded this way is used for the LCP calculation.