UNPKG

chrome-devtools-frontend

Version:
253 lines (219 loc) • 9.45 kB
// 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, }); }