Come Ottimizzare l'INP (Interaction to Next Paint): Guida Pratica con Esempi di Codice

Scopri come migliorare l'Interaction to Next Paint (INP) con tecniche pratiche e codice funzionante: scheduler.yield(), Web Worker, code splitting e gestione script di terze parti. Il 43% dei siti non supera ancora questa metrica.

Perché l'INP è il Core Web Vital più ostico da domare

Parliamoci chiaro: nel 2026, il 43% dei siti web non supera ancora la soglia INP di 200 millisecondi. Questo rende l'Interaction to Next Paint la metrica Core Web Vital più comunemente fallita al mondo. Se il tuo sito sembra "impallato" quando un utente clicca un pulsante, compila un modulo o apre un menu a tendina, il colpevole è quasi sicuramente un punteggio INP scadente.

L'INP misura la reattività complessiva della pagina durante l'intera visita dell'utente — non solo il primo click come faceva il vecchio FID (First Input Delay), pensionato definitivamente a marzo 2024. Ogni click, tap e pressione di tasto viene tracciata, e Google usa l'interazione peggiore come punteggio finale. Sì, proprio la peggiore.

In questa guida analizzeremo le tre fasi che compongono l'INP, vedremo come diagnosticare le interazioni lente con la Long Animation Frames API, e applicheremo soluzioni concrete con codice funzionante — da scheduler.yield() ai Web Worker fino al code splitting strategico. Niente teoria astratta, solo roba che puoi usare subito.

Le tre fasi dell'INP: Input Delay, Processing Time, Presentation Delay

Per ottimizzare l'INP in modo efficace, devi capire che non è un numero monolitico. È la somma di tre fasi distinte, e ognuna richiede un approccio diverso.

1. Input Delay (Ritardo di Input)

È il tempo che passa tra il momento in cui l'utente interagisce (click, tap, pressione tasto) e il momento in cui il browser inizia effettivamente a eseguire gli event handler associati. Se il main thread è impegnato a fare altro — idratazione del framework, script di terze parti, calcoli pesanti — l'interazione dell'utente resta in coda. E l'utente aspetta.

2. Processing Time (Tempo di Elaborazione)

È il tempo che gli event handler impiegano per completare il loro lavoro. Operazioni DOM costose, aggiornamenti di stato sincroni, calcoli complessi e reflow forzati del layout contribuiscono tutti ad allungare questa fase. Onestamente, è spesso qui che si annida il problema principale.

3. Presentation Delay (Ritardo di Presentazione)

È il tempo necessario al browser per ricalcolare stili, eseguire il layout e dipingere il frame successivo. Alberi DOM troppo profondi, animazioni CSS pesanti e immagini da decodificare possono rallentare questa fase in modo significativo.

Per ottenere un punteggio "Buono" (sotto i 200 ms), bisogna lavorare su tutte e tre le fasi simultaneamente. Vediamo come fare, passo dopo passo.

Come misurare e diagnosticare l'INP

Dati sul campo vs. dati di laboratorio

Ecco una cosa che sorprende molti sviluppatori: l'INP può essere misurato solo con dati reali degli utenti (field data). Non puoi ottenere un punteggio INP significativo da Lighthouse o da qualsiasi test sintetico, perché servono utenti veri che interagiscano con la pagina.

Le fonti più affidabili per i dati sul campo sono:

  • Google Search Console — Mostra i report Core Web Vitals raggruppando le URL per stato (Buono, Da Migliorare, Scarso)
  • PageSpeed Insights — Visualizza i dati CrUX (Chrome User Experience Report) con il punteggio al 75° percentile degli ultimi 28 giorni
  • Soluzioni RUM — Strumenti come DebugBear, SpeedCurve o la libreria web-vitals di Google ti danno dati contestuali molto più dettagliati

La Long Animation Frames API (LoAF): il tuo migliore alleato per il debug

La Long Animation Frames API (LoAF, si pronuncia "Lo-Af") è l'evoluzione della vecchia Long Tasks API. Disponibile in Chrome dalla versione 123, è lo strumento più potente per capire perché il tuo INP è alto.

Mentre l'INP ti dice l'effetto ("questa interazione è lenta"), la LoAF ti rivela la causa: quale script specifico ha bloccato il frame, quanto tempo ha impiegato, e se è codice tuo o di terze parti. Un vero game-changer per il debugging.

Ecco come collegare i dati LoAF alle interazioni INP usando la libreria web-vitals:

import {onINP} from 'web-vitals/attribution';

onINP((metric) => {
  const {attribution} = metric;
  const {
    interactionTarget,
    interactionType,
    inputDelay,
    processingDuration,
    presentationDelay,
    longAnimationFrameEntries
  } = attribution;

  console.log('Elemento interazione:', interactionTarget);
  console.log('Tipo:', interactionType);
  console.log('Input Delay:', inputDelay, 'ms');
  console.log('Processing:', processingDuration, 'ms');
  console.log('Presentation:', presentationDelay, 'ms');

  // Analizza gli script responsabili
  longAnimationFrameEntries.forEach((loaf) => {
    loaf.scripts.forEach((script) => {
      console.log('Script:', script.sourceURL);
      console.log('Durata:', script.duration, 'ms');
      console.log('Tipo invocazione:', script.invokerType);
    });
  });
});

Con questo snippet puoi vedere esattamente quale script ha causato il rallentamento, se è codice tuo o di una libreria esterna, e quale fase dell'interazione è il vero collo di bottiglia.

Strategia 1: spezzare i Long Task con scheduler.yield()

Il main thread del browser può elaborare un solo task alla volta. Qualsiasi task che dura più di 50 ms viene considerato un "long task" e blocca le interazioni degli utenti. La soluzione più moderna? scheduler.yield().

Perché scheduler.yield() è meglio di setTimeout

scheduler.yield() fa parte della Prioritized Task Scheduling API ed è disponibile nei browser Chromium. La differenza rispetto al classico setTimeout(0) è sostanziale: mentre setTimeout inserisce il task in coda con ritardi imprevedibili, scheduler.yield() cede il controllo al main thread ma mantiene il task in posizione prioritaria — viene ripreso appena possibile, subito dopo che il browser ha gestito le interazioni urgenti dell'utente.

async function elaboraDatiComplessi(items) {
  const risultati = [];

  for (let i = 0; i < items.length; i++) {
    // Elaborazione intensiva per ogni elemento
    risultati.push(calcoloPesante(items[i]));

    // Ogni 10 elementi, cedi il controllo al main thread
    if (i % 10 === 0) {
      await scheduler.yield();
    }
  }

  return risultati;
}

// Fallback per browser non-Chromium
async function yieldAlMainThread() {
  // One-liner con fallback automatico
  await globalThis.scheduler?.yield?.();
}

Esempio pratico: rendering di una lista lunga

Immagina di dover renderizzare una lista di 500 prodotti con filtri e ordinamento lato client. Senza yielding, l'intera operazione blocca il main thread e l'utente vede la pagina "congelata":

// PRIMA: blocca il main thread per centinaia di ms
function filtraEOrdina(prodotti, filtro) {
  const filtrati = prodotti.filter(p => p.categoria === filtro);
  const ordinati = filtrati.sort((a, b) => a.prezzo - b.prezzo);
  aggiornaDOM(ordinati);
}

// DOPO: spezza il lavoro, mantiene la pagina reattiva
async function filtraEOrdinaOttimizzato(prodotti, filtro) {
  // Fase 1: Filtraggio
  const filtrati = prodotti.filter(p => p.categoria === filtro);
  await globalThis.scheduler?.yield?.();

  // Fase 2: Ordinamento
  const ordinati = filtrati.sort((a, b) => a.prezzo - b.prezzo);
  await globalThis.scheduler?.yield?.();

  // Fase 3: Aggiornamento DOM
  aggiornaDOM(ordinati);
}

La differenza è enorme. Con questo approccio il browser riesce a gestire eventuali interazioni dell'utente tra una fase e l'altra, riducendo drasticamente l'input delay. L'ho testato su un e-commerce con 800 prodotti e l'INP è sceso da 380 ms a 95 ms.

Strategia 2: spostare i calcoli pesanti nei Web Worker

I Web Worker eseguono JavaScript in un thread separato, completamente scollegato dal main thread. Sono perfetti per calcoli pesanti, analisi dati, elaborazione immagini e compressione file — in pratica, qualsiasi operazione che non tocca direttamente il DOM.

Esempio base: offloading di un calcolo intensivo

// calcolo-worker.js — eseguito in un thread separato
self.onmessage = function(evento) {
  const {dati, operazione} = evento.data;

  let risultato;
  switch (operazione) {
    case 'ordina':
      risultato = dati.sort((a, b) => {
        // Ordinamento complesso multi-campo
        return a.rilevanza !== b.rilevanza
          ? b.rilevanza - a.rilevanza
          : a.prezzo - b.prezzo;
      });
      break;
    case 'calcola-statistiche':
      risultato = {
        media: dati.reduce((s, v) => s + v, 0) / dati.length,
        mediana: dati.sort((a, b) => a - b)[Math.floor(dati.length / 2)],
        max: Math.max(...dati),
        min: Math.min(...dati)
      };
      break;
  }

  self.postMessage(risultato);
};
// main.js — nel thread principale
const worker = new Worker('calcolo-worker.js');

function elaboraInBackground(dati, operazione) {
  return new Promise((resolve) => {
    worker.onmessage = (evento) => resolve(evento.data);
    worker.postMessage({dati, operazione});
  });
}

// Utilizzo: il main thread resta libero per le interazioni
document.getElementById('btn-ordina').addEventListener('click', async () => {
  mostraSpinner();
  const ordinati = await elaboraInBackground(prodotti, 'ordina');
  aggiornaTabella(ordinati);
  nascondiSpinner();
});

Semplificare la comunicazione con Comlink

La comunicazione tramite postMessage diventa un incubo quando hai operazioni multiple da gestire. Per fortuna, la libreria Comlink di Google semplifica tutto trasformando il worker in un oggetto con metodi normali che puoi chiamare come se fossero locali:

// worker-comlink.js
import * as Comlink from 'comlink';

const api = {
  ordinaProdotti(prodotti) {
    return prodotti.sort((a, b) => b.rilevanza - a.rilevanza);
  },
  calcolaStatistiche(valori) {
    return {
      media: valori.reduce((s, v) => s + v, 0) / valori.length,
      totale: valori.reduce((s, v) => s + v, 0)
    };
  }
};

Comlink.expose(api);
// main.js con Comlink
import * as Comlink from 'comlink';

const api = Comlink.wrap(new Worker('worker-comlink.js'));

// Ora puoi chiamare i metodi come fossero locali
const risultato = await api.ordinaProdotti(listaProdotti);
const stats = await api.calcolaStatistiche(valori);

Molto più pulito, vero?

Strategia 3: ottimizzare gli Event Handler

Ogni millisecondo speso in un event handler è un millisecondo che l'utente attende prima di vedere un feedback visivo. Sembra ovvio, ma è incredibile quanti siti trascurino questo aspetto. Ecco le tecniche più efficaci per ridurre il processing time.

Evitare i reflow forzati (Layout Thrashing)

Il layout thrashing si verifica quando alterni letture e scritture di proprietà di layout sul DOM, costringendo il browser a ricalcolare il layout più volte nello stesso frame. È uno degli errori più comuni (e più costosi):

// MALE: causa layout thrashing
function ridimensionaElementi(elementi) {
  elementi.forEach(el => {
    const altezza = el.offsetHeight; // Lettura (forza reflow)
    el.style.height = (altezza * 1.2) + 'px'; // Scrittura
    // Al prossimo ciclo, la lettura forza un nuovo reflow!
  });
}

// BENE: batch di letture, poi batch di scritture
function ridimensionaElementiOttimizzato(elementi) {
  // Prima leggi tutti i valori
  const altezze = elementi.map(el => el.offsetHeight);

  // Poi scrivi tutte le modifiche
  elementi.forEach((el, i) => {
    el.style.height = (altezze[i] * 1.2) + 'px';
  });
}

Debounce e Throttle per eventi frequenti

Gli eventi scroll, resize e input possono scatenarsi centinaia di volte al secondo. Senza un controllo, il browser si ritrova sommerso di lavoro inutile:

// Throttle: esegui al massimo ogni 100ms
function throttle(fn, limite) {
  let inAttesa = false;
  return function(...args) {
    if (!inAttesa) {
      fn.apply(this, args);
      inAttesa = true;
      setTimeout(() => { inAttesa = false; }, limite);
    }
  };
}

// Usa requestAnimationFrame per aggiornamenti visivi
const gestisciScroll = throttle(() => {
  requestAnimationFrame(() => {
    aggiornaPosizioneHeader();
  });
}, 100);

window.addEventListener('scroll', gestisciScroll, {passive: true});

Un dettaglio che molti ignorano: l'opzione {passive: true} è fondamentale. Dice al browser che l'handler non chiamerà preventDefault(), permettendogli di proseguire lo scroll senza aspettare. Da sola, questa opzione può fare una differenza enorme sull'INP durante lo scroll.

Strategia 4: controllare gli script di terze parti

Devo essere diretto su questo punto: gli script di terze parti sono tra i principali responsabili di un INP elevato nel 2026. Analytics, chat widget, pixel di marketing, slider — i dati LoAF mostrano costantemente che librerie esterne divorano una porzione significativa del tempo del main thread.

I colpevoli più comuni

  • Widget di live chat — Enormi divoratori di risorse; caricano spesso centinaia di KB di JavaScript e restano attivi per tutta la sessione
  • Page builder pesanti (Divi, Elementor e simili) — Aggiungono nidificazione DOM profonda e JavaScript non ottimizzato
  • Slider automatici — Le transizioni ogni 3-5 secondi possono sovrapporsi ai click dell'utente, causando input delay
  • Script di analytics multipli — Google Analytics, Meta Pixel, TikTok Pixel che competono tutti per il main thread contemporaneamente

Caricare script di terze parti in modo intelligente

<!-- MALE: blocca il rendering -->
<script src="https://cdn.chat-provider.com/widget.js"></script>

<!-- MEGLIO: caricamento asincrono -->
<script async src="https://cdn.chat-provider.com/widget.js"></script>

<!-- OTTIMO: caricamento ritardato dopo l'interazione utente -->
<script>
  function caricaChatWidget() {
    const script = document.createElement('script');
    script.src = 'https://cdn.chat-provider.com/widget.js';
    document.body.appendChild(script);
    // Rimuovi i listener dopo il primo caricamento
    window.removeEventListener('scroll', caricaChatWidget);
    document.removeEventListener('click', caricaChatWidget);
  }

  window.addEventListener('scroll', caricaChatWidget, {once: true});
  document.addEventListener('click', caricaChatWidget, {once: true});
</script>

Partytown: spostare il codice di terze parti nei Web Worker

Partytown è una libreria che permette di eseguire script di terze parti in un Web Worker, liberando completamente il main thread. Funziona particolarmente bene con script di analytics e tracking:

<!-- Configurazione Partytown -->
<script>
  partytown = {
    forward: ['dataLayer.push', 'fbq']
  };
</script>
<script src="/~partytown/partytown.js"></script>

<!-- Sposta Google Analytics nel worker -->
<script type="text/partytown"
  src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX">
</script>

Una nota di cautela: Partytown funziona alla grande per analytics e pixel di tracciamento, ma può dare problemi con script che devono interagire con il DOM in tempo reale (come i widget di live chat). Testa sempre con attenzione prima di usarlo in produzione.

Strategia 5: Code Splitting e riduzione del JavaScript inutilizzato

La regola è semplice: meno JavaScript carichi e parsi, meno il main thread è congestionato. Il code splitting divide il tuo bundle JavaScript in chunk più piccoli caricati on-demand, solo quando servono davvero.

Dynamic Import in JavaScript vanilla

// Invece di importare tutto all'avvio:
// import { graficoPesante } from './grafici.js';

// Carica il modulo solo quando serve
document.getElementById('mostra-grafico').addEventListener('click', async () => {
  const {graficoPesante} = await import('./grafici.js');
  graficoPesante(dati);
});

Code Splitting in React con lazy e Suspense

import {lazy, Suspense} from 'react';

// Il componente viene caricato solo quando renderizzato
const GraficoDashboard = lazy(() => import('./GraficoDashboard'));

function Dashboard() {
  return (
    <Suspense fallback={<div>Caricamento grafico...</div>}>
      <GraficoDashboard />
    </Suspense>
  );
}

Verificare il JavaScript non utilizzato

Un trucco che uso spesso: apri il pannello Coverage di Chrome DevTools (Ctrl+Shift+P, poi cerca "Coverage") e guarda quanto JavaScript viene caricato ma mai eseguito. Se oltre il 50% di uno script risulta inutilizzato al caricamento della pagina, è un candidato perfetto per code splitting o rimozione completa.

Strategia 6: ridurre la complessità del DOM

Un DOM troppo complesso rallenta sia la fase di processing che quella di presentation dell'INP. Le linee guida di Google sono chiare:

  • Meno di 1.500 nodi totali nel DOM
  • Profondità massima di 32 livelli
  • Nessun nodo padre con più di 60 figli

Per le liste lunghe, la risposta è la virtualizzazione: renderizzare solo gli elementi visibili nella viewport e riciclarli durante lo scroll. Librerie come react-window o @tanstack/virtual gestiscono tutto automaticamente:

import {useVirtualizer} from '@tanstack/react-virtual';

function ListaProdotti({prodotti}) {
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: prodotti.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 80, // altezza stimata di ogni riga
  });

  return (
    <div ref={parentRef} style={{height: '600px', overflow: 'auto'}}>
      <div style={{height: virtualizer.getTotalSize() + 'px', position: 'relative'}}>
        {virtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.key}
            style={{
              position: 'absolute',
              top: virtualRow.start + 'px',
              height: virtualRow.size + 'px',
              width: '100%'
            }}
          >
            {prodotti[virtualRow.index].nome}
          </div>
        ))}
      </div>
    </div>
  );
}

I numeri parlano da soli: con 10.000 prodotti, senza virtualizzazione il browser gestisce 10.000 nodi DOM. Con la virtualizzazione, solo quelli visibili (circa 8-10) vengono renderizzati. Il tempo di layout e paint cala in modo drastico.

Il divario mobile: il vero campo di battaglia dell'INP

Questo dato mi ha colpito parecchio: l'INP mediano su mobile (131 ms al 75° percentile) è 2,8 volte peggiore rispetto al desktop (48 ms). Non è una piccola differenza — è un abisso.

I dispositivi mobili hanno processori più lenti, meno memoria e latenza di rete più alta. Tutti fattori che allungano il tempo di blocco del main thread. E c'è un dettaglio che non puoi ignorare: Google utilizza l'indicizzazione mobile-first, quindi il tuo punteggio INP su mobile è quello che conta davvero per il posizionamento.

Consigli pratici per il mobile:

  • Testa sempre con il throttling della CPU a 4x in Chrome DevTools — simula un dispositivo medio realistico
  • Punta a un budget JavaScript sotto i 300 KB (compresso) per il caricamento iniziale
  • Preferisci le animazioni CSS a quelle JavaScript — vengono eseguite dal compositore del browser, fuori dal main thread
  • Usa content-visibility: auto per gli elementi fuori viewport, così il browser può saltare il rendering delle sezioni non visibili

Checklist di ottimizzazione INP: il tuo piano d'azione

Dopo aver analizzato tutte le strategie, ecco un piano d'azione concreto ordinato per impatto:

  1. Misura — Configura il monitoraggio RUM con web-vitals e attribution LoAF per identificare le interazioni lente nel mondo reale
  2. Identifica i colpevoli — Usa i dati LoAF per capire quale script e quale fase (input delay, processing, presentation) causa il problema
  3. Riduci il JavaScript — Rimuovi codice inutilizzato, implementa il code splitting, differisci gli script non critici
  4. Spezza i long task — Usa scheduler.yield() per cedere il main thread durante operazioni lunghe
  5. Offloada nei Web Worker — Sposta calcoli pesanti, ordinamenti e analisi dati nei worker thread
  6. Controlla le terze parti — Carica chat widget, analytics e pixel solo quando necessario; valuta Partytown
  7. Ottimizza gli event handler — Elimina layout thrashing, usa passive listeners, implementa debounce e throttle
  8. Semplifica il DOM — Virtualizza le liste lunghe, riduci la nidificazione, usa content-visibility
  9. Monitora — L'INP può degradarsi con ogni nuova funzionalità; integra i controlli nella CI/CD e non smettere mai di misurare

Domande frequenti (FAQ)

Qual è la differenza tra INP e FID?

Il FID (First Input Delay) misurava solo il ritardo della prima interazione dell'utente con la pagina. L'INP invece misura la latenza di tutte le interazioni durante l'intera visita e riporta quella peggiore (al 98° percentile). È un indicatore molto più realistico dell'esperienza utente effettiva. Google ha sostituito definitivamente il FID con l'INP a marzo 2024.

Posso misurare l'INP con Lighthouse?

No, purtroppo no. L'INP è una metrica che richiede interazioni reali degli utenti e non può essere simulata in laboratorio. Lighthouse non genera un punteggio INP. Però il Total Blocking Time (TBT) di Lighthouse è la metrica di laboratorio più correlata all'INP: se migliori il TBT, con ogni probabilità migliori anche l'INP.

scheduler.yield() funziona su tutti i browser?

Nel 2026, scheduler.yield() è supportato nativamente solo nei browser Chromium (Chrome, Edge, Opera). Per Safari e Firefox puoi usare il polyfill scheduler-polyfill oppure il fallback progressivo await globalThis.scheduler?.yield?.() che semplicemente non fa nulla nei browser non supportati — nessun errore, nessun crash.

Quanto impatta l'INP sul posizionamento SEO?

L'INP è un fattore di ranking ufficiale di Google come parte dei Core Web Vitals. I siti che superano tutte e tre le soglie (LCP, INP, CLS) registrano in media un 24% in meno di frequenza di rimbalzo e un miglioramento misurabile nel posizionamento organico. Con le AI Overview che selezionano le fonti anche in base alla qualità tecnica, avere un INP nella zona verde è diventato ancora più importante.

I Web Worker possono accedere al DOM?

No, i Web Worker non hanno accesso al DOM. Comunicano con il main thread solo tramite postMessage. Questo li rende perfetti per calcoli puri — ordinamento, filtri, analisi dati, crittografia — ma non per manipolazioni dirette dell'interfaccia. Se la comunicazione via postMessage ti sembra macchinosa, prova Comlink: trasforma il worker in un oggetto con metodi che puoi chiamare come se fossero locali. Cambia tutto.

Sull'Autore Editorial Team

Our team of expert writers and editors.