Development & Design

How to Improve INP in JavaScript-Heavy Interfaces Without Rewriting Your App

By Dev 001
How to Improve INP in JavaScript-Heavy Interfaces Without Rewriting Your App

Poor Interaction to Next Paint almost always has the same root cause: when a user clicks, taps or types, the browser's main thread is busy with JavaScript or rendering work, and nothing visible can change until it finishes. The fix is rarely a rewrite. It is a small set of changes applied in the right order: find which phase of the interaction is slow, show visible feedback before doing expensive work, break long tasks so the browser can paint between them, and reduce the amount of DOM each interaction touches.

This guide covers those techniques in the context where INP problems cluster most: interfaces with lots of state and lots of DOM, such as filterable product grids, tabbed catalogues, data dashboards and complex forms. It also covers two things most INP guides skip. The first is what changed now that Firefox and Safari report the metric. The second is the blind spot that iframes create in your own measurements.

How to Improve INP in JavaScript-Heavy Interfaces Without Rewriting Your App

What INP measures, briefly

INP observes every click, tap and key press during a page visit, and reports a value close to the slowest one. More precisely, for pages with many interactions it discards the worst outliers. Each interaction's latency runs from the user's input until the browser paints the next frame after the handlers complete. That time breaks into three phases:

  • Input delay: the time before your event handler starts, usually because the main thread is busy with another task.

  • Processing time: the time spent in the event handlers themselves.

  • Presentation delay: the time from the end of the handlers until the frame is painted: style calculation, layout, paint and compositing for everything the handlers changed.

Google's thresholds are measured at the 75th percentile of page visits: 200 ms or less is good, over 500 ms is poor. INP replaced First Input Delay as a Core Web Vital in March 2024. The key difference is that FID measured only the input delay of the first interaction, while INP includes all three phases for all interactions. Pages that passed FID easily often fail INP, because their slowest moments come later in the session and in the processing and rendering phases.

Diagnosis starts with knowing which phase dominates, because each needs a different fix.

What changed in 2025 and 2026

For its first two years, INP was effectively a Chrome metric. The data came from Chrome users, and the Event Timing API features needed to compute it were missing elsewhere. That changed as part of the cross-browser Interop 2025 effort:

  • Firefox 144 (October 2025) added the interactionId property that INP calculation depends on.

  • Safari 26.2 (December 2025) shipped the Event Timing API, making INP measurable in Safari and in all iOS browsers, which use WebKit.

For teams running their own real-user monitoring, this is significant. iPhone users could make up a large share of traffic, but their interaction performance was previously invisible. Early reports noted that Safari's implementation still had bugs that could produce unreasonably high values. Treat Safari INP data as useful for relative comparisons before trusting absolute numbers.

One gap remains. scheduler.yield(), the cleanest API for breaking up long tasks, is supported in Chromium (from Chrome 129) and in Firefox (from version 142), but not in Safari. Every yielding strategy needs a fallback, as shown below.

Why interactive interfaces are where INP breaks

Static content pages rarely have INP problems. The typical failures come from interfaces where a single interaction triggers a large amount of work:

  • A filter checkbox that re-filters, re-sorts and re-renders 500 product cards.

  • A tab that swaps one large grid for another.

  • A search box that recalculates results on every keystroke.

  • A dashboard control that recalculates several charts synchronously.

  • An "expand all" button that changes thousands of nodes.

Catalogue-style interfaces are the classic case: large grids of thumbnails organised into tabs and categories. The same pattern appears in streaming service homepages, e-commerce listings, app stores and game lobbies, including the Polish Crazy Tower kasyno homepage, where tabs switch between rows of game tiles. In any of these interfaces, how quickly the page responds to switching a tab depends less on the framework than on how much the page rebuilds on each switch. If the handler replaces a few hundred complex cards synchronously, the user sees nothing change until the style, layout and paint work for all of them is done. Measured as INP, that shows up mostly as processing time and presentation delay, not input delay.

This is also the pattern where "it feels fine on my machine" misleads the most. A developer's laptop handles a 150 ms task without anyone noticing. A mid-range Android phone can take several times longer for the same work, and INP is measured at the 75th percentile of real devices.

Fix 1: show feedback before doing the work

The single most effective change is conceptual: the next paint does not have to show the final result, only a response. INP ends at the first frame painted after the interaction. If that frame shows the tab as selected, a pressed button or a loading indicator, the interaction is fast, even if the full result arrives a moment later.

The mistake is doing everything in one synchronous handler:

js

tabButton.addEventListener('click', () => {
  setActiveTab(tabButton);          // cheap visual change
  const items = filterAndSort(data); // expensive
  renderGrid(items);                 // very expensive DOM work
});

Nothing is painted until renderGrid finishes, so the whole handler counts as processing time, followed by a large presentation delay. Separate the cheap feedback from the expensive work and give the browser a chance to paint between them:

js

tabButton.addEventListener('click', async () => {
  setActiveTab(tabButton);   // runs now
  grid.setAttribute('aria-busy', 'true');

  await yieldToMain();       // browser can paint the selected tab

  const items = filterAndSort(data);
  renderGrid(items);
  grid.removeAttribute('aria-busy');
});

The aria-busy attribute is not decoration. It tells assistive technology that the region is updating, which matters when the visual result arrives later than the interaction.

Fix 2: yield to the main thread, with a fallback

Yielding means ending the current task so the browser can handle pending input and render a frame, then continuing the work in a new task. The standard pattern uses scheduler.yield() where available and falls back to setTimeout:

js

function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

The difference matters. scheduler.yield() schedules the continuation ahead of other ordinary tasks, so your work resumes promptly after the browser handles input and rendering. A setTimeout continuation goes to the back of the task queue, where third-party scripts can run first. The fallback still helps INP, but the rest of the work may take longer to complete.

For long loops, such as processing a large dataset, yield periodically rather than after every item. Yielding has overhead, so a time budget works better than a fixed count:

js

async function processInChunks(items, processItem) {
  let deadline = performance.now() + 40;

  for (const item of items) {
    processItem(item);

    if (performance.now() >= deadline) {
      await yieldToMain();
      deadline = performance.now() + 40;
    }
  }
}

A 40–50 ms budget keeps each task below the 50 ms threshold that browsers treat as a long task.

Yielding does not reduce the total amount of work. It makes the page responsive while the work happens. If the work itself is excessive, you still need the next fix.

Fix 3: render less on each interaction

Presentation delay grows with the amount of DOM an interaction changes and the amount of layout it invalidates. Three techniques address it directly.

Let your framework prioritise the update. In React 18 and later, wrapping a non-urgent state update in startTransition marks it as interruptible. React can keep responding to input while it renders the new result, and can discard an out-of-date render if the user clicks again:

jsx

import { useState, startTransition } from 'react';

function Catalogue({ items }) {
  const [tab, setTab] = useState('popular');
  const [visibleTab, setVisibleTab] = useState('popular');

  function selectTab(next) {
    setTab(next);                               // urgent: highlight the tab
    startTransition(() => setVisibleTab(next)); // non-urgent: swap the grid
  }

  return (
    <>
      <Tabs active={tab} onSelect={selectTab} />
      <Grid items={items[visibleTab]} dimmed={tab !== visibleTab} />
    </>
  );
}

For text inputs that filter large lists, useDeferredValue does the same thing for derived values. Other frameworks have their own equivalents. The underlying idea is the same: separate the urgent visual response from the expensive result.

Render fewer nodes. Virtualising long lists, so that only visible rows are in the DOM, is the most reliable fix for very large grids. For moderately sized pages, CSS content-visibility: auto lets the browser skip rendering work for off-screen sections until they approach the viewport. It is now supported in all major engines:

css

.catalogue-row {
  content-visibility: auto;
  contain-intrinsic-size: auto 320px;
}

contain-intrinsic-size gives skipped sections a placeholder size, so the scrollbar and layout stay stable.

Avoid forced synchronous layout. Reading layout properties such as offsetHeight or getBoundingClientRect() after changing the DOM forces the browser to calculate layout immediately, inside your handler. In loops this becomes layout thrashing: write, read, recalculate, repeated hundreds of times. Batch reads before writes, or move measurements into requestAnimationFrame callbacks, so the browser recalculates once.

The blind spot, interactions inside iframes

This is where many teams' data stops matching reality.

Chrome's field data, which is what Google uses in the Chrome User Experience Report (CrUX) and in Search Console, measures INP from the user's point of view. Users do not know whether they are clicking inside an iframe, so interactions within iframes count towards the INP of the top-level page. A slow embedded video player, map, chat widget, payment form or embedded third-party application can make your page fail INP in CrUX.

Your own measurements cannot see this. For security and privacy reasons, the top-level page has no access to performance data from within iframes, not even same-origin ones. The web-vitals library, real-user monitoring products and any PerformanceObserver on your page measure only interactions in your own document. The result is a familiar and confusing situation: your RUM dashboard says INP is fine, while CrUX says the page fails.

If your page embeds interactive third-party content, three steps help:

  1. Suspect the iframes when CrUX and RUM disagree. A consistent gap on pages with embedded widgets is a strong clue.

  2. Measure inside frames you control. If the embedded content comes from your own subdomain or another service you run, measure INP within that document and report it separately.

  3. Defer embeds until they are needed. A static preview that loads the real embed only on click, often called a facade, removes the embed's startup work from the main page. Many embeds run heavy scripts during load that can delay interactions elsewhere, even when the embed shares the main thread only indirectly.

For third-party embeds you do not control, the best you can do is load them lazily and choose providers whose widgets respond quickly.

Why AI-generated interfaces often fail INP

AI coding tools generate interactive UI quickly, and the output typically works correctly in a demo. INP problems stay hidden in that setting for structural reasons.

Generated handlers tend to be synchronous and complete: update state, filter, sort and render, all in one function. That is the natural way to write "when the user clicks the tab, show the tab's items", and it is the pattern that fails INP at scale. Yielding, transitions and deferred values are not required for correctness, so a model has no strong reason to add them unless asked.

Demo data hides the cost. An interface generated and tested against 20 sample items feels instant. The same code with 800 real items on a mid-range phone may not. Agents that verify their work by running unit tests or taking a screenshot on a fast development machine do not see the difference either.

The practical countermeasures are the same ones you would apply to human code, made explicit:

  • Put the constraint in the instructions. Tell the agent that handlers must update visible state first and move expensive work behind a yield or a transition, and that lists over a certain size must be virtualised.

  • Test with production-sized data, with CPU throttling enabled in DevTools.

  • Put a check in CI. Lab tools cannot measure INP directly without real interactions, but browser automation can script the key interactions, such as switching tabs and applying filters, and record Event Timing durations. Any interaction over budget fails the build.

How to Improve INP in JavaScript-Heavy Interfaces Without Rewriting Your App

Measuring the fix

Field data tells you whether a problem exists. Attribution tells you where. The web-vitals library's attribution build reports which interaction was slowest, which element was the target, and how long each phase took:

js

import { onINP } from 'web-vitals/attribution';

onINP(({ value, attribution }) => {
  sendToAnalytics({
    inp: value,
    target: attribution.interactionTarget,
    inputDelay: attribution.inputDelay,
    processing: attribution.processingDuration,
    presentation: attribution.presentationDelay,
  });
});

The phase breakdown shows which fix to try first. High input delay points to other long tasks: third-party scripts, hydration, timers. High processing time points to your handlers. High presentation delay points to the amount of rendering work.

In Chromium browsers, the Long Animation Frames API gives richer attribution: which scripts ran during slow frames, and how long style and layout took. It is Chromium-only, so treat it as a diagnostic tool rather than a cross-browser metric.

In the lab, the Chrome DevTools Performance panel shows interactions in a dedicated track, and marks long tasks and forced layouts directly. Record the interaction with CPU throttling at 4× or 6× to approximate a mid-range phone. An interaction that takes 80 ms on your laptop often turns into 400 ms there.

What to watch

The direction is clear. Interaction metrics are now cross-browser, and cross-browser field data will expose performance problems on devices many teams have never measured. The open items for the coming year are whether Safari adds the Scheduler API, how quickly its early INP implementation stabilises, and how much the growing volume of AI-generated frontend code affects the web's overall responsiveness.

The fixes won't change much. Respond visibly first, then do the heavy work in pieces the browser can interleave with input and painting, and rebuild only what the interaction actually changed.