Fixing Poor INP on Static Pages With Heavy Widgets

INP — Interaction to Next Paint — measures how long the page takes to respond visibly when a reader clicks, taps or types. On a static site most pages have nothing to interact with beyond links, and INP is excellent. The problem pages are the ones with a widget: a filterable table of 2,000 rows, a pricing calculator, an embedded code playground, a search modal. INP is the slowest interaction in the visit (roughly — the 98th percentile for pages with many interactions), so one slow widget makes the whole page "poor" in the Core Web Vitals report, however fast everything else is.

This guide fixes INP on four widget-heavy pages of an Eleventy and Preact documentation site, where field INP at the 75th percentile was 380 ms on mobile for those pages against 70 ms for the rest. It uses field attribution to find the slow interactions, then fixes input delay, processing time and presentation delay separately. It belongs to JavaScript Hydration & Partial Rendering; field measurement is set up in Measuring INP on Static Sites with RUM.

Step 1: Attribute INP in the Field

The web-vitals library's attribution build reports where the time went:

import { onINP } from 'web-vitals/attribution';
onINP(({ value, attribution: a }) => {
  navigator.sendBeacon('/rum', JSON.stringify({
    metric: 'INP', value, page: location.pathname,
    target: a.interactionTarget, type: a.interactionType,
    inputDelay: a.inputDelay, processing: a.processingDuration, presentation: a.presentationDelay,
    longestScript: a.longAnimationFrameEntries?.[0]?.scripts?.[0]?.sourceURL,
  }));
});

Grouped by target over two weeks on the four pages:

TargetPageINP p75Input delayProcessingPresentation
#compat-table input.filterCompatibility table520 ms40 ms410 ms70 ms
button.calc-planPricing calculator310 ms230 ms50 ms30 ms
.playground .runCode playground290 ms20 ms60 ms210 ms
button.chat-launcherAny page with chat260 ms190 ms50 ms20 ms

Each widget had a different dominant phase, so each needed a different fix.

Input delay and processing time per widget interaction Paired bars of input delay and processing time in milliseconds. The compatibility table filter had 40 milliseconds input delay and 410 processing. The pricing calculator button 230 and 50. The playground run button 20 and 60, with 210 milliseconds of presentation delay not shown. The chat launcher 190 and 50. Where each slow interaction spent its time Input delay Processing Table filter 40 ms 410 ms Calculator button 230 ms 50 ms Playground run 20 ms 60 ms Chat launcher 190 ms 50 ms
Field INP attribution, 75th percentile per target, two weeks of mobile data.

Fix 1: Long Processing — the Filterable Table

Typing in the filter box re-filtered 2,100 rows and re-rendered the whole table on every keystroke: 410 ms of processing on a mid-range phone. Three changes:

  • Show the typed character first. The input is uncontrolled, so its value updates immediately; the filter runs after a 120 ms debounce rather than on every key.
  • Yield before the heavy work. The filter function yields to the main thread before rendering, so the browser can paint the input's new state first:
async function applyFilter(q) {
  status.textContent = 'Filtering…';
  await (globalThis.scheduler?.yield?.() ?? new Promise((r) => setTimeout(r, 0)));
  const rows = filterRows(q);           // 18 ms on a throttled CPU
  renderRows(rows.slice(0, 200));       // render only what's visible
}
  • Render less. The table renders the first 200 matching rows with a "show more" button, instead of all matches. Rendering, not filtering, had been 85% of the cost.

Fix 2: Input Delay — the Pricing Calculator

The calculator's handler was fast; the problem was what the main thread was doing when readers clicked. The calculator was hydrated with client:load-style eager hydration together with three other Preact components on the page, and readers who clicked within the first two seconds waited for hydration to finish. Moving the calculator to hydrate on visibility, and the other components (a testimonial carousel and an FAQ accordion, both below the fold) to hydrate on idle, cleared the main thread during the window when most clicks happened:

<is-land on:visible><pricing-calculator>…</pricing-calculator></is-land>
<is-land on:idle><testimonial-carousel>…</testimonial-carousel></is-land>

The site uses Eleventy's <is-land> component for partial hydration; Astro's client:visible and client:idle are the equivalents.

Fix 3: Presentation Delay — the Code Playground

Clicking "Run" executed user code in a sandboxed iframe (fast) and then appended the output to a console panel that triggered a full-page layout: the panel had no fixed height, so its growth reflowed the whole article, and an animation on the output used top instead of transform. Giving the console a fixed height with internal scrolling, and animating with transform and opacity, cut presentation delay from 210 ms to 30 ms. content-visibility: auto on long article sections below the playground reduced the layout work further.

Which fix addresses which phase of a slow interaction Four steps. Measure INP in the field with attribution by target. If input delay dominates, defer hydration and third-party work out of the early window. If processing dominates, debounce, yield before heavy work and render less. If presentation dominates, contain layout with fixed sizes, transform-based animation and content-visibility. Then confirm in the field after two weeks. Matching the fix to the phase Field attribution by target and phase Input delay defer hydration + 3P Processing debounce, yield, render less Presentation contain layout Confirm in field two weeks later
Each of the four widgets was dominated by a different phase.

Fix 4: Input Delay From a Third Party — the Chat Widget

The chat launcher was slow to respond because the chat vendor's 380 KB script was still initialising when readers clicked it. Replacing the launcher with a facade — a lightweight static button that loads the real widget on first click and shows a loading state immediately — removed the vendor script from the initial load entirely. The click now paints the "Connecting…" state in under 50 ms, and the widget follows. Fixing Main-Thread Blocking From Chat Widgets covers this widget in detail.

Step 2: Verify in the Lab, Confirm in the Field

Each fix was checked first in the Performance panel with 4× CPU throttling, recording the interaction and checking the long animation frame it produced, then confirmed in field data two weeks after release — the lab is reliable for processing and presentation, but input delay depends on real timing of when readers click, which only the field shows.

Keeping New Widgets Honest

Widgets are added over time, often by different teams, so the site now treats INP per page as a release check rather than a one-off project. Every widget page has a Playwright test that performs its main interaction with 4× CPU throttling and reads the Event Timing entry for it:

const dur = await page.evaluate(() => new Promise((res) => {
  new PerformanceObserver((l) => res(Math.max(...l.getEntries().map((e) => e.duration)))).observe({ type: 'event', durationThreshold: 16, buffered: true });
}));
expect(dur).toBeLessThan(200);

Lab numbers on a throttled desktop CPU are not field numbers, but they move in the same direction, and a widget whose main interaction takes 400 ms under throttling will be poor on real phones. The field dashboard, grouped by interaction target, remains the source of truth and is reviewed monthly alongside LCP and CLS, as in Building a Core Web Vitals Dashboard from RUM Data.

Measured Impact

PageINP p75 beforeINP p75 afterTool
Compatibility table520 ms140 msweb-vitals RUM, mobile
Pricing310 ms110 msweb-vitals RUM, mobile
Playground290 ms120 msweb-vitals RUM, mobile
Pages with chat260 ms90 msweb-vitals RUM, mobile
Origin INP (CrUX)230 ms ("needs improvement")120 ms ("good")CrUX, 28 days
Field INP at the 75th percentile after the fixes, per page Vertical bars of INP after fixes. Compatibility table 140 milliseconds. Pricing 110. Playground 120. Pages with chat 90. All are under the 200 millisecond threshold for good. INP after the fixes 140 ms Compat table 110 ms Pricing 120 ms Playground 90 ms Chat pages
web-vitals RUM on mobile, two weeks after each fix; before values were 520, 310, 290 and 260 milliseconds.

Pitfalls & Rollback

  • Optimising handlers when input delay dominates. Check the attribution split first.
  • Rendering every row. Render what is visible; offer more on demand.
  • Eager hydration of below-the-fold widgets. Hydrate on visible or idle.
  • Layout-triggering animations. Use transform and opacity, and give growing panels fixed sizes.
  • Rollback: each fix is local to one widget; field attribution shows immediately which page regresses if one is reverted.

Conclusion

A static page with poor INP almost always has one heavy widget, and field attribution says which phase of its interaction is slow. Debouncing, yielding and rendering less fixed a 410 ms filter handler; deferring hydration fixed a calculator blocked by its neighbours; containing layout fixed a playground's slow paint; and a facade fixed a chat launcher. The origin moved from 230 ms to 120 ms and into "good".

FAQ

Why does a static page have poor INP?

Because the page is static but its widgets are not. A filterable table, a calculator, a code playground or a chat widget can run long JavaScript tasks when the reader interacts with them or while they hydrate. INP measures the slowest interaction in the visit, so one heavy widget is enough to make the whole page's INP poor.

How do I find which interaction is slow?

Collect INP in the field with attribution using the web-vitals library, which reports the interaction target, the event type, and how the time splits into input delay, processing and presentation. Group by target selector to see which widget is responsible, then reproduce it in the Performance panel with CPU throttling.

What is the difference between input delay and processing time?

Input delay is time the interaction waits because the main thread is busy with something else, such as hydration or a third-party script. Processing time is the event handlers themselves. Presentation delay is the rendering work after the handlers. Each points at a different fix, so the attribution split matters.

Does yielding to the main thread really help INP?

Yes, when processing is long. Splitting a 300 millisecond handler into chunks with scheduler.yield or a setTimeout lets the browser paint the response to the interaction first and continue the rest afterwards. INP measures until the next paint, so getting a visible update out early is what counts.

Filed underFixes