Core Web Vitals for Angular SPAs: LCP, INP and CLS Fixes [2026]
Google's Core Web Vitals turn "does this page feel fast?" into three numbers, and they matter twice: users feel them directly, and Google uses them as a page-experience signal in search. A single-page app has its own twists: routes change without a page load, the first render often waits on data, and one slow click handler can ruin a metric for the whole session. This lesson ties the rest of Module 8 together into a measurement-first workflow.
This is lesson 8.6 of the Angular Tutorial. It builds directly on OnPush (8.1–8.2), @for tracking (8.3), NgOptimizedImage (8.4) and bundle analysis (8.5). You'll learn what each metric measures, how to read lab versus field data, what's different in an SPA, and the Angular-specific fix for each metric.
The three metrics #
| Metric | Measures | Good | Poor |
|---|---|---|---|
| LCP: Largest Contentful Paint | When the largest image or text block on the first screen finished rendering | ≤ 2.5 s | > 4 s |
| INP: Interaction to Next Paint | How long the page takes to visually respond to clicks, taps and key presses, across the whole visit | ≤ 200 ms | > 500 ms |
| CLS: Cumulative Layout Shift | How much visible content moves unexpectedly | ≤ 0.1 | > 0.25 |
Google assesses each at the 75th percentile of real visits: a page passes if three out of four visits are "good". INP replaced First Input Delay (FID) as the responsiveness metric in March 2024, and it's much harder to pass, because it looks at every interaction, not just the first.
Lab data vs field data #
| Lab (Lighthouse, DevTools) | Field (CrUX, your own RUM) | |
|---|---|---|
| Source | One simulated load on your machine | Real Chrome users over the last 28 days |
| Good for | Debugging, before/after comparisons | Knowing what users actually experience; what Google uses |
| Measures INP? | No, since there's no real user; it reports Total Blocking Time as a proxy | Yes |
| Covers route changes? | Only the initial load | The whole visit |
PageSpeed Insights shows both for a URL: field data at the top (if the page has enough traffic) and a Lighthouse run below. Use field data to decide what to fix and lab data to fix it. A green Lighthouse score with poor field INP is common in SPAs: Lighthouse only loads the page, while real users click around for minutes.
What's different in a single-page app #
- Route changes aren't page loads. LCP and the initial CLS are measured on the first load only. When the router swaps views, there's no new LCP, so a slow route transition doesn't show up there. Chrome is experimenting with measuring "soft navigations", but it isn't part of the official metrics yet.
- INP and CLS keep accumulating. INP reports roughly the worst interaction of the whole visit, and CLS keeps adding up across views, so a janky filter on page three or a banner that pushes content down after a route change counts against the URL the user first landed on.
- The first render often waits on data. If the hero needs an API response before anything meaningful appears, LCP includes that round trip.
Measure real users with web-vitals #
For field data on every route and interaction, add Google's small web-vitals library and send the numbers to your analytics:
// main.ts (after bootstrapApplication)
import { onCLS, onINP, onLCP } from 'web-vitals/attribution';
function report(metric: { name: string; value: number; rating: string; attribution?: unknown }) {
navigator.sendBeacon('/api/vitals', JSON.stringify({
name: metric.name, value: metric.value, rating: metric.rating,
path: location.pathname, attribution: metric.attribution,
}));
}
onLCP(report);
onINP(report);
onCLS(report);
The attribution build tells you why: the LCP element, the slow interaction's target and its processing time, and which element shifted. Group results by path to find the routes that need work.
Fixing LCP in Angular #
- Mark the LCP image
prioritywithNgOptimizedImageand serve the right size (lesson 8.4). This is the most common fix. - Don't block the hero on data. Render the page shell and hero text immediately; load data-dependent sections with
@deferor aresource()while the hero is already on screen. - Ship less JavaScript up front (lesson 8.5). The browser can't render a client-rendered page until
mainhas downloaded and run. - Server-render or prerender content pages. For landing pages, articles and product pages, SSR or static prerendering puts the LCP element in the initial HTML. Module 9 covers this.
- Speed up fonts: preconnect to the font host, and use
font-display: swapso text renders immediately.
Fixing INP in Angular #
INP is about the time between a user action and the next frame. Anything that keeps the main thread busy in that window hurts:
- Keep change detection small. OnPush plus signals (lessons 8.1–8.2) means a click re-renders only what changed.
- Stop re-creating lists. A good
track(lesson 8.3) turns "rebuild 500 rows" into "update 1 row". - Virtualise long lists. For thousands of rows, the CDK's virtual scroll renders only what's visible.
- Do less in handlers, and do it later. Update the UI first, then do heavy work after the next paint:
async applyFilters() {
this.loading.set(true); // paint "Filtering…" right away
await new Promise(r => setTimeout(r)); // yield so the browser can paint
this.results.set(filterLargeDataset(this.all(), this.filters())); // heavy work after
this.loading.set(false);
}
- Debounce high-frequency input. Filtering on every keystroke of a 10,000-item list is the classic INP killer; debounce the signal (lesson 3.4) or move the work to a Web Worker.
- Audit third-party scripts. Chat widgets and tag managers run on the same main thread; load them late or on interaction.
Fixing CLS in Angular #
- Give images dimensions, which
NgOptimizedImageenforces (lesson 8.4). - Size your
@deferplaceholders like the real content, so the swap doesn't push the page:
@defer (on viewport) {
<app-reviews [productId]="id()" />
} @placeholder {
<div class="reviews-skeleton" style="min-height: 420px"></div>
}
- Don't insert content above what the user is reading: cookie banners, "new version" notices and ads belong in reserved space or overlays.
- Animate with
transformandopacity, nottop,heightormargin, which move layout. - Match fallback fonts to web fonts with
size-adjustso text doesn't reflow when the web font arrives.
A measurement-first workflow #
- Check field data in PageSpeed Insights or your
web-vitalsreports; pick the worst metric on your highest-traffic route. - Reproduce in the lab: Lighthouse for LCP and CLS, DevTools' Performance panel (with CPU throttling) and the Angular DevTools profiler for INP.
- Fix one thing, using the lists above.
- Verify in the lab, deploy, then confirm in field data over the following weeks. CrUX is a 28-day rolling window, so improvements take time to show.
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
| Lighthouse is green, field INP is poor | Lighthouse doesn't interact; real users do | Measure with web-vitals; profile real interactions |
| LCP is the hero, but it's still slow | Hero waits for an API response | Render the shell first; defer data-dependent parts |
| CLS rises after navigating | Content inserted above the fold on a later view | Reserve space; use overlays for notices |
| INP spikes on one filter | Synchronous heavy work in the handler | Update the UI, yield, then compute; or use a worker |
| Improvements don't show in Search Console | Field data is a 28-day rolling window | Wait, and track your own RUM meanwhile |
| Route transitions feel slow, but LCP is fine | Soft navigations aren't in LCP | Measure route timing yourself; optimise lazy chunks |
What's next #
Lesson 8.7 is a practical memory-leak playbook: finding detached components and lingering subscriptions with heap snapshots, and the leak patterns that survive takeUntilDestroyed(). Then 8.8 pulls the whole module together into a checklist for avoiding unnecessary re-renders.
Try it yourself #
Run PageSpeed Insights on your app's busiest page and note which metric is worst in the field data. Add the web-vitals snippet above, logging to the console first, then click through the app and watch INP update on each interaction. Find the slowest interaction and apply one fix from the INP list.
search_documentationLighthouse only loads the page. It never clicks anything, so it can’t measure INP; a high score there doesn’t contradict poor field INP. To find the interaction: add onINP from web-vitals/attribution and log attribution.interactionTarget and processingDuration. Your ProductList filter chips are the likely culprit: each click runs applyFilters(), which filters 4,000 products synchronously and re-renders the grid with track $index, so every card is recreated. Two fixes: switch to track product.id (lesson 8.3), and set a loading signal and yield before filtering so the chip responds on the next frame. Then record a DevTools Performance trace with 4× CPU throttling to confirm the long task is gone.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


