Salta al contenuto principale

04 - INP e Core Web Vitals: da 59 a 88 con Quattro Fix Angular SSR

Quattro interventi su un sito Angular SSR hanno portato la home mobile da 59 a 88 su PageSpeed: precalcolo, route detection, reflow e CSP.

Cosa ne pensi di questo articolo?

04 - INP e Core Web Vitals: da 59 a 88 con Quattro Fix Angular SSR
Original Researchfedericocalo.dev

In Breve

Quattro interventi su un sito Angular SSR hanno portato la home mobile da 59 a 88 su PageSpeed: precalcolo, route detection, reflow e CSP.

Risposta diretta

Quattro interventi mirati hanno portato la home mobile di un sito Angular SSR da un punteggio PageSpeed di 59 a 88: precalcolare l'anteprima invece di scaricare l'intero catalogo, rilevare la route con PlatformLocation invece di REQUEST a prerender, separare lettura e scrittura del layout, e adottare una CSP host-based compatibile col prerender.

La maggior parte dei consigli sui Core Web Vitals ignora l'INP e si ferma a ottimizzare l'immagine hero. Ma su un dispositivo mobile di fascia media il vero costo e il JavaScript che gira dopo il caricamento: lo stato serializzato, i reflow forzati, gli script bloccati da una CSP sbagliata. Ecco i quattro fix che hanno davvero spostato il punteggio da 59 a 88, misurati sulla pagina reale, non su un benchmark sintetico.

Nota di trasparenza: i numeri qui sotto vengono da run Lighthouse mobile (emulazione Moto G Power, throttling 4G) sulla home in produzione, prima e dopo ogni intervento. Non sono stime. Gli snippet Angular eseguibili di ogni pattern sono raccolti nel repository pubblico collegato piu avanti.

Una precisazione di metodo: il punteggio di prestazioni di Lighthouse si calcola da FCP, Speed Index, LCP, Total Blocking Time e CLS e non include l'INP, che si misura soprattutto sul campo. Il passaggio da 59 a 88 è quindi un punteggio di laboratorio, e il reflow forzato è un contributo atteso all'INP, non un INP misurato.

Perche l'INP conta piu del numero che stai guardando

Il punteggio aggregato di PageSpeed e una comoda sintesi, ma nasconde il problema vero. Su desktop con connessione veloce quasi tutto sembra a posto. Su un telefono di fascia media in 4G, la differenza tra 59 e 88 non la fa l'immagine: la fanno il payload di stato che il browser deve deserializzare, i long task che bloccano il thread principale, e i reflow sincroni forzati da codice che legge e scrive il layout nello stesso ciclo. Ottimizzare l'INP significa guardare quel JavaScript, non solo i pixel.

Fix 1: precalcola, non fare fan-out del catalogo

Il pattern piu diffuso e anche il piu costoso: per mostrare tre card in anteprima, la home scaricava lato client l'intero catalogo, circa settanta file JSON in parallelo. Il risultato era un flood di richieste sul percorso critico, uno stato serializzato enorme e un LCP peggiore.

La regola: qualsiasi widget che mostra un sottoinsieme (top-N recenti, featured, preview) non deve scaricare tutto lato client. Si precalcola un file piccolo a build-time e si fa un solo GET, idealmente risolto da TransferState cosi il browser non fa alcuna fetch aggiuntiva dopo l'SSR.

Il trade-off e onesto: aggiungi uno step di build e un file da rigenerare quando il contenuto cambia. In cambio trasformi settanta richieste piu un grosso payload in un singolo GET, spesso zero. Per qualsiasi cosa sopra la piega su mobile, lo scambio conviene quasi sempre.

Fix 2: a prerender usa PlatformLocation, non REQUEST

Il fan-out non veniva dal servizio, ma da un rilevatore di route rotto. Un app initializer decideva se caricare l'anteprima leggendo la URL della richiesta con REQUEST. Ma @angular/ssr fornisce REQUEST solo per RenderMode.Server, non per RenderMode.Prerender: a build-time REQUEST e null, il check era sempre falso e il codice cadeva sul percorso di fallback, cioe il fan-out completo.

La correzione e leggere il path da PlatformLocation, che deriva da INITIAL_CONFIG.url ed e popolato per ogni modalita di render prima del bootstrap. E stato il fix piu impattante di tutti: da solo ha portato lo stato serializzato da circa 1,5 MB a pochi KB e ha eliminato le settanta fetch parallele con i relativi errori 429.

Fix 3: separa lettura e scrittura con afterNextRender

Una direttiva di scroll-animate scriveva sul DOM (classi, variabili CSS) e subito dopo leggeva la geometria con getBoundingClientRect, nello stesso callback. Con una singola istanza e invisibile. Con decine di istanze per pagina, la sequenza scrittura-lettura-scrittura forza un reflow sincrono per elemento: circa settanta millisecondi attribuiti a reflow forzato e un contributo diretto all'INP su mobile.

La soluzione sono le fasi earlyRead e write di afterNextRender: Angular esegue tutte le letture prima di tutte le scritture, su ogni istanza della pagina, quindi il browser fa un solo reflow invece di uno per elemento. Il trade-off e passare il valore misurato da earlyRead a write attraverso lo stato di ritorno invece di leggerlo inline: un po piu di cerimonia che rende il confine lettura-scrittura esplicito e strutturalmente impossibile da interlacciare.

Fix 4: CSP host-based, mai strict-dynamic col prerender

Irrigidire la Content-Security-Policy con nonce piu strict-dynamic e la mossa da manuale, e rompe silenziosamente un'app prerenderata. Su una pagina RenderMode.Prerender l'HTML e statico e i tag script del build non possono ricevere un nonce per-richiesta; strict-dynamic ignora apposta host e sorgente self, quindi i bundle statici vengono bloccati e l'app non si idrata.

Il modello corretto e host-based: script-src self piu il nonce, senza strict-dynamic. Il self copre i bundle same-origin su ogni modalita di render, il nonce resta come difesa in profondita per gli inline generati sulle pagine Server. Trade-off: e una policy leggermente piu larga in teoria, ma e l'unica che carica e resta stretta con il prerender. Rollout disciplinato: prima in Report-Only, verifica console pulita in produzione, poi flip a enforce, che e solo il nome dell'header.

Il risultato in numeri

Ogni fix ha una sua firma misurabile. La tabella riassume il prima e il dopo sulla home in produzione, con l'intervento piu impattante (la route detection) responsabile del crollo del payload di stato.

Confronto Core Web Vitals prima e dopo i quattro fix sulla home mobile
Metrica Prima Dopo Delta
Punteggio PageSpeed mobile 59 88 +29
CLS (home) 0.335 ~0 -0.335
Payload ng-state ~1.5 MB pochi KB ~-1.5 MB
Reflow forzato (INP) ~70 ms 0 -70 ms
Risposte 429 su asset statici ~50 0 -50
Home in produzione, Lighthouse mobile (Moto G Power, 4G). Codice e dettaglio di ogni numero nel repository collegato.

Cosa non semplifico

Nessuno di questi fix e una bacchetta magica. Il precalcolo aggiunge manutenzione; PlatformLocation ti da il path ma non gli header, ed e corretto proprio per questo; la separazione lettura-scrittura chiede piu disciplina nel codice; la CSP host-based e piu larga di un nonce puro. Ogni scelta e un compromesso esplicito, e la ragione per cui il numero si e mosso e che ho ottimizzato l'INP e il payload, non l'estetica del report.

Nella serie «Original Research», precedente: 03 - Quattro Postmortem di Produzione; successivo: 05 - Clean Architecture Spring Boot Template.

Domande frequenti

Perche il punteggio PageSpeed da solo e fuorviante?

Il punteggio aggregato nasconde il collo di bottiglia reale su mobile, che di solito e il JavaScript post-caricamento: stato serializzato da deserializzare, long task che bloccano il thread principale e reflow forzati. Ottimizzare quei fattori, non l'immagine hero, e cio che ha spostato il numero da 59 a 88.

Perche REQUEST non funziona a prerender in Angular SSR?

@angular/ssr inietta REQUEST solo per RenderMode.Server. Su una pagina RenderMode.Prerender non esiste una richiesta live, quindi inject(REQUEST) e null a build-time: ogni check basato sulla URL della richiesta risulta falso e il codice cade sul percorso di fallback. Per rilevare la route a prerender si usa PlatformLocation.pathname.

Come elimina i reflow forzati afterNextRender con earlyRead e write?

Angular raggruppa tutte le callback earlyRead prima di tutte le callback write, su ogni istanza della pagina. Cosi ogni misurazione della geometria avviene prima di ogni mutazione del DOM, e il browser fa al massimo un reflow invece di uno per elemento, eliminando i circa 70 ms di reflow forzato che contribuivano all'INP.

Perche strict-dynamic e incompatibile con le pagine prerenderate?

strict-dynamic si fida solo degli script che portano il nonce corrente e ignora self e la lista host. Ma l'HTML prerenderato e statico e i suoi tag script del build non possono ricevere un nonce per-richiesta, quindi vengono bloccati e l'app non si idrata. La policy corretta e host-based: script-src self piu nonce, senza strict-dynamic.

Fonti e approfondimenti

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti