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:
| Target | Page | INP p75 | Input delay | Processing | Presentation |
|---|---|---|---|---|---|
#compat-table input.filter | Compatibility table | 520 ms | 40 ms | 410 ms | 70 ms |
button.calc-plan | Pricing calculator | 310 ms | 230 ms | 50 ms | 30 ms |
.playground .run | Code playground | 290 ms | 20 ms | 60 ms | 210 ms |
button.chat-launcher | Any page with chat | 260 ms | 190 ms | 50 ms | 20 ms |
Each widget had a different dominant phase, so each needed a different fix.
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.
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
| Page | INP p75 before | INP p75 after | Tool |
|---|---|---|---|
| Compatibility table | 520 ms | 140 ms | web-vitals RUM, mobile |
| Pricing | 310 ms | 110 ms | web-vitals RUM, mobile |
| Playground | 290 ms | 120 ms | web-vitals RUM, mobile |
| Pages with chat | 260 ms | 90 ms | web-vitals RUM, mobile |
| Origin INP (CrUX) | 230 ms ("needs improvement") | 120 ms ("good") | CrUX, 28 days |
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
transformandopacity, 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.
Related
- Parent: JavaScript Hydration & Partial Rendering — hydration and interactivity on static sites.
- Measuring INP on Static Sites with RUM — the field data this relies on.
- Breaking Up Long Tasks on Static Sites — yielding in more depth.
- Fixing Main-Thread Blocking From Chat Widgets — the chat facade.
- Fixing Hydration Mismatch Errors in Astro Islands — another hydration cost.
Filed underFixes