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.

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
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 restitutieverzoeken.
Formulierabandonment
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:
- Iron/Out gratis benchmark — haal je INP op uit het Chrome UX Report
- Google Search Console — het Core Web Vitals-rapport toont INP per paginagroep
- Real User Monitoring — volg INP voor elke bezoeker met onze observability-implementatie
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 automaticallyINP programmatisch meten
Installeer de web-vitals library:
npm install web-vitalsTrack 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 thrashing4. 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.