WritingINP Optimization: Breaking Long Tasks to Unblock the Main Thread — Clixo
7 min readcore-web-vitals, inp, javascript-performance, web-performance

INP Optimization: Breaking Long Tasks to Unblock the Main Thread

Deep dive into Interaction to Next Paint optimization. Learn how to identify long JavaScript tasks, use scheduler.yield, and structure event handlers to hit the 200ms INP threshold.

Your page loads fast. LCP is green. CLS is clean. But users report the site feeling sluggish — clicks feel delayed, dropdowns stutter, form inputs lag. The culprit is almost always Interaction to Next Paint. INP became a Core Web Vital in March 2024, replacing First Input Delay, and it is now the most commonly failed metric. Understanding why requires a mental model shift: from "how fast does the page load" to "how fast does the page respond."

What Interaction to Next Paint Measures

INP measures the time from when a user interaction occurs — a click, a keypress, a tap — to when the browser finishes painting the next frame in response. It captures all interactions throughout the entire page lifetime, not just the first one, and reports the worst-case interaction at the 98th percentile.

The threshold: 200ms or under is good. 200–500ms needs improvement. Above 500ms is poor.

Three sub-phases make up every INP interaction:

  1. Input delay — time from the interaction event firing to when the event handler starts running. Caused by long tasks blocking the main thread.
  2. Processing time — time the event handler itself takes to execute.
  3. Presentation delay — time from when the handler finishes to when the browser paints the result.

Most INP problems live in input delay or processing time. Both are caused by the same root issue: long JavaScript tasks blocking the browser's main thread.

Why Long Tasks Cause Slow INP

JavaScript is single-threaded. The browser uses the main thread for JavaScript execution, style calculation, layout, and painting. When a script runs a task that takes more than 50ms — parsing a large dataset, rendering a complex component tree, processing a third-party analytics payload — the main thread is busy and cannot respond to user input until that task finishes.

A user who clicks a button during a 200ms JavaScript task will wait up to 200ms before their click is even acknowledged. That input delay, plus the handler execution time, plus the paint, easily pushes past 500ms.

INP Optimization: Breaking Long Tasks

Identify Long Tasks First

Before optimising, locate the problem. Chrome DevTools Performance panel marks long tasks with a red triangle in the Main thread flame chart. Tasks that exceed 50ms are long tasks by definition. Look for dense clusters of long tasks during user interactions.

The PerformanceObserver API lets you monitor long tasks programmatically in production:

const observer = new PerformanceObserver((list) => {
  list.getEntries().forEach((entry) => {
    if (entry.duration > 50) {
      console.log('Long task:', entry.duration, entry.startTime);
    }
  });
});
observer.observe({ type: 'longtask', buffered: true });

Ship this to your RUM pipeline to get field data on which tasks are blocking interactions in production, not just in your dev machine.

Use scheduler.yield to Break Work Into Chunks

The scheduler.yield() API lets you pause a long task mid-execution and yield control back to the browser. The browser can then handle pending user interactions before your code resumes.

async function processLargeList(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);
 
    // Yield every 50 items to let the browser breathe
    if (i % 50 === 0) {
      await scheduler.yield();
    }
  }
}

scheduler.yield() is prioritised — it resumes your continuation before other queued tasks, which makes it preferable to setTimeout(fn, 0) for chunked work. Browser support is good across modern Chromium, with a fallback polyfill available.

Defer Non-Critical Work in Event Handlers

Not all the work in a click handler needs to run before the browser paints a response. Separate the work into two buckets:

  1. Critical: state updates and DOM changes the user needs to see in the immediate next frame
  2. Deferrable: logging, analytics, secondary data fetches, cache updates

Move deferrable work into a microtask or a separate scheduler.postTask call with priority: 'background':

button.addEventListener('click', async (event) => {
  // Critical: update UI immediately
  updateButtonState('loading');
 
  // Deferrable: log analytics after yield
  await scheduler.yield();
  logAnalyticsEvent('button_click', { id: event.target.id });
});

This keeps processing time short for the critical path, which directly reduces INP.

Move Heavy Work Off the Main Thread with Web Workers

Some computation is genuinely expensive: parsing large JSON, running search indexing, compressing data, applying filters to arrays with millions of entries. None of this needs the main thread. Move it to a Web Worker.

Web Workers run JavaScript in a background thread. They cannot access the DOM directly, but they can communicate results to the main thread via postMessage. The main thread stays free for user interactions while the Worker does the heavy lifting.

// worker.js
self.onmessage = (e) => {
  const result = heavyComputation(e.data);
  self.postMessage(result);
};
 
// main.js
const worker = new Worker('worker.js');
worker.postMessage(largeDataset);
worker.onmessage = (e) => renderResult(e.data);

Libraries like Comlink simplify the Worker communication model considerably.

Minimise DOM Size and Style Complexity

Every interaction that triggers a style recalculation or layout pass is slower on pages with large DOM trees. A DOM with 3,000–5,000 nodes causes browsers to do significantly more work per interaction than one with 500 nodes.

Audit your DOM size in DevTools under Performance Insights. Common culprits:

  • Rendered-but-hidden UI (modal markup always in the DOM)
  • Infinite scroll that accumulates nodes without removing off-screen ones
  • Deeply nested component trees from JavaScript frameworks

Use virtualisation for long lists (only render visible rows). Defer modal markup until the modal is actually opened. Remove off-screen nodes in infinite scroll by unmounting them.

Audit Third-Party Scripts

Third-party scripts — tag managers, A/B testing tools, chat widgets, ad scripts — frequently run large tasks on the main thread without yielding. They are common INP villains because they run on every page and developers do not control their internals.

Strategies:

  • Load third-party scripts with defer or async to avoid blocking parse.
  • Use Partytown to move third-party scripts to a Web Worker.
  • Audit which third-party scripts actually contribute to your business goals. Every script that does not justify its INP cost is a candidate for removal.

A Practical INP Debugging Workflow

  1. Open Chrome DevTools and record a Performance trace while clicking the element that feels slow.
  2. Find the interaction in the Interactions lane and expand it to see input delay, processing time, and presentation delay.
  3. Look for the long task responsible for input delay. What script or function owns it?
  4. Check processing time: what is the event handler actually doing? Can any of that work be deferred?
  5. Check presentation delay: is there a forced layout or style recalculation after the handler?

Fix the longest phase first. Repeat until the interaction is under 200ms in the lab, then verify with field data.

If your team has a JavaScript-heavy product — a React SPA, a Next.js app with complex interactivity, or a dashboard with real-time data — INP optimisation is often a codebase-level effort. Clixo builds and tunes production JavaScript applications. Talk to us if you want a concrete performance plan, not a checklist.