chrome-devtools-frontend
Version:
Chrome DevTools UI
253 lines (219 loc) • 9.45 kB
text/typescript
// Copyright 2025 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as i18n from '../../../core/i18n/i18n.js';
import * as Protocol from '../../../generated/protocol.js';
import type * as Handlers from '../handlers/handlers.js';
import * as Helpers from '../helpers/helpers.js';
import type * as Types from '../types/types.js';
import {metricSavingsForWastedBytes} from './Common.js';
import {linearInterpolation} from './Statistics.js';
import {
InsightCategory,
type InsightModel,
type InsightSetContext,
type PartialInsightModel,
} from './types.js';
export const UIStrings = {
/**
* @description Title of an insight that provides information and suggestions of resources that could improve their caching.
*/
title: 'Use efficient cache lifetimes',
/**
* @description Text to tell the user about how caching can help improve performance.
*/
description:
'A long cache lifetime can speed up repeat visits to your page. [Learn more](https://web.dev/uses-long-cache-ttl/).',
/**
* @description Column for a font loaded by the page to render text.
*/
requestColumn: 'Request',
/**
* @description Column for a resource cache's Time To Live.
*/
cacheTTL: 'Cache TTL',
/**
* @description Text describing that there were no requests found that need caching.
*/
noRequestsToCache: 'No requests with inefficient cache policies',
/**
* @description Table row value representing the remaining items not shown in the table due to size constraints. This row will always represent at least 2 items.
* @example {5} PH1
*/
others: '{PH1} others',
} as const;
const str_ = i18n.i18n.registerUIStrings('models/trace/insights/Cache.ts', UIStrings);
export const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export type CacheInsightModel = InsightModel<typeof UIStrings, {
requests: Array<{
request: Types.Events.SyntheticNetworkRequest,
ttl: number,
wastedBytes: number,
}>,
}>;
// Threshold for cache hits.
const IGNORE_THRESHOLD_IN_PERCENT = 0.925;
function finalize(partialModel: PartialInsightModel<CacheInsightModel>): CacheInsightModel {
return {
insightKey: 'Cache',
strings: UIStrings,
title: i18nString(UIStrings.title),
description: i18nString(UIStrings.description),
category: InsightCategory.ALL,
state: partialModel.requests.length > 0 ? 'fail' : 'pass',
...partialModel,
};
}
/**
* Determines if a request is "cacheable".
* A request is "cacheable" if it is of the appropriate protocol and resource type
* (see Helpers.Network.NON_NETWORK_SCHEMES and Helpers.Network.STATIC_RESOURCE_TYPE)
* and has the appropriate statusCodes.
*/
export function isCacheable(request: Types.Events.SyntheticNetworkRequest): boolean {
// Caching doesn't make sense for requests not loaded over the network.
if (Helpers.Network.NON_NETWORK_SCHEMES.includes(request.args.data.protocol)) {
return false;
}
return Boolean(
Helpers.Network.CACHEABLE_STATUS_CODES.has(request.args.data.statusCode) &&
Helpers.Network.STATIC_RESOURCE_TYPES.has(request.args.data.resourceType || Protocol.Network.ResourceType.Other));
}
/**
* Returns max-age if defined, otherwise expires header if defined, and null if not.
*/
export function computeCacheLifetimeInSeconds(
headers: Array<{name: string, value: string}>, cacheControl: Helpers.Network.CacheControl|null): number|null {
if (cacheControl?.['max-age'] !== undefined) {
return cacheControl['max-age'];
}
const expiresHeaders = headers.find(h => h.name === 'expires')?.value ?? null;
if (expiresHeaders) {
const expires = new Date(expiresHeaders).getTime();
// Treat expires values as having already expired.
if (!expires) {
return 0;
}
return Math.ceil((expires - Date.now()) / 1000);
}
return null;
}
/**
* Computes the percent likelihood that a return visit will be within the cache lifetime, based on
* historical Chrome UMA stats (see RESOURCE_AGE_IN_HOURS_DECILES comment).
*
* This function returns values on this curve: https://www.desmos.com/calculator/eaqiszhugy (but using seconds, rather than hours)
* See http://github.com/GoogleChrome/lighthouse/pull/3531 for history.
*/
function getCacheHitProbability(maxAgeInSeconds: number): number {
// This array contains the hand wavy distribution of the age of a resource in hours at the time of
// cache hit at 0th, 10th, 20th, 30th, etc percentiles. This is used to compute `wastedMs` since there
// are clearly diminishing returns to cache duration i.e. 6 months is not 2x better than 3 months.
// Based on UMA stats for HttpCache.StaleEntry.Validated.Age. see https://www.desmos.com/calculator/jjwc5mzuwd
// This UMA data is from 2017 but the metric isn't tracked any longer in 2025.
const RESOURCE_AGE_IN_HOURS_DECILES = [0, 0.2, 1, 3, 8, 12, 24, 48, 72, 168, 8760, Infinity];
const maxAgeInHours = maxAgeInSeconds / 3600;
const upperDecileIndex = RESOURCE_AGE_IN_HOURS_DECILES.findIndex(decile => decile >= maxAgeInHours);
// Clip the likelihood between 0 and 1
if (upperDecileIndex === RESOURCE_AGE_IN_HOURS_DECILES.length - 1) {
return 1;
}
if (upperDecileIndex === 0) {
return 0;
}
// Use the two closest decile points as control points
const upperDecileValue = RESOURCE_AGE_IN_HOURS_DECILES[upperDecileIndex];
const lowerDecileValue = RESOURCE_AGE_IN_HOURS_DECILES[upperDecileIndex - 1];
const upperDecile = upperDecileIndex / 10;
const lowerDecile = (upperDecileIndex - 1) / 10;
// Approximate the real likelihood with linear interpolation
return linearInterpolation(lowerDecileValue, lowerDecile, upperDecileValue, upperDecile, maxAgeInHours);
}
export function getCombinedHeaders(responseHeaders: Array<{name: string, value: string}>): Map<string, string> {
const headers = new Map<string, string>();
for (const header of responseHeaders) {
const name = header.name.toLowerCase();
if (headers.get(name)) {
headers.set(name, `${headers.get(name)}, ${header.value}`);
} else {
headers.set(name, header.value);
}
}
return headers;
}
/**
* Returns whether a request contains headers that disable caching.
* Disabled caching is checked on the 'cache-control' and 'pragma' headers.
*/
export function cachingDisabled(
headers: Map<string, string>|null, parsedCacheControl: Helpers.Network.CacheControl|null): boolean {
const cacheControl = headers?.get('cache-control') ?? null;
const pragma = headers?.get('pragma') ?? null;
// The HTTP/1.0 Pragma header can disable caching if cache-control is not set, see https://tools.ietf.org/html/rfc7234#section-5.4
if (!cacheControl && pragma?.includes('no-cache')) {
return true;
}
// If we have any of these, the user intentionally doesn't want to cache.
if (parsedCacheControl &&
(parsedCacheControl['must-revalidate'] || parsedCacheControl['no-cache'] || parsedCacheControl['no-store'] ||
parsedCacheControl['private'])) {
return true;
}
return false;
}
export interface CacheableRequest {
request: Types.Events.SyntheticNetworkRequest;
ttl: number;
wastedBytes: number;
}
export function generateInsight(
parsedTrace: Handlers.Types.ParsedTrace, context: InsightSetContext): CacheInsightModel {
const isWithinContext = (event: Types.Events.Event): boolean => Helpers.Timing.eventIsInBounds(event, context.bounds);
const contextRequests = parsedTrace.NetworkRequests.byTime.filter(isWithinContext);
const results: CacheableRequest[] = [];
let totalWastedBytes = 0;
const wastedBytesByRequestId = new Map<string, number>();
for (const req of contextRequests) {
if (!req.args.data.responseHeaders || !isCacheable(req)) {
continue;
}
const headers = getCombinedHeaders(req.args.data.responseHeaders);
const cacheControl = headers.get('cache-control') ?? null;
const parsedDirectives = Helpers.Network.parseCacheControl(cacheControl);
// Skip requests that are deliberately avoiding caching.
if (cachingDisabled(headers, parsedDirectives)) {
continue;
}
let ttl = computeCacheLifetimeInSeconds(req.args.data.responseHeaders, parsedDirectives);
// Ignore if a non-positive number.
if (ttl !== null && (!Number.isFinite(ttl) || ttl <= 0)) {
continue;
}
ttl = ttl || 0;
// Ignore >= 30d.
const ttlDays = ttl / 86400;
if (ttlDays >= 30) {
continue;
}
// If cache lifetime is high enough, let's skip.
const cacheHitProbability = getCacheHitProbability(ttl);
if (cacheHitProbability > IGNORE_THRESHOLD_IN_PERCENT) {
continue;
}
const transferSize = req.args.data.encodedDataLength || 0;
const wastedBytes = (1 - cacheHitProbability) * transferSize;
wastedBytesByRequestId.set(req.args.data.requestId, wastedBytes);
totalWastedBytes += wastedBytes;
results.push({request: req, ttl, wastedBytes});
}
// Sort by transfer size.
results.sort((a, b) => {
return b.request.args.data.decodedBodyLength - a.request.args.data.decodedBodyLength || a.ttl - b.ttl;
});
return finalize({
relatedEvents: results.map(r => r.request),
requests: results,
metricSavings: metricSavingsForWastedBytes(wastedBytesByRequestId, context),
wastedBytes: totalWastedBytes,
});
}