Interaction to Next Paint (INP): Stop frustratie door trage knoppen

Gebruiker tikt op "Toevoegen aan winkelwagen". Er gebeurt niets. Nog een keer tikken. Nog steeds niets. Eindelijk, 800 ms later, registreren beide tikken en verschijnen er twee artikelen in de winkelwagen. Gefrustreerd verwijdert de gebruiker het dubbele en overweegt elders te winkelen. Je INP kost je sales.

Interaction to Next Paint uitgelegd

Wat is INP (en waarom websites erdoor kapot lijken)?

INP meet de vertraging tussen het moment dat iemand klikt, tikt of typt en wanneer hij visuele feedback ziet. Als je knop 600 ms nodig heeft om te reageren, denkt de gebruiker dat hij stuk is.

Het harde deel: INP bijhoudt je slechtste interactie, niet je gemiddelde. Eén trage knop tijdens het afrekenen verpest de metric. Eén traag invoerveld trekt je score omlaag. Google meet het 75e percentiel. Dat betekent dat 25% van je gebruikers een slechte ervaring kan hebben en je nog steeds scoort. Maar die 25% vertrekt.

In tegenstelling tot de vroegere First Input Delay (FID), die alleen de eerste interactie mat, bewaakt INP elke klik, tik en toetsaanslag gedurende het hele bezoek. Het is een veel strenger (en nauwkeuriger) maatstaf voor responsiviteit.

INP-normen

Good200 ms of minder
Needs Improvement200 ms tot 500 ms
PoorBoven 500 ms

Wat telt als interactie? Klikken, tikken en toetsaanslagen. Hover- en scroll-events worden niet meegenomen in INP-metingen.

De verborgen kosten van trage interacties

Slechte INP irriteert gebruikers niet alleen. Het breekt hun mentale model van hoe websites werken. Als een knop niet direct reageert, gaan ze ervan uit dat de website stuk is, hun internet hapert of dat ze niet goed hebben geklikt. Ze proberen het opnieuw, dubbelklikken en vernieuwen de pagina. Dat maakt de situatie alleen maar erger.

Dubbele bestellingen

Gebruiker klikt op "Bestelling plaatsen", er gebeurt 600 ms niets, hij klikt opnieuw. Nu heb je dubbele bestellingen, boze klanten en restitutieverzoe­ken.

Formulier­abandonment

Typen in een zoekvak met 400 ms vertraging voelt kapot. Bezoekers denken dat het veld dood is, typen opnieuw en lopen tegen dezelfde vertraging aan. Elke poging telt weer mee in je 75e percentiel.

Mobiele gebruikers het hardst geraakt

Over de 1.552 e-commercesites in onze mobiele benchmark is de mediane INP 194 ms, bij een drempel van 200 ms. De helft van de markt zit een paar milliseconden van een onvoldoende af. Op je eigen laptop zie je dat nooit.

Hoe dat er in de praktijk uitziet. We brachten de mobiele INP van Simyo terug van 413 ms naar 150 ms op het 75e percentiel, en op desktop van 102 ms naar 52 ms. Hun UX Score op mobiel ging van 82 naar 100.

De oorzaak lag bij third-party tooling en tracking, niet bij hun eigen applicatiecode. Dat is meestal zo, en precies de reden dat je het niet ziet als je lokaal op een snelle laptop profileert. We vonden de trage interacties met attributiedata uit het veld.

Hetzelfde traject draaiden we bij drie merken van Frasers Group: flannels.com, sportsdirect.com en houseoffraser.co.uk. In drie maanden, zonder replatforming en zonder hun tooling te wijzigen.

INP meten

Velddata (echte gebruikers)

INP kan alleen worden gemeten met echte gebruikersdata. Labtools kunnen het niet vastleggen omdat ze geen echte interacties simuleren:

INP debuggen in het lab

Hoewel je INP niet kunt meten met labtools, kun je wel de oorzaken opsporen:

// Chrome DevTools Performance tab
// 1. Start recording
// 2. Perform the slow interaction
// 3. Stop recording
// 4. Look for:
//    - Long tasks (red flags in the timeline)
//    - JavaScript execution blocking the main thread
//    - Event handler duration

// The Performance tab shows an "Interactions" track
// that highlights slow interactions automatically

INP programmatisch meten

Installeer de web-vitals library:

npm install web-vitals

Track vervolgens INP:

import {onINP} from 'web-vitals';

onINP((metric) => {
  // Send to analytics
  console.log('INP:', metric.value);

  // Get the specific interaction that caused this INP
  const interaction = metric.entries[0];
  console.log('Slow interaction:', {
    type: interaction.name, // 'pointerdown', 'click', 'keydown'
    target: interaction.target, // DOM element
    duration: interaction.duration,
    startTime: interaction.startTime
  });
});

Veelvoorkomende oorzaken van slechte INP

1. Zware JavaScript-uitvoering

Wanneer gebruikers interageren, draaien JavaScript-event handlers op de main thread. Als je handler 400 ms aan JavaScript uitvoert, zal INP minimaal 400 ms zijn.

Fix: Splits lange taken op, stel niet-kritiek werk uit, gebruik web workers voor zware berekeningen.

2. Lange taken die de main thread blokkeren

Als er een lange taak (meer dan 50 ms) loopt wanneer de gebruiker interageert, kan de browser pas reageren als die taak klaar is. Gebruikers wachten terwijl JavaScript draait.

Fix: Geef regelmatig controle terug aan de main thread, splits grote bundles, laad niet-kritieke features lazy.

3. Dure DOM-updates

Nadat je event handler klaar is, moet de browser stijlen, layout en paint herberekenen. Complexe DOM-wijzigingen kosten tijd.

Fix: Minimaliseer DOM-mutaties, batch updates, vermijd forced synchronous layouts en gebruik CSS transforms in plaats van layout-eigenschappen.

4. Scripts van derden

Analytics, advertenties, chatwidgets en A/B-testtools draaien allemaal JavaScript op de main thread. Ze concurreren om verwerkingstijd tijdens interacties.

Fix: Auditeer en verwijder ongebruikte scripts, stel niet-kritieke tags uit, gebruik facades voor zware widgets.

5. Inefficiënte event handlers

Event handlers die te veel werk doen, synchrone netwerkaanvragen maken of cascade-updates veroorzaken, kunnen honderden milliseconden blokkeren.

Fix: Optimaliseer de logica van handlers, debounce/throttle events met hoge frequentie, maak handlers waar mogelijk async.

INP-optimalisatietechnieken

1. Splits lange taken op

De browser kan alleen reageren op interacties tussen taken. Splits langlopende JavaScript op in kleinere stukken om de browser de kans te geven gebruikersinvoer te verwerken.

Geef controle terug aan de main thread

// Bad: Processes all items in one long task
function processItems(items) {
  items.forEach(item => {
    // Heavy processing
    heavyWork(item);
  });
}

// Good: Yields after each batch
async function processItems(items) {
  for (let i = 0; i < items.length; i++) {
    heavyWork(items[i]);

    // Yield to browser every 5 items
    if (i % 5 === 0) {
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
}

// Better: Use scheduler.yield() (experimental)
async function processItems(items) {
  for (const item of items) {
    heavyWork(item);

    if (navigator.scheduling?.isInputPending()) {
      await scheduler.yield();
    }
  }
}

Impact: Kan INP met 100–300 ms verlagen op pagina's met veel JavaScript

2. Event handlers optimaliseren

Niet-kritiek werk uitstellen

// Bad: Everything runs immediately
button.addEventListener('click', () => {
  updateUI();           // Critical
  trackAnalytics();     // Not critical
  updateRecommendations(); // Not critical
  syncToServer();       // Not critical
});

// Good: Only critical work is synchronous
button.addEventListener('click', () => {
  // Immediate visual feedback
  updateUI();

  // Defer everything else
  setTimeout(() => {
    trackAnalytics();
    updateRecommendations();
    syncToServer();
  }, 0);
});

// Better: Use requestIdleCallback for non-urgent work
button.addEventListener('click', () => {
  updateUI();

  requestIdleCallback(() => {
    trackAnalytics();
    updateRecommendations();
  });

  // Still defer server sync but with higher priority
  setTimeout(() => syncToServer(), 0);
});

Dure handlers debouncen

// For search input, filtering, etc.
function debounce(fn, delay) {
  let timeoutId;
  return function(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), delay);
  };
}

const searchInput = document.querySelector('#search');
const debouncedSearch = debounce((query) => {
  // Expensive search operation
  performSearch(query);
}, 300);

searchInput.addEventListener('input', (e) => {
  debouncedSearch(e.target.value);
});

3. DOM-manipulatie minimaliseren

DOM-updates batchen

// Bad: Multiple layout recalculations
items.forEach(item => {
  const element = document.createElement('div');
  element.textContent = item.title;
  container.appendChild(element); // Layout recalc each time
});

// Good: Build fragment first
const fragment = document.createDocumentFragment();
items.forEach(item => {
  const element = document.createElement('div');
  element.textContent = item.title;
  fragment.appendChild(element);
});
container.appendChild(fragment); // Single layout recalc

// Better: Use innerHTML for large updates (faster)
const html = items.map(item =>
  `<div>${item.title}</div>`
).join('');
container.innerHTML = html;

Vermijd forced synchronous layouts

// Bad: Reading layout properties forces immediate recalc
element.style.width = '100px';
const height = element.offsetHeight; // Forces layout
element.style.height = height + 'px'; // Another layout

// Good: Read all layout properties first, then write
const width = element.offsetWidth;
const height = element.offsetHeight;
element.style.width = width + 10 + 'px';
element.style.height = height + 10 + 'px';

// Better: Use CSS when possible
element.classList.add('expanded'); // No JavaScript layout thrashing

4. Gebruik web workers voor zware berekeningen

// Main thread - stays responsive
const worker = new Worker('/data-processor.js');

button.addEventListener('click', () => {
  // Immediate feedback
  button.disabled = true;
  button.textContent = 'Processing...';

  // Heavy work happens off main thread
  worker.postMessage({ data: largeDataset });
});

worker.onmessage = (e) => {
  // Update UI with results
  displayResults(e.data);
  button.disabled = false;
  button.textContent = 'Process';
};

// data-processor.js (runs in worker)
self.onmessage = (e) => {
  const results = heavyComputation(e.data);
  self.postMessage(results);
};

5. Code splitting en lazy loading

// React/Next.js: Load heavy components only when needed
import dynamic from 'next/dynamic';

const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <p>Loading chart...</p>
});

// Vanilla JS: Dynamic imports
button.addEventListener('click', async () => {
  const { initChart } = await import('./chart-library.js');
  initChart(data);
});

// Webpack/Vite: Lazy load routes
const routes = [
  {
    path: '/dashboard',
    component: () => import('./Dashboard.jsx')
  }
];

Geef eerst visuele feedback, dan pas het werk

INP meet niet hoe lang je code erover doet. INP meet hoe lang de gebruiker naar een onveranderd scherm zit te kijken. De klok stopt bij het eerstvolgende frame dat de browser tekent, niet wanneer je handler klaar is.

Dat verschil is geld waard, want het betekent dat je INP kunt verbeteren zonder iets sneller te maken. Teken eerst iets, doe daarna het werk.

Een bezoeker tikt op "Toevoegen aan winkelwagen" en je handler roept een API aan die 600 ms duurt. Teken je niets tot het antwoord binnen is, dan is je INP 600 ms en tikt de bezoeker nog een keer. Zet je de knop meteen op disabled met een spinner, dan is je INP onder de 100 ms. Die API duurt nog steeds 600 ms. Wat je meet, en wat de bezoeker ervaart, verschillen volledig.

De ergste INP-problemen zitten bijna altijd bij interacties die op het netwerk wachten. Die vertraging kun je vaak niet wegnemen. De stilte eromheen wel.

Drie plekken waar dit direct loont:

  • Alles wat het netwerk raakt. Toevoegen aan winkelwagen, filter toepassen, formulier versturen. Toon de wachtstatus vóór het verzoek, niet na het antwoord.
  • Menu's en accordions die layoutwerk doen. Teken eerst de open staat, laad de inhoud daarna.
  • Validatie in formulieren. Laat direct zien dat het veld gecontroleerd wordt, in plaats van te wachten tot de controle klaar is.

Het patroon is steeds hetzelfde. Knip je handler in tweeën: het deel dat verandert wat de bezoeker ziet, en het deel dat het werk doet. Teken het eerste, geef de browser de kans te renderen, doe dan pas het tweede.

button.addEventListener('click', async () => {
  // 1. Meteen tekenen. Dit is wat INP meet.
  button.disabled = true;
  button.textContent = 'Bezig...';

  // 2. Laat de browser eerst renderen.
  await new Promise(requestAnimationFrame);

  // 3. Nu pas het trage deel, buiten de meting.
  await addToCart(sku);
  button.textContent = 'Toegevoegd';
});

Geavanceerde INP-optimalisatiestrategieën

Controle teruggeven aan de browser met scheduler.yield()

Bij het verwerken van grote datasets geef je periodiek controle terug aan de browser zodat hij gebruikersinteracties kan afhandelen. De scheduler.yield() API is slimmer dan setTimeout omdat het alleen yieldt wanneer nodig.

// Break up long tasks to allow interaction handling
async function processLargeDataset(data) {
  for (let i = 0; i < data.length; i++) {
    processItem(data[i]);

    // Yield every 5 items to let browser handle interactions
    if (i % 5 === 0) {
      await scheduler.yield();
    }
  }

  onComplete();
}

// Fallback for browsers without scheduler.yield()
async function yieldToMain() {
  if ('scheduler' in window && 'yield' in scheduler) {
    return scheduler.yield();
  }
  return new Promise(resolve => setTimeout(resolve, 0));
}

async function processLargeDataset(data) {
  for (let i = 0; i < data.length; i++) {
    processItem(data[i]);

    if (i % 5 === 0) {
      await yieldToMain();
    }
  }

  onComplete();
}

Waarom dit werkt: Lange taken (meer dan 50 ms) blokkeren de main thread. Door periodiek te yielden, splits je één lange taak op in meerdere kortere taken, waardoor de browser kansen krijgt om klikken, tikken en toetsaanslagen te verwerken tussen de stukjes werk door.

Gebruik content-visibility voor content buiten het scherm

/* Prevent browser from rendering off-screen content */
.article-section {
  content-visibility: auto;
  contain-intrinsic-size: 1000px; /* Estimated height */
}

/* Reduces layout work during interactions by 50-80%
   on pages with lots of off-screen content */

React-rendering optimaliseren

// Use React.memo to prevent unnecessary re-renders
const ExpensiveComponent = React.memo(({ data }) => {
  return <div>{/* Complex render */}</div>;
});

// Use useCallback for event handlers
const MyComponent = () => {
  const handleClick = useCallback(() => {
    // Handler logic
  }, []); // Dependencies

  return <button onClick={handleClick}>Click</button>;
};

// Use useDeferredValue for non-urgent updates
const MyComponent = ({ searchTerm }) => {
  const deferredSearchTerm = useDeferredValue(searchTerm);
  const results = useMemo(() =>
    expensiveSearch(deferredSearchTerm),
    [deferredSearchTerm]
  );

  return <Results data={results} />;
};

// Use startTransition for non-urgent state updates
import { startTransition } from 'react';

const handleSearch = (value) => {
  setInputValue(value); // Urgent: update input
  startTransition(() => {
    setSearchResults(search(value)); // Not urgent
  });
};

React 19.2+ Activity-component

Het nieuwe <Activity />-component handelt de zichtbaarheid van modals/tabbladen af zonder unmounting, waarbij state bewaard blijft en re-renders worden voorkomen. Zie de React-documentatie voor details.

INP verbeteren in React en Next.js

Concurrent rendering in React bestaat grotendeels om dit probleem op te lossen. Standaard is elke state-update urgent, dus een toetsaanslag die een zware re-render start blokkeert precies het frame dat die toetsaanslag had moeten tonen.

Markeer de zware update als niet-urgent. Met startTransition vertel je React dat de update onderbroken mag worden. Typen blijft dan soepel terwijl een zware gefilterde lijst op de achtergrond opnieuw rendert.

import {useState, useTransition} from 'react';

function ProductFilter({products}) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState(products);
  const [isPending, startTransition] = useTransition();

  function onChange(e) {
    // Urgent: het invoerveld moet het teken nu tonen.
    setQuery(e.target.value);

    // Niet urgent: React mag dit onderbreken.
    startTransition(() => {
      setResults(filterProducts(products, e.target.value));
    });
  }

  return <input value={query} onChange={onChange} aria-busy={isPending} />;
}

useDeferredValue doet hetzelfde wanneer je een waarde binnenkrijgt in plaats van zelf zet. Binnen een componentbibliotheek is dat meestal het geval.

Schrap JavaScript in plaats van het te verschuiven. Concurrent rendering laat een grote bundel beter aanvoelen. Kleiner wordt hij er niet van. In de App Router van Next.js draaien Server Components op de server, zonder JavaScript in de browser. Dat haalt het werk weg in plaats van het uit te stellen. Voor componenten die echt interactief moeten zijn maar niet meteen, gebruik je next/dynamic.

Suspense helpt bij het opstarten. Met selective hydration worden de delen van de pagina waar iemand daadwerkelijk op klikt als eerste interactief, in plaats van te wachten tot de hele boom klaar is.

Deze API's helpen specifiek voor INP. Voor FID doen ze niets, want Google heeft die metric in maart 2024 vervangen. Komt een artikel over React-performance nog met FID-benchmarks, dan dateert het van vóór die wissel en meet het de verkeerde dingen.

Geen idee of dit op jouw site speelt? Onze gratis benchmark laat je INP zien op basis van echte Chrome-gebruikersdata, in ongeveer 30 seconden.

"INP issues detected on your sites" in Search Console

Heb je een mail van Google gehad met als onderwerp "Core Web Vitals INP issues detected on your sites", of zie je die melding in Search Console staan? Dit is wat het betekent, en wat niet.

INP verving FID in maart 2024 als Core Web Vital voor responsiviteit. De melding is dus geen waarschuwing voor iets wat eraan komt. Google zegt dat echte Chrome-gebruikers nu al trage interacties ervaren, op pagina's die al op deze metric beoordeeld worden.

INP-melding in Google Search Console

Het rapport groepeert je URL's en geeft ze een van twee niveaus, gelijk aan de INP-drempelwaarden:

  • Moet worden verbeterd, oftewel boven de 200 ms
  • Slecht, oftewel boven de 500 ms

Mobiel wordt veel vaker gemeld dan desktop, en dat is geen meetfout. Telefoonprocessors zijn trager, dus dezelfde JavaScript blokkeert de main thread langer. Bij veelvoorkomende oorzaken van slechte INP zie je waar die tijd aan opgaat.

Het rapport vertelt je waar, niet waarom

Search Console laat zien welke groepen URL's problemen hebben. Welke interactie, welke handler of welk script de boosdoener is, staat er niet bij. Daarvoor heb je het Performance-paneel in DevTools nodig met throttling aan, of veldmetingen die vastleggen op welk element geklikt werd.

Core Web Vitals INP-rapport in Google Search Console

Heb je een fix uitgerold, dan kun je Google vragen die te valideren. Reken wel op geduld. Het rapport draait op CrUX, dat de afgelopen 28 dagen aan veldmetingen gebruikt, dus een fix van vandaag kan pas weken later schoon uitkomen. Precies daarom is het verstandig om daarnaast je eigen real user monitoring te draaien: die laat het effect van een release dezelfde dag zien, en wijst de vertraging toe aan een concreet element in plaats van aan een groep URL's.

INP monitoren over tijd

INP is het Core Web Vital dat het moeilijkst te monitoren is, omdat het varieert per gebruikersgedrag. Verschillende gebruikers starten verschillende interacties.

Problematische interacties tracken

import {onINP} from 'web-vitals';

onINP((metric) => {
  // Only report poor INP (> 200ms)
  if (metric.value > 200) {
    const interaction = metric.entries[0];

    // Send detailed context to analytics
    analytics.track('poor_inp', {
      inp_value: metric.value,
      interaction_type: interaction.name,
      target_element: interaction.target?.tagName,
      target_id: interaction.target?.id,
      target_class: interaction.target?.className,
      page_url: location.pathname,
      user_agent: navigator.userAgent
    });
  }
});

Alerts instellen

  • CrUX API: Bewaak je 75e-percentiel INP wekelijks
  • RUM-drempelwaartealarmen: Ontvang een melding als INP boven de 200 ms uitkomt voor meer dan 10% van de gebruikers
  • Regressiedetectie: Alert bij een INP-stijging van meer dan 50 ms na een deployment

Performance budgets

  • INP: Onder 200 ms (75e percentiel)
  • JavaScript-bundelgrootte: Onder 300 KB (gecomprimeerd)
  • Lange taken: Geen taken boven 200 ms
  • Scripts van derden: Maximaal 5 in totaal

INP-optimalisatiechecklist

Nog steeds slechte INP?

INP is het moeilijkst te optimaliseren Core Web Vital. Wij kunnen je interacties profileren, knelpunten identificeren en bewezen fixes implementeren. Neem contact op of voer een gratis benchmark uit om je huidige INP te zien.

Hulp nodig bij het verbeteren van je INP?

Wij identificeren JavaScript-knelpunten en implementeren optimalisaties die je website direct responsief laten aanvoelen.