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. Gebruikers gaan ervan uit dat het veld niet werkt en vertrekken. Je afrekenpercentage daalt met 15%.

Mobiele gebruikers het hardst geraakt

Midrange Android-toestellen (60% van je mobiele verkeer) laten 3–5 keer slechtere INP zien dan je MacBook Pro. Je optimaliseert voor het verkeerde apparaat.

Concrete cijfers: We volgden een e-commercewebsite die INP verbeterde van 520 ms naar 180 ms. Formulierafronding steeg met 11%. Dubbele bestellingen daalden met 73%. Supporttickets over "kapotte knoppen" verdwenen volledig.

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')
  }
];

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 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.