What Changed in Core Web Vitals and Angular Performance Tooling from 2020 to 2026
Since 2020 CLS moved to session windows, INP replaced FID in March 2024, LCP and INP became measurable in Firefox and Safari, and in 2026 soft navigation metrics entered CrUX as experimental; in Angular, hydration, @defer, incremental hydration, zoneless and OnPush became defaults. By the end of this series Bookstore's home page went from 65 to 97 in Lighthouse; we also list the first five things to check in an old Angular 12 project.
Version note: The measurements in this post were taken with Angular 22.2 (with TypeScript 6.0, the version Angular 22.2 supports), web-vitals 6.2.3, Lighthouse 13.6, .NET 10 (ASP.NET Core 10) and Playwright 1.65. All numbers are lab measurements taken on a single machine against a local build with headless Chromium 158. Browser support data comes from MDN browser-compat-data 8.1.9 (2026-11-12); Angular versions were checked against the roadmap on angular.dev and the @publicApi notes in the typings of the @angular/* 22.2 packages.
Predict First
In the first post of the series, the home page of Bookstore, the online bookstore used as the shared example across this site's series, scored 63-69 in Lighthouse, and its LCP with the phone profile was 16 seconds. Nine posts later we measured the same page with the same tool again. Before reading on, predict: what is the score now? And which of the metrics in this series did not exist yet when Core Web Vitals were announced in 2020?
2020-2024: Core Web Vitals, CLS's New Definition and INP
web.dev's "Web Vitals" article was published on May 4, 2020 and summarized user experience with three metrics: LCP for loading, FID for interactivity and CLS for visual stability. The thresholds were built on the same idea as today: 75% of users (p75) should have an experience below the "good" limit. The metrics' effect on search ranking and the Search Console side belong to the Analytics and Measurement series.
The first change was in the definition of CLS. At first CLS was the sum of all shifts over the whole life of the page, and pages that stay open for a long time, SPAs in particular, got unfairly bad scores. With web.dev's "Evolving the CLS metric" post of April 7, 2021, CLS became the total of the largest session window, made of shifts less than 1 second apart and lasting no more than 5 seconds in total. In the CLS post we measured the impact × distance calculation and session windows with this definition.
The second and bigger change was the interaction metric. FID measured only the input delay of the first interaction: it did not see how long the handler ran or when the screen was updated, and it did not count later interactions at all. INP started as an experimental metric, became "pending" in 2023, and replaced FID as a stable Core Web Vital in March 2024. The three phases (input delay, processing, presentation delay) we used in the INP post while bringing the catalog search down from 416 ms to 8 ms are exactly the parts FID never saw.
Browsers: LCP and INP in Every Engine
For a long time Core Web Vitals were in practice "Chrome Vitals": only Chromium could measure the metrics, and field data came only from Chrome users. According to browser-compat-data 8.1.9 this picture has changed:
- LCP (
LargestContentfulPaint): Chrome 77, Firefox 122, Safari 26.2. interactionId, which INP relies on: Chrome 96, Firefox 144, Safari 26.2.LayoutShift, which CLS relies on, and LoAF, which we used to diagnose INP: Chromium only.
In practice this means for RUM: Safari and Firefox now send LCP and INP reports, they send no CLS, and INP attribution fields based on LoAF, such as longestScript, stay empty. Splitting the per-route p75 by browser family as well now makes a finding like "LCP is bad on iPhone" possible.
2026: Soft Navigations
Every measurement in this series belongs to the first load of an SPA. Route transitions inside the app, soft navigations, are not counted as new page loads in the stable Core Web Vitals; INP and CLS are attributed to the first URL for the whole session. Chrome 151 brought the Soft Navigations API, and according to web.dev's post of October 13, 2026, CrUX started publishing experimental LCP, INP and CLS metrics that include soft navigations; the target for moving them into the stable metrics is May 2027. web-vitals 6 can report soft navigations in browsers that support them; we left this as an exercise in the RUM code of the first post. The 810 ms in-app transition we measured in the navigation post does not show up anywhere in the current stable metrics; it will once the soft navigation metrics are stable.
Measurement Tools: web-vitals and Lighthouse
According to the web-vitals library's CHANGELOG, its major versions follow the story of the metrics: 3.0 (August 2022) added a config parameter to every function and started reporting TTFB after bfcache restores; 4.0 (May 2024) brought the INP phase breakdown and LoAF attribution and deprecated onFID(); 5.0 (May 2025) removed onFID() and moved its support policy to Baseline Widely available browsers; 6.0 (July 2026) added soft navigation support. The RUM code of this series uses this import with 6.2.3:
import { onCLS, onFCP, onINP, onLCP, onTTFB, type MetricWithAttribution } from 'web-vitals/attribution';
onTTFB(add);
onFCP(add);
onLCP(add);
onINP(add);
onCLS(add);
If you see getFID or onFID in an old project, that code is at least two major versions behind; when you upgrade, add INP and archive the FID records in RUM separately.
Lighthouse's score formula changed too. According to the table in the Lighthouse documentation, in Lighthouse 8 TTI had a weight of 10% and CLS 15%; Lighthouse 10 removed TTI from the score and raised CLS's weight to 25%. Lighthouse 13 (October 2025) did not change the score calculation but replaced the old diagnostic audits with the insights from DevTools' Performance panel. In the final state of this series, Lighthouse 13.6's JSON report contains 17 insight audits (such as lcp-breakdown-insight, cls-culprits-insight, render-blocking-insight, network-dependency-tree-insight), and the weights in the performance category are FCP 10, Speed Index 10, LCP 25, TBT 30, CLS 25. The same page measured in the first post and today:
Lighthouse 5 runs, mobile: http://127.0.0.1:5300/
run score FCP LCP TBT CLS SI
1 64 3016 11927 217 0.033 4173
2 63 3010 11926 207 0.033 5107
3 65 2149 11926 232 0.033 5127
4 68 2154 11926 222 0.033 3328
5 69 2153 11926 208 0.033 3328
med 65 2154 11926 217 0.033 4173
Lighthouse 3 runs, mobile: http://127.0.0.1:5300/
run score FCP LCP TBT CLS SI
1 98 606 2185 133 0.000 606
2 97 605 2260 136 0.000 605
3 97 605 2259 135 0.000 605
med 97 605 2259 135 0.000 605
The score went from 65 to 97, and the LCP that Lighthouse simulates from 11.9 s to 2.26 s. Lighthouse's simulated throttling and the throttling we apply through CDP give different numbers; the warning from the first post still holds: a lab result shows a direction, the decision comes from field data.
Angular: From NgOptimizedImage to injectAsync
In the same period Angular's performance tooling changed from the ground up. A summary based on the roadmap and the typings of the 22.2 packages:
| Version | Performance-related change | Where in this series |
|---|---|---|
| 15 | NgOptimizedImage stable | Image and font post: priority, loader, srcset |
| 16 | Hydration that does not rebuild the DOM, developer preview | SSR post |
| 17 | Hydration stable (provideClientHydration, @publicApi 17.0); @defer developer preview | CLS and JavaScript cost posts |
| 18 | @defer stable; event replay (developer preview) and zoneless (experimental) arrived | Event replay in the SSR post |
| 19 | Event replay stable and the default in new projects; incremental hydration and per-route render mode in developer preview | hydrate on viewport, RenderMode |
| 20 | Incremental hydration and server routes stable; signal, effect, linkedSignal stable; zoneless stable in 20.2 | INP post |
| 21 | New projects are zoneless | FrameRequestCallback in the INP post |
| 22 | Incremental hydration by default; OnPush by default (the old behavior is called Eager); resource and httpResource stable; injectAsync; id for resource | JavaScript cost and SSR posts |
How to read the table: in 2020, for loading performance in an Angular app you had lazy modules, budgets and an SSR with Angular Universal that wiped and rebuilt the DOM. Today there is a render mode per route, hydration that keeps the server's HTML and works section by section, @defer at the template level, injectAsync at the service level, and zoneless + OnPush, which narrow change detection by default. One finding of this series is that these tools affect each other: a block that hydrates late with hydrate on viewport misses the HTTP transfer cache, which turns off once the app is stable; a lazy route adds a round trip on a CSR page that users land on directly, and Angular's SSR engine closes it with modulepreload.
The First Five Things in an Old Angular 12 Project
Angular 12 came out in May 2021 and its support ended long ago; when you enter such a project for performance, the order matters. Angular's update guide recommends moving one major version at a time; an upgrade across ten major versions is a project of its own. That is why the work that needs no upgrade and pays off the most comes first:
1. Measure first: RUM and p75
web-vitals 6 is independent of the Angular version; collecting a per-route p75 with sendBeacon as in the first post is possible in Angular 12 too. Every improvement made without knowing which page is bad, in which metric and on which device, is a guess.
2. Images and CLS: width/height, AVIF, the LCP image
In this series a single unsized cover produced 0.54 CLS on its own, and a 5.5 KB AVIF instead of a 440 KB JPEG brought LCP from 13.7 s down to 1.3 s. These are HTML and server-side changes; they can be done in Angular 12 too. NgOptimizedImage requires Angular 15.
3. Budgets and lazy routes
The budgets field in angular.json exists in old projects too; tighten the limits to slightly above today's size so that growth breaks the build. In Angular 12, lazy loading is per module with loadChildren; loadComponent came later with standalone components. Every place where a large library (a chart, an editor, a map) is used on only one page is a first candidate.
4. Compression, caching and render-blocking resources
Server-side settings do not depend on the Angular version: the Brotli level, precompressed files, immutable for hashed files, render-blocking third-party CSS and scripts. In this series, changing the compression level brought the catalog list 1.2 s earlier.
5. SSR and change detection along the upgrade path
Angular Universal's SSR without hydration could produce CLS and flicker because it wiped and rebuilt the page in the browser. Hydration (17), @defer (18), incremental hydration (20) and zoneless (20.2) only come with an upgrade; build the upgrade plan with these versions as stops along the way. If there is an INP problem, moving expensive template methods into computed (Angular 16+), or at least into pure pipes, helps even on an old version, without waiting for the move to OnPush.
Production Notes
A summary of this series' measurements, all lab numbers taken on the same machine with the phone profile:
| Post | Change | Measured result |
|---|---|---|
| 02 | Response compression, loading analytics after load | Home page FCP from 2.35 s to 1.10 s |
| 03 | Prerendering the home page | Load delay from 1.1 s to 27 ms |
| 04 | AVIF, srcset, sized images | Home page LCP from 13.69 s to 1.34 s |
| 05 | computed, scheduler.yield(), CDK virtual scroll | Catalog INP from 416 ms to 8 ms |
| 06 | Slot, skeleton, margin collapse fix | Book page CLS (with scrolling) from 0.1079 to 0 |
| 07 | Lazy routes, @defer, budgets | Startup JS from 383 KB to 359 KB |
| 08 | SSR, Output Cache, hydrate on viewport | Book page FCP from 1.28 s to 0.3 s |
| 09 | Router preloading, Brotli Optimal, precompression | In-app transition from 810 ms to 207 ms |
Every row in the table is a lab measurement and may not show up in the field in the same proportion. In production, decisions should always follow the same order: find the bad route and metric in RUM, build the same scenario in the lab, measure a single change, confirm in RUM that the result arrives. The metrics also keep changing; once the soft navigation metrics are stable, SPAs' picture in CrUX will change and route transitions that are invisible today will enter p75.
Common Pitfalls
- Assuming that a page that is "good" by FID is also good by INP; FID never saw handler time or later interactions.
- Comparing an old CLS report (before 2021, a whole-session total) with today's value.
- Not splitting RUM by browser family; Safari sends INP but part of the attribution stays empty, and it sends no CLS.
- Comparing scores across Lighthouse versions; the weights changed from 8 to 10.
- Missing the new defaults (OnPush, zoneless, incremental hydration) when upgrading Angular; an old component moved to OnPush without the migration can stop updating its bindings.
- Starting with the upgrade in an Angular 12 project when the goal is performance; measurement, images and server settings pay off without waiting.
A Small Experiment
★☆☆ Run lighthouse.mjs with --save and read the lcp-breakdown-insight and network-dependency-tree-insight audits from the JSON with lh-insight.mjs. Compare them with the LCP subparts in this post.
★★☆ Add the reportSoftNavs option to rum.ts and collect the LCP and INP reports of moving from the home page to a book page in Chromium 158. Which route are the soft navigations attributed to?
★★★ Imagine a counterpart of Bookstore's catalog page written with Angular 12: a module with loadChildren, zone.js and ChangeDetectionStrategy.Default. Draw up an upgrade plan to apply the five items of this post to that project: which step needs which Angular version, and which metric will you measure at the end of each step?
The Web Performance series ends here. Bookstore's home page went from 65 to 97 in Lighthouse and its LCP with the phone profile from 16.2 s to 1.45 s; the catalog search answers in 8 ms, and the book page does not shift in any scenario. What comes next is the job of field data: continuing the same measurements with real users and keeping the RUM code up to date as the metrics change.