Lighthouse, Rendering and Coverage: Reading Audits Correctly
In Lighthouse 13 the audits merged with Performance Insights, but the score is calculated only from metrics: fixing cache-insight left the score unchanged, and three runs on the same page varied between 89 and 91. Simulated vs observed LCP, lab vs field data, navigation/timespan/snapshot modes, Rendering emulations, and using a source map to show that only 2.6 KB of the 42 percent in Coverage is our own code.
Version note: The examples in this post were run with Chrome 156 (156.0.8078.80), Lighthouse 13.6.0 (CLI, Command Line Interface), Angular 22.2 and .NET 10. Lighthouse runs used the CLI installed in the work folder, with an empty Chrome profile each time; Coverage and Rendering outputs were captured from the same Chrome version in headless mode through the Chrome DevTools Protocol (CDP).
Predict First
You run Lighthouse three times in a row against Bookstore's production build. Do all three Performance scores come out the same? The report warns "Use efficient cache lifetimes", you add the right Cache-Control headers on the server, and the warning turns green. How many points does the Performance score go up?
Second question: on first load, the Coverage panel says 42 percent of main-*.js is unused. How much of those 58 KB is your components and how much is framework? How much would you gain by converting to lazy routes?
What Changed in Lighthouse 13?
Lighthouse 13 shipped in October 2025, and the biggest change was merging its audits with the Insights of the Performance panel. Lighthouse 12 had made insights the default report view; 13 removed the old audits from both the report and the JSON (JavaScript Object Notation) output. For example, layout-shifts gave way to cls-culprits, render-blocking-resources to render-blocking, uses-long-cache-ttl to cache, and critical-request-chains and uses-rel-preconnect to network-dependency-tree. Some audits, such as first-meaningful-paint, offscreen-images, uses-passive-event-listeners and no-document-write, were removed with no replacement. The point the Lighthouse team stressed: the Performance score did not change. The score is calculated from metrics, not audits; only audits that do not count toward the score changed.
You can see this directly in our 13.6.0 report. The five metrics that determine the score with their weights, and the list of insight audits:
scored [('first-contentful-paint', 10), ('largest-contentful-paint', 25), ('total-blocking-time', 30), ('cumulative-layout-shift', 25), ('speed-index', 10)]
insight audits 17 ['cache-insight', 'cls-culprits-insight', 'document-latency-insight', 'dom-size-insight', 'duplicated-javascript-insight', 'font-display-insight', 'forced-reflow-insight', 'image-delivery-insight', 'inp-breakdown-insight', 'lcp-breakdown-insight', 'lcp-discovery-insight', 'legacy-javascript-insight', 'modern-http-insight', 'network-dependency-tree-insight', 'render-blocking-insight', 'third-parties-insight', 'viewport-insight']
The insights use the same engine as the Insights tab of the Performance panel; the LCP (Largest Contentful Paint) breakdown we saw in the previous post arrives here as lcp-breakdown-insight. Lighthouse 13 requires Node 22.19 or higher. Our report also had an "Agentic Browsing" category (with audits such as llms-txt and webmcp-registered-tools); that category is outside the scope of this post.
Reading the Score Correctly: Lab Data and Variance
Three runs against the same page, each with an empty profile:
1 {'performance': 90, 'accessibility': 100, 'best-practices': 100, 'seo': 90, 'agentic-browsing': 100} {'first-contentful-paint': '0.8 s', 'largest-contentful-paint': '3.5 s', 'total-blocking-time': '120 ms', 'cumulative-layout-shift': '0', 'speed-index': '0.8 s'}
2 {'performance': 91, 'accessibility': 100, 'best-practices': 100, 'seo': 90, 'agentic-browsing': 100} {'first-contentful-paint': '0.8 s', 'largest-contentful-paint': '3.3 s', 'total-blocking-time': '110 ms', 'cumulative-layout-shift': '0', 'speed-index': '0.8 s'}
3 {'performance': 89, 'accessibility': 100, 'best-practices': 100, 'seo': 90, 'agentic-browsing': 100} {'first-contentful-paint': '0.8 s', 'largest-contentful-paint': '3.5 s', 'total-blocking-time': '110 ms', 'cumulative-layout-shift': '0', 'speed-index': '0.8 s'}
With nothing changed, the score moved between 89 and 91, and LCP between 3.3 and 3.6 seconds. That is on a local server on a fixed machine; on a real site the network, ad scripts and server load make the variance bigger. Saying "the score dropped" from a single run is wrong; look at the median of several runs. If you run Lighthouse from DevTools, extensions also skew the result; use an Incognito window or a separate profile.
There is one more oddity: Lighthouse reported LCP as 3.5 seconds, yet the same report's LCP breakdown insight measured time to first byte at 2.3 ms and element render delay at 123.5 ms. The reason is the default settings:
throttling simulate mobile {'rttMs': 150, 'throughputKbps': 1638.4, 'requestLatencyMs': 562.5, 'downloadThroughputKbps': 1474.5600000000002, 'uploadThroughputKbps': 675, 'cpuSlowdownMultiplier': 4}
Lighthouse loads the page without throttling and, from the observed trace, simulates what would happen on a mobile device on a slow network. The metric values come from this simulation; the subparts in the insight come from the observed load. Both are in the same report, but they do not measure the same thing.
Lab data measures a single load in a controlled environment; field data is the 28-day distribution of real users and comes from Google's CrUX (Chrome User Experience Report). INP (Interaction to Next Paint) is not measured in a navigation report, because there is no interaction; TBT (Total Blocking Time) in the score is its lab proxy. The lab says "fixed", the field says "users felt it too"; when they disagree, the field is right.
Fixing the Insight, Not the Score
Three insights were red in the first report:
FAIL/WARN cache-insight 0 Est savings of 270 KiB Use efficient cache lifetimes
FAIL/WARN network-dependency-tree-insight 0 Network dependency tree
FAIL/WARN render-blocking-insight 0 Est savings of 150 ms Render-blocking requests
{'url': 'http://localhost:5391/main-66HIL7BB.js', 'cacheLifetimeMs': 0, 'totalBytes': 139054, 'wastedBytes': 139054}
Kestrel does not send Cache-Control for static files by default. Because the Angular build puts a content hash in file names, these files can be cached forever. But careful: ngsw-worker.js and index.html have no hash and must change with every deploy; make them immutable and the old-bundle problem from the Application post becomes permanent. So we wrote a rule that only matches hashed bundles:
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
// only content-hashed bundles are immutable; ngsw-worker.js and index.html must be revalidated every time
bool hashed = HashedAsset().IsMatch(ctx.File.Name);
ctx.Context.Response.Headers.CacheControl = hashed ? "public, max-age=31536000, immutable" : "no-cache";
}
});
partial class Program
{
[System.Text.RegularExpressions.GeneratedRegex(@"^(main|chunk|styles|polyfills)-[A-Z0-9]{8}\.(js|css)$")]
private static partial System.Text.RegularExpressions.Regex HashedAsset();
}
main-66HIL7BB.js: Cache-Control: public, max-age=31536000, immutable
ngsw-worker.js: Cache-Control: no-cache
index.html: Cache-Control: no-cache
{'performance': 91, 'accessibility': 100, 'best-practices': 100, 'seo': 90, 'agentic-browsing': 100} 1 None 3.3 s
cache-insight now passes (score 1). The Performance score is 91: within the range of the previous three runs. That is the answer to the question in Predict First; insights do not count toward the score, they speed up the real user's repeat visit. The 90 in SEO (Search Engine Optimization) comes from a single audit: meta-description, meaning the page has no meta description. The content of SEO audits is the subject of the upcoming Technical SEO series.
Navigation, Timespan and Snapshot Modes
The Mode option in the Lighthouse panel produces three kinds of report. Navigation is what we have done so far: it loads the page and measures the load. Timespan starts a recording and measures the interactions you perform on the page (typing in the filter, adding to the cart); INP and CLS (Cumulative Layout Shift) become meaningful in this mode, because it captures what happens after the load. Snapshot audits the current state of the page: for running accessibility and best practice checks while a modal is open. The right flow for Bookstore: measure the load with navigation, the filter interaction with timespan, and the cart page's state with snapshot. You can script the same flow in Node with Lighthouse's user flow API (startFlow) and run it in CI (Continuous Integration); setting up Lighthouse CI is a job for the CI/CD series.
The Rendering Tab: Seeing What You Cannot See
You open the Rendering tab from the Command Menu with "Show Rendering" or from the Drawer. Paint flashing shows repainted areas as flashing green boxes: if the whole list flashes while you type in the filter, everything is being painted, not just the cards that changed. Layout Shift Regions marks shifting areas with a colored overlay; it is the fastest way to see which element CLS comes from. Frame Rendering Stats puts an FPS (frames per second) and GPU (Graphics Processing Unit) memory meter on top of the page.
The same tab emulates media features. When we turned on prefers-color-scheme and prefers-reduced-motion with the same CDP command, the page saw this:
--- emulated media: {"dark":true,"reducedMotion":true}
This is how you test your Angular app's dark theme or the @media (prefers-reduced-motion: reduce) rules that turn off animations without touching the operating system settings. "Emulate vision deficiencies" shows the page with protanopia, deuteranopia, tritanopia, achromatopsia and blurred vision; an out-of-stock badge that relies on red alone becomes indistinguishable from green under deuteranopia emulation. The content of accessibility audits is covered in the upcoming Accessibility (a11y) series.
Coverage: Unused JavaScript and CSS
You open the Coverage panel from the Command Menu with "Show Coverage", press record and reload the page. For each file it shows total and unused bytes, and in the editor it marks lines red or green. The result of the first load on the production build:
--- JS coverage after first load
main-MOBEH5XT.js total=139253 unused=58925 (42.3%)
chunk-WVN3REIB.js total=136552 unused=53330 (39.1%)
--- CSS rules tracked=7 used=7 unused=0
The number looks big. But do not decide without knowing which source file it comes from. We translated the unused ranges back to the original files with the build's hidden source map (an approximate calculation with 20-character slices):
main bundle, unused bytes by app source (approx.):
src/app/book-detail/book-detail.ts unused=871 used=209
src/app/book-list-slow/book-list-slow.ts unused=561 used=279
src/app/recently-viewed.service.ts unused=509 used=91
src/app/cart/cart.ts unused=282 used=178
src/app/book-list/book-list.ts unused=156 used=844
src/app/cart.store.ts unused=116 used=324
src/app/app.ts unused=110 used=990
src/app/book.service.ts unused=51 used=189
Here is the answer to the second question in Predict First: of the 58.9 KB of unused code, only about 2.6 KB is our files. book-detail.ts, book-list-slow.ts and cart.ts at the top of the list are the components of routes that are never shown on the first page; because they are imported eagerly in app.routes.ts, they ended up in main. Converting them to lazy routes with loadComponent is right, but the gain is a few KB. The remaining 56 KB is framework code: the paths of the router, HttpClient and the Service Worker client that did not run at that moment. Coverage says "this code did not run in this session", not "this code is unnecessary".
A candidate for @defer is a heavy piece that is not needed in the first view but lives on the same route: a reviews panel on the detail page, a chart, a map. The admin report is already a lazy route; chunk-JFHRZQSQ.js (31.9 KB), loaded the first time you go there, looked 94.5 percent unused at load time: only 12 cases of a 300-case switch run. Lazy loading only postpones the bytes; it does not eliminate unused code. One note: CDP's Profiler.takePreciseCoverage command resets the counters on every call, while the panel accumulates across the recording. Keep that in mind when comparing the two numbers.
Production Notes
A Lighthouse score is a signal, not a target. If your team has a "never below 90" rule, at least use the median of three runs and measure in the same environment every time; even our local measurement moved by two points. For decisions about user experience, look at CrUX or your own RUM (Real User Monitoring) data.
Split cache headers by hash: hashed files are immutable, while index.html, ngsw.json and ngsw-worker.js are revalidated every time. If you use a CDN (Content Delivery Network), set up the same rule there as well; otherwise the CDN overrides the correct header from the server.
Record Coverage with the user's real flow, not a single page load. Code that does not run on the first screen may run when adding to the cart; make it lazy and you add a network delay to the first click.
Common Pitfalls
- Deciding from a single Lighthouse run; the score varies from run to run.
- Expecting the score to rise after fixing an insight; in Lighthouse 13 the score comes only from metrics.
- Mixing up simulated LCP and observed LCP.
- Giving
immutableto every static file and freezingngsw-worker.js. - Reading red in Coverage as "deletable code".
- Leaving Rendering emulations on and filing a "design is broken" bug.
A Small Experiment
★☆☆ Run a Performance report three times against Bookstore's production build from the DevTools Lighthouse panel in an Incognito window. Note the three scores and LCP values and compute the median.
★★☆ Add a meta description to index.html and confirm the SEO score goes to 100. Then record in Timespan mode while typing "Comp" into the filter and find the INP breakdown in the report.
★★★ Convert BookDetail, Cart and BookListSlow to lazy routes with loadComponent. Compare the main size in the build output and the unused bytes in Coverage before and after; write down whether the gain is as large as you expected.
Coverage and the Performance panel answered "what is Angular doing" indirectly. In the next post we move to the tool that answers it directly: Angular DevTools, with its component tree, profiler, injector tree and router tree.