Fix Event Handler Delays for Better INP
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.
What's the Problem?
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:
- User clicks submit button
- Handler validates 15 form fields (40ms)
- Handler prepares API payload (20ms)
- Handler starts async request
- Handler tracks analytics event (30ms)
- Handler finally updates button text
- Browser paints — INP measured here (90ms+ processing)
The user waited 90ms staring at an unchanged button before seeing "Submitting..." feedback.
Common causes of handler delays:
- Synchronous validation — Form validation running before any UI update
- Data transformation — Serializing or processing data in the handler
- Analytics calls — Tracking events blocking UI updates
- State cascades — Multiple state updates triggering sequential re-renders
- Synchronous storage — Reading/writing localStorage or sessionStorage
Chrome's analysis of real-world INP shows processing duration accounts for 40-50% of total INP on many sites.
The "Not My Code" Problem: Input Delay
Sometimes, your event handler is innocent. You might optimize your click handler to 5ms, but the INP score is still 300ms. Why?
Input Delay.
If a user clicks while the main thread is already busy doing something else (e.g., parsing a third-party script, hydrating a footer), the browser can't even start your event handler until that task finishes.
- Scenario: Main thread is blocked for 200ms by
ad-script.js. - User Action: Clicks button at 100ms mark.
- Result: Browser waits 100ms (remaining blocking time) → Input Delay = 100ms.
- Your Code: Runs for 5ms.
- Total INP: 105ms + Presentation Delay.
If your "Processing Duration" is low but INP is high, look for other tasks running immediately before the interaction.
How to Identify the Issue
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.
The Fix
Show Immediate Visual Feedback
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.
Use scheduler.yield() to Break Up Work
The 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
}
Debounce High-Frequency Events
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.
Move Heavy Work to Web Workers
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.
Avoid Synchronous Storage in Handlers
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.
Split Validation Phases
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)
}
Framework-Specific Solutions
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.Verify the Fix
After changes:
- Monitor processing duration with web-vitals—target under 100ms
- Record Performance traces—verify visual feedback appears in first frame after input
- Compare INP in field data before and after deployment
Moving 150ms of handler work after visual feedback reduces perceived INP by 150ms.
Common Mistakes
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.
Related Issues
Event handler delays often compound with other INP causes:
- Long-Running JavaScript — Handlers calling expensive functions
- Heavy DOM Operations — Handler-triggered renders causing presentation delay
Test Your Entire Site
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.