Event handlers run synchronously on the main thread. When a user clicks and your handler does 200ms of work before updating the UI, that's 200ms of perceived lag—even if the operation completes successfully.
INP measures from input to next paint: input delay + processing duration + presentation delay. Event handler code runs during processing duration. Heavy handlers directly increase INP.
The timeline for a slow interaction:
The user waited 90ms staring at an unchanged button before seeing "Submitting..." feedback.
Common causes of handler delays:
Chrome's analysis of real-world INP shows processing duration accounts for 40-50% of total INP on many sites.
Use the web-vitals library to measure processing duration:
import { onINP } from 'web-vitals/attribution'
onINP((metric) => {
const { processingDuration, interactionTarget } = metric.attribution
if (processingDuration > 100) {
console.warn('Slow handler:', interactionTarget, `${processingDuration}ms`)
}
})
In Chrome DevTools, record a Performance trace during the interaction. Find the click/keydown event in the main thread, expand it, and see exactly which functions consumed time before the paint.
Update the UI first, then do everything else. The browser paints as soon as JavaScript yields.
// Before: user waits 150ms for visual feedback
submitButton.onclick = async () => {
const isValid = validateAllFields() // 50ms
if (!isValid)
return showErrors()
const payload = preparePayload() // 30ms
trackEvent('submit_clicked') // 20ms
submitButton.textContent = 'Submitting...' // Finally visible
await sendToServer(payload)
}
// After: feedback in <10ms, work continues after
submitButton.onclick = async () => {
submitButton.disabled = true
submitButton.textContent = 'Submitting...'
// Yield to browser, then continue
requestAnimationFrame(() => {
setTimeout(async () => {
const isValid = validateAllFields()
if (!isValid) {
submitButton.disabled = false
submitButton.textContent = 'Submit'
showErrors()
return
}
const payload = preparePayload()
trackEvent('submit_clicked')
await sendToServer(payload)
}, 0)
})
}
The requestAnimationFrame + setTimeout pattern ensures the browser paints before heavy work continues.
scheduler.yield() to Break Up WorkThe Scheduler API (Chrome 115+) provides cleaner yielding:
async function handleClick() {
// Immediate feedback
button.classList.add('active')
// Yield to let browser paint
await scheduler.yield()
// Heavy work after paint
const result = expensiveComputation()
updateResults(result)
}
For browsers without scheduler support:
function yieldToMain() {
return new Promise(resolve =>
setTimeout(resolve, 0)
)
}
async function handleClick() {
button.classList.add('active')
await yieldToMain()
// Continue work
}
Input, scroll, and mousemove events fire rapidly. Debouncing reduces handler execution:
function debounce(fn, delay) {
let timeout
return (...args) => {
clearTimeout(timeout)
timeout = setTimeout(() => fn(...args), delay)
}
}
const handleSearch = debounce((query) => {
const results = searchIndex(query) // Expensive
renderResults(results)
}, 150)
searchInput.oninput = (e) => {
// Always show immediate feedback
showSearchingIndicator()
// Debounced expensive work
handleSearch(e.target.value)
}
For scroll handlers, use throttle instead—it executes periodically rather than only after quiet periods.
Computation-heavy operations belong in a worker:
// worker.js
self.onmessage = ({ data }) => {
const result = heavyComputation(data)
self.postMessage(result)
}
// main.js
const worker = new Worker('worker.js')
button.onclick = () => {
button.textContent = 'Processing...'
worker.postMessage(inputData)
worker.onmessage = ({ data }) => {
displayResults(data)
button.textContent = 'Done'
}
}
Workers run on a separate thread—computation doesn't block interactions at all.
localStorage and sessionStorage are synchronous and can take 10-50ms on slow devices:
// Before: blocks thread during write
button.onclick = () => {
localStorage.setItem('preferences', JSON.stringify(bigObject))
showConfirmation()
}
// After: write after paint
button.onclick = () => {
showConfirmation()
requestIdleCallback(() => {
localStorage.setItem('preferences', JSON.stringify(bigObject))
})
}
For larger data, use IndexedDB with a library like idb—it's asynchronous.
Run fast validation immediately, defer expensive validation:
function quickValidate(form) {
// Fast: required fields, basic format
return form.email.value && form.email.value.includes('@')
}
async function fullValidate(form) {
// Slow: API calls, complex rules
const emailAvailable = await checkEmailAvailability(form.email.value)
const passwordStrong = assessPasswordStrength(form.password.value)
return emailAvailable && passwordStrong
}
form.onsubmit = async (e) => {
e.preventDefault()
// Fast check provides immediate feedback
if (!quickValidate(form)) {
showError('Please fill required fields')
return
}
// Show loading, then do full validation
showLoading()
await scheduler.yield()
if (!await fullValidate(form)) {
hideLoading()
showDetailedErrors()
return
}
await submitForm(form)
}
useTransition to mark state updates as non-urgent:import { useState, useTransition } from 'react'
function SearchResults() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isPending, startTransition] = useTransition()
function handleChange(e) {
// Urgent: update input immediately
setQuery(e.target.value)
// Non-urgent: can be interrupted
startTransition(() => {
setResults(search(e.target.value))
})
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending ? <Spinner /> : <Results data={results} />}
</>
)
}
nextTick to defer work until after Vue's reactivity updates:<script setup>
const isLoading = ref(false)
const results = ref([])
async function handleSearch(query) {
// Immediate feedback
isLoading.value = true
// Wait for Vue to update DOM
await nextTick()
// Heavy work after paint
results.value = await search(query)
isLoading.value = false
}
</script>
computedAsync from VueUse or moving logic to a worker.After changes:
Moving 150ms of handler work after visual feedback reduces perceived INP by 150ms.
Awaiting before visual feedback — await doesn't yield to the browser. Visual updates before any await still wait for the entire async function setup.
// Still blocks until await is reached
button.onclick = async () => {
heavySetup() // 100ms
await asyncWork() // Browser can't paint until here
}
Putting heavy work in requestAnimationFrame — rAF callbacks run before paint. Put expensive work in setTimeout inside rAF.
Forgetting mobile devices — A 50ms handler on desktop becomes 150ms+ on a mid-range phone. Always test throttled.
Over-debouncing — Debounce delays of 300ms+ make interfaces feel sluggish. Keep delays under 200ms for search, 100ms for autocomplete.
Event handler delays often compound with other INP causes:
Different interactions have different handler complexity. A simple navigation click behaves differently than a complex form submission. Unlighthouse surfaces TBT (INP proxy) issues across your entire site, helping identify which pages have the slowest interactions.