Fix Event Handler Delays for Better INP

Slow event handlers directly hurt INP. Optimize click, input, and keypress handlers for instant feedback.
Harlan WiltonHarlan Wilton6 min read Published

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:

  1. User clicks submit button
  2. Handler validates 15 form fields (40ms)
  3. Handler prepares API payload (20ms)
  4. Handler starts async request
  5. Handler tracks analytics event (30ms)
  6. Handler finally updates button text
  7. 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.

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

Next.js / ReactUse 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} />}
    </>
  )
}
React 18's concurrent features let urgent updates interrupt non-urgent ones.
Nuxt / VueUse 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>
For expensive computed properties, consider computedAsync from VueUse or moving logic to a worker.

Verify the Fix

After changes:

  1. Monitor processing duration with web-vitals—target under 100ms
  2. Record Performance traces—verify visual feedback appears in first frame after input
  3. Compare INP in field data before and after deployment
TBTTotal Blocking Time
30% weight
Good ≤200msPoor >600ms

Moving 150ms of handler work after visual feedback reduces perceived INP by 150ms.

Common Mistakes

Awaiting before visual feedbackawait 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:

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.