Skip to main content

04 - INP and Core Web Vitals: From 59 to 88 with Four Angular SSR Fixes

Four fixes on an Angular SSR site took the mobile home page from 59 to 88 on PageSpeed: precompute, route detection, reflow and CSP.

What do you think about this article?

04 - INP and Core Web Vitals: From 59 to 88 with Four Angular SSR Fixes
Original Researchfedericocalo.dev

At a Glance

Four fixes on an Angular SSR site took the mobile home page from 59 to 88 on PageSpeed: precompute, route detection, reflow and CSP.

Direct answer

Four targeted fixes took a real Angular SSR home page from a mobile PageSpeed score of 59 to 88: precompute the preview instead of fanning out the whole catalog, detect the route with PlatformLocation instead of REQUEST at prerender, split layout reads from writes, and adopt a prerender-compatible host-based CSP.

Most Core Web Vitals advice ignores INP and stops at optimizing the hero image. But on a mid-range mobile device the real cost is the JavaScript that runs after load: the serialized state, the forced reflows, the scripts a wrong Content-Security-Policy blocks outright. Here are the four fixes that actually moved the score from 59 to 88, measured on the live page, not on a synthetic benchmark.

Transparency note: the numbers below come from Lighthouse mobile runs (Moto G Power emulation, 4G throttling) against the production home page, before and after each change. They are not estimates. The runnable Angular snippets for every pattern are collected in the public repository linked further down.

A methodological note: the Lighthouse performance score is computed from FCP, Speed Index, LCP, Total Blocking Time and CLS and does not include INP, which is measured mainly in the field. The move from 59 to 88 is therefore a lab score, and the forced reflow is an expected contribution to INP, not a measured INP.

Why INP matters more than the number you are staring at

The aggregate PageSpeed score is a convenient summary, but it hides the real problem. On a desktop with a fast connection almost everything looks fine. On a mid-range phone over 4G, the gap between 59 and 88 is not the image: it is the state payload the browser has to deserialize, the long tasks that block the main thread, and the synchronous reflows forced by code that reads and writes layout in the same tick. Optimizing INP means looking at that JavaScript, not just at pixels.

Fix 1: precompute, do not fan out the catalog

The most common pattern is also the most expensive: to render three preview cards, the home page fetched the entire catalog client-side, about seventy JSON files in parallel. The result was a request flood on the critical path, a huge serialized state, and a worse LCP.

The rule: any widget that shows a subset (top-N recent, featured, preview) must not fetch everything client-side. Precompute a small file at build time and do a single GET, ideally resolved from TransferState so the browser does zero extra fetches after SSR.

The trade-off is honest: you add a build step and a file to regenerate when content changes. In exchange you turn seventy requests plus a large payload into one GET, often zero. For anything above the fold on mobile, that trade is almost always worth it.

Fix 2: at prerender use PlatformLocation, not REQUEST

The fan-out did not come from the service, but from a broken route detector. An app initializer decided whether to load the preview by reading the request URL with REQUEST. But @angular/ssr only provides REQUEST for RenderMode.Server, not RenderMode.Prerender: at build time REQUEST is null, the check was always false, and the code fell through to the fallback path, the full fan-out.

The fix is to read the path from PlatformLocation, which derives from INITIAL_CONFIG.url and is populated for every render mode before bootstrap. It was the single most impactful fix: on its own it dropped the serialized state from about 1.5 MB to a few KB and removed the seventy parallel fetches along with their 429 errors.

Fix 3: split reads and writes with afterNextRender

A scroll-animate directive wrote to the DOM (classes, CSS variables) and then immediately read geometry with getBoundingClientRect, in the same callback. With a single instance it is invisible. With dozens of instances per page, the write-read-write sequence forces a synchronous reflow per element: about seventy milliseconds attributed to forced reflow, and a direct INP contributor on mobile.

The solution is the earlyRead and write phases of afterNextRender: Angular runs all reads before all writes, across every instance on the page, so the browser reflows once instead of once per element. The trade-off is passing the measured value from earlyRead to write through the returned state instead of reading inline: a little more ceremony that makes the read/write boundary explicit and structurally impossible to interleave.

Fix 4: host-based CSP, never strict-dynamic with prerender

Tightening the Content-Security-Policy with nonce plus strict-dynamic is the textbook move, and it silently breaks a prerendered app. On a RenderMode.Prerender page the HTML is static and the build script tags cannot receive a per-request nonce; strict-dynamic deliberately ignores host and self sources, so the static bundles are blocked and the app never hydrates.

The correct model is host-based: script-src self plus the nonce, without strict-dynamic. Self covers the same-origin bundles on every render mode, the nonce stays as defense-in-depth for the inline scripts emitted on Server pages. Trade-off: it is a slightly broader policy in theory, but it is the only one that both loads and stays strict with prerender. Roll it out in report-only first, verify a clean production console, then flip to enforce, which is only the header name.

The result in numbers

Each fix has its own measurable signature. The table summarizes the before and after on the production home page, with the most impactful change (the route detection) responsible for the collapse of the state payload.

Core Web Vitals comparison before and after the four fixes on the mobile home page
Metric Before After Delta
Mobile PageSpeed score 59 88 +29
CLS (home) 0.335 ~0 -0.335
ng-state payload ~1.5 MB a few KB ~-1.5 MB
Forced reflow (INP) ~70 ms 0 -70 ms
Rate-limited static responses (429) ~50 0 -50
Production home page, Lighthouse mobile (Moto G Power, 4G). Code and the detail behind every number in the linked repository.

What I am not simplifying

None of these fixes is a magic wand. Precompute adds maintenance; PlatformLocation gives you the path but not the headers, and that is exactly why it is correct; the read/write split asks for more discipline in the code; a host-based CSP is broader than a pure nonce. Every choice is an explicit trade-off, and the reason the number moved is that I optimized INP and the payload, not the aesthetics of the report.

In the "Original Research" series, previous: 03 - Four Production Postmortems; next: 05 - Clean Architecture Spring Boot Template.

Frequently asked questions

Why is the PageSpeed score alone misleading?

The aggregate score hides the real bottleneck on mobile, which is usually the post-load JavaScript: serialized state to deserialize, long tasks that block the main thread, and forced reflows. Optimizing those factors, not the hero image, is what moved the number from 59 to 88.

Why does REQUEST not work at prerender in Angular SSR?

@angular/ssr injects REQUEST only for RenderMode.Server. On a RenderMode.Prerender page there is no live request, so inject(REQUEST) is null at build time: any check based on the request URL evaluates false and the code falls through to the fallback path. To detect the route at prerender, use PlatformLocation.pathname.

How does afterNextRender with earlyRead and write remove forced reflow?

Angular batches all earlyRead callbacks before all write callbacks, across every instance on the page. So each geometry measurement happens before every DOM mutation, and the browser reflows at most once instead of once per element, removing the roughly 70 ms of forced reflow that contributed to INP.

Why is strict-dynamic incompatible with prerendered pages?

strict-dynamic trusts only scripts carrying the current nonce and ignores self and the host list. But prerendered HTML is static and its build script tags cannot receive a per-request nonce, so they are blocked and the app never hydrates. The correct policy is host-based: script-src self plus nonce, without strict-dynamic.

Sources and further reading

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects