Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 0 additions & 98 deletions lib/dom/performance/modernImageFormats.js

This file was deleted.

24 changes: 24 additions & 0 deletions lib/dom/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,30 @@ const util = {
? entries[0].transferSize
: 0;
},

/**
* Get the Content-Type the server really answered with, via
* PerformanceResourceTiming.contentType. The only in-page signal for what
* was delivered when Accept negotiation answers a .jpg URL with webp.
*
* Empty string when we cannot know: browsers without the field,
* unrecognized MIME types, and cross-origin responses without
* Timing-Allow-Origin. Callers must read '' as "no information",
* never as "not a modern format".
* @memberof util
* @param {String} url The absolute URL of the asset.
* @returns {String} the served content type, or an empty string.
**/
getResourceContentType: function (url) {
const entries = globalThis.performance.getEntriesByName(url, 'resource');
for (const entry of entries) {
if (typeof entry.contentType === 'string' && entry.contentType !== '') {
return entry.contentType.toLowerCase();
}
}
return '';
},

ms(ms) {
return ms < 1000 ? ms + ' ms' : Number(ms / 1000).toFixed(3) + ' s';
}
Expand Down
98 changes: 98 additions & 0 deletions lib/har/performance/modernImageFormats.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import * as util from '../util.js';

// AVIF and WebP routinely deliver 25-50% smaller files than JPEG and PNG at
// the same perceived quality. This lives on the HAR side rather than in a DOM
// rule because only the HAR knows what was actually delivered: the response
// Content-Type is recorded for every asset whatever its origin, while a page
// script can read PerformanceResourceTiming.contentType for same-origin
// responses only. A URL extension is not the format either - an image
// pipeline negotiating on Accept answers a .jpg URL with image/webp, and
// judging that by the URL reports a site as broken while it is doing the
// right thing.
const LEGACY_CONTENT_TYPES = new Set([
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/bmp'
]);

// Below this there is nothing to win: a re-encode saves a few bytes at best,
// and AVIF / WebP container overhead can make a tiny GIF bigger. It is also
// where tracking pixels live - a 42 byte beacon is an image only by content
// type, and "convert your tracking pixel to WebP" is not advice worth giving.
const MIN_SIZE_BYTES = 1024;

function getSize(asset) {
// transferSize is -1 in HARs that do not record it (DevTools exports),
// so fall back to the decoded body size before giving up.
for (const size of [asset.contentSize, asset.transferSize]) {
if (typeof size === 'number' && size > 0) {
return size;
}
}
return 0;
}

function getContentType(asset) {
const headers = (asset.headers && asset.headers.response) || {};
const value = headers['content-type'] && headers['content-type'][0];
// Strip parameters and normalise: 'Image/JPEG; charset=x' -> 'image/jpeg'.
return value ? value.split(';')[0].trim().toLowerCase() : '';
}

export default {
id: 'modernImageFormats',
title: 'Serve images in modern formats (AVIF, WebP)',
description:
'AVIF and WebP routinely deliver 25-50% smaller files than JPEG and PNG at the same perceived quality, and every browser version still under support understands at least one of them. Ship modern formats through a <picture> element with <source type="image/avif"> / "image/webp" entries, from a content-negotiating image pipeline that answers with AVIF / WebP when the client accepts it, or straight from modern URLs. This checks the Content-Type actually delivered, so an image negotiated on Accept counts as modern however its URL is spelled. https://web.dev/articles/serve-images-webp',
weight: 4,
severity: 'warn',
tags: ['performance', 'image'],

processPage: function (page) {
const offending = [];
let legacy = 0;
let total = 0;

for (const asset of page.assets) {
if (asset.type !== 'image') {
continue;
}
const contentType = getContentType(asset);
// No Content-Type means we cannot say what was delivered, so we say
// nothing rather than guessing from the URL.
if (!contentType) {
continue;
}
// A known-tiny image has nothing to gain from a modern format. An
// unknown size still gets judged, so we do not lose real images to a
// HAR that records no sizes at all.
const size = getSize(asset);
if (size > 0 && size < MIN_SIZE_BYTES) {
continue;
}
total++;
if (LEGACY_CONTENT_TYPES.has(contentType)) {
legacy++;
offending.push(asset.url);
}
}

let advice = '';
if (legacy > 0) {
advice =
'The page delivers ' +
util.plural(legacy, 'image') +
' (out of ' +
total +
') as JPEG, PNG or GIF. Convert them to AVIF or WebP, either in your image pipeline or by negotiating on the Accept header, and expect 25-50% smaller files at the same quality.';
}

return {
score: total > 0 ? Math.round(100 * (1 - legacy / total)) : 100,
offending: offending,
advice: advice
};
}
};
140 changes: 140 additions & 0 deletions test/har/performance/modernImageFormatsTest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import test from 'ava';
import har from '../../help/har.js';

// Rewrite the Content-Type of every image response in a HAR clone, so we can
// exercise "delivered as JPEG" and "delivered as WebP from the same .jpg URL"
// against the same fixture.
function withImageContentType(clone, contentType) {
for (const entry of clone.log.entries) {
const header = entry.response.headers.find(
(h) => h.name.toLowerCase() === 'content-type'
);
if (header && header.value.startsWith('image/')) {
header.value = contentType;
entry.response.content.mimeType = contentType;
}
}
return clone;
}

async function cloneFixture() {
return JSON.parse(
JSON.stringify(await har.harFromTestFile('www.nytimes.com.har'))
);
}

test('Modern image formats / flags images delivered as JPEG or PNG', async (t) => {
const result = await har.firstAdviceForTestFile('www.nytimes.com.har');
const advice = result.performance.adviceList.modernImageFormats;
t.is(advice.score, 0);
t.true(advice.offending.length > 0);
t.regex(advice.advice, /as JPEG, PNG or GIF/);
});

test('Modern image formats / a .jpg URL delivered as WebP is not flagged', async (t) => {
// The URLs in this fixture keep their .jpg / .png extensions — only the
// Content-Type changes, which is exactly Accept-based negotiation.
const clone = withImageContentType(await cloneFixture(), 'image/webp');
const result = await har.firstAdviceForHar(clone);
const advice = result.performance.adviceList.modernImageFormats;
t.is(advice.score, 100);
t.deepEqual(advice.offending, []);
t.is(advice.advice, '');
});

test('Modern image formats / AVIF counts as modern too', async (t) => {
const clone = withImageContentType(await cloneFixture(), 'image/avif');
const result = await har.firstAdviceForHar(clone);
t.is(result.performance.adviceList.modernImageFormats.score, 100);
});

test('Modern image formats / Content-Type parameters and casing are ignored', async (t) => {
const clone = withImageContentType(await cloneFixture(), 'Image/WebP; charset=utf-8');
const result = await har.firstAdviceForHar(clone);
t.is(result.performance.adviceList.modernImageFormats.score, 100);
});

test('Modern image formats / scores the share of legacy images', async (t) => {
const clone = await cloneFixture();
let seen = 0;
for (const entry of clone.log.entries) {
const header = entry.response.headers.find(
(h) => h.name.toLowerCase() === 'content-type'
);
if (header && header.value.startsWith('image/')) {
// Every other image gets a modern Content-Type.
const contentType = seen++ % 2 === 0 ? 'image/webp' : 'image/jpeg';
header.value = contentType;
entry.response.content.mimeType = contentType;
}
}
t.true(seen > 0, 'the fixture should contain images to rewrite');
const advice = (await har.firstAdviceForHar(clone)).performance.adviceList
.modernImageFormats;
// pagexray classifies a narrower asset set than the raw HAR entries (no
// favicons, no SVG), so assert the mix rather than an exact count.
t.true(advice.score > 0 && advice.score < 100);
t.true(advice.offending.length > 0);
t.true(advice.offending.every((url) => typeof url === 'string'));
});

test('Modern image formats / images without a Content-Type are not judged', async (t) => {
const clone = await cloneFixture();
for (const entry of clone.log.entries) {
entry.response.headers = entry.response.headers.filter(
(h) =>
!(
h.name.toLowerCase() === 'content-type' &&
h.value.startsWith('image/')
)
);
}
const advice = (await har.firstAdviceForHar(clone)).performance.adviceList
.modernImageFormats;
t.is(advice.score, 100);
t.deepEqual(advice.offending, []);
});

test('Modern image formats / tracking pixels are not flagged', async (t) => {
// A 42 byte GIF beacon is an image only by content type. Re-encoding it
// saves nothing, so it should not appear as advice.
const clone = await cloneFixture();
clone.log.entries.push({
startedDateTime: '2026-09-08T10:00:01.000Z',
time: 10,
request: {
method: 'GET',
url: 'https://tracker.example.com/pixel.gif?id=1',
httpVersion: 'HTTP/2',
headers: [],
queryString: [],
cookies: [],
headersSize: -1,
bodySize: -1
},
response: {
status: 200,
statusText: 'OK',
httpVersion: 'HTTP/2',
headers: [{ name: 'content-type', value: 'image/gif' }],
cookies: [],
content: { size: 42, mimeType: 'image/gif' },
redirectURL: '',
headersSize: -1,
bodySize: 42
},
cache: {},
timings: { blocked: 0, dns: 0, connect: 0, send: 0, wait: 5, receive: 5 },
pageref: clone.log.pages[0].id
});
withImageContentType(clone, 'image/webp');
// withImageContentType rewrote the beacon too, so put it back.
const beacon = clone.log.entries.at(-1);
beacon.response.headers[0].value = 'image/gif';
beacon.response.content.mimeType = 'image/gif';

const advice = (await har.firstAdviceForHar(clone)).performance.adviceList
.modernImageFormats;
t.is(advice.score, 100);
t.false(advice.offending.some((url) => url.includes('pixel.gif')));
});
2 changes: 2 additions & 0 deletions test/help/webserver.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const MIME = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.txt': 'text/plain; charset=utf-8'
Expand Down