Core Web Vitals for Programmatic Templates: The INP Fix That Moved Our Rankings
INP replaced FID in 2024 and by 2026 it's the Core Web Vital most programmatic sites still fail. Here's the template-level engineering that fixed it for us.
Interaction to Next Paint has been the Core Web Vital nobody wanted to deal with. It replaced FID in March 2024, and by 2026 we've watched a steady stream of programmatic sites — ours included at one point — lose the "Good" label on CrUX because of it. This is the breakdown of what actually moved the needle on a 180k-page template-driven site, not the generic "use requestIdleCallback" advice.
Why INP hits programmatic sites harder
FID measured the delay before the browser could start handling your first interaction. INP measures the full latency of every interaction across the page's lifetime, and reports roughly the worst one. That change matters for programmatic SEO sites specifically, because:
- Templates ship the same JS to every page. If one widget is slow, it's slow on 180k URLs.
- Programmatic pages tend to be dense: filter chips, comparison tables, dropdowns, "see more" toggles.
- Traffic is long-tail and mobile-heavy, so you're measured on mid-tier Android devices with thermal throttling.
In Google Search Console, a single template pattern failing INP can tank the Core Web Vitals report for tens of thousands of URLs at once. We saw a step-change in impressions on a cluster of comparison pages after fixing one event handler. Not a coincidence — but also not a silver bullet, and we'll get to the nuance.
What "Good" actually means in 2026
The thresholds haven't moved: INP under 200ms is Good, 200–500ms is Needs Improvement, above 500ms is Poor. The 75th percentile across the 28-day CrUX window is what Google uses. That p75 framing is the thing that trips teams up — your dev machine shows 60ms, but the Pixel 6a on a bad connection in CrUX shows 480ms.
Diagnose before you refactor
The mistake we made the first time was jumping straight to code splitting. We shaved 40KB off the bundle and INP didn't budge. The bottleneck wasn't download time — it was main-thread work during interaction.
Here's the diagnostic order we now use:
- CrUX / GSC to confirm which URL groups are failing at p75.
- Field data via
web-vitalslibrary sent to GA4 as a custom event, tagged with template name. - Chrome DevTools Performance panel with CPU throttle 4x and "Interactions" track enabled.
- Attribution build of web-vitals to log which element and which event produced the slow INP.
The attribution build is the one most teams skip, and it's the one that tells you the truth.
import { onINP } from 'web-vitals/attribution';
onINP(({ value, attribution }) => {
const payload = {
inp: value,
target: attribution.interactionTarget,
type: attribution.interactionType,
inputDelay: attribution.inputDelay,
processingDuration: attribution.processingDuration,
presentationDelay: attribution.presentationDelay,
template: document.documentElement.dataset.template,
};
navigator.sendBeacon('/vitals', JSON.stringify(payload));
});
Once you log interactionTarget and the three sub-phases (input delay, processing, presentation delay), the fix becomes obvious. In our case, 72% of slow INP events came from a single <select> on the comparison template, and the processing phase was the dominant cost.
Reading the three phases
- Input delay dominant → main thread is busy with other scripts. Look at third-party tags, hydration, analytics.
- Processing dominant → your event handler is doing too much synchronous work.
- Presentation delay dominant → the resulting DOM mutation is expensive to paint. Think large re-renders, forced layout, heavy CSS.
Each needs a different fix. Don't start refactoring until you know which one you have.
The template-level fixes that worked
On the comparison template, processing delay was the villain. The handler was recomputing a sort over ~800 rows synchronously, then re-rendering the full table. Classic.
We applied three patterns, in this order:
1. Yield to the main thread
The scheduler.yield() API is widely available in Chromium browsers in 2026, with a Promise-based fallback for Safari. Breaking a long task into smaller chunks lets the browser paint feedback before the full work completes.
async function handleSortChange(key) {
showPendingState(); // paint 1: user sees feedback
await yieldToMain();
const sorted = sortRows(rows, key); // heavy work
await yieldToMain();
renderTable(sorted); // paint 2: result
}
function yieldToMain() {
if ('scheduler' in window && 'yield' in scheduler) {
return scheduler.yield();
}
return new Promise(r => setTimeout(r, 0));
}
That one change dropped p75 INP on the comparison template from ~420ms to ~230ms in field data over two weeks.
2. Decouple visual feedback from the real work
Users don't care if the sort finishes in 50ms or 150ms. They care that something happens within 100ms. We split the handler into two phases: immediately toggle an aria-busy state and show a skeleton, then do the computation.
This is really the same principle as React's useTransition, but you don't need React for it. The point is: paint acknowledgement first, compute second.
3. Kill the hydration bomb
Our analytics bundle and a consent manager were both hydrating on page load, blocking the main thread for ~180ms on mid-tier Android. We moved both behind a requestIdleCallback wrapper with a 2-second timeout, and gated the consent manager to only load if no stored consent existed.
Input delay dropped from a median 90ms to around 20ms. That alone pushed a chunk of borderline pages from Needs Improvement into Good.
Measurement discipline: GA4 + BigQuery
Field data lies if you don't segment it. We pipe web-vitals attribution events into GA4, then export to BigQuery nightly. The query we actually use most:
SELECT
template,
interaction_target,
APPROX_QUANTILES(inp_value, 100)[OFFSET(75)] AS p75_inp,
COUNT(*) AS samples
FROM `project.analytics_xxx.vitals_events`
WHERE event_date BETWEEN '2026-01-01' AND '2026-01-28'
AND device_category = 'mobile'
GROUP BY template, interaction_target
HAVING samples > 500
ORDER BY p75_inp DESC;
Segmenting by template and interaction target is what tells you "the filter dropdown on the city pages is the worst offender", instead of a vague "mobile INP is bad".
If you want the broader pipeline we use for feeding Search Console data into the same warehouse, we wrote that up separately on the blog.
Don't trust lab data alone
Lighthouse's INP estimate is based on Total Blocking Time and a scripted interaction. It's useful for regression testing in CI but it will miss the slow interactions your actual users have, because real users click weird things in weird orders. We run Lighthouse in CI as a guardrail, not as the source of truth.
A word on the SEO payoff
Core Web Vitals is a ranking signal, but it's a tiebreaker, not a lever. On the comparison cluster we fixed, we saw clicks rise roughly 12–18% over the following six weeks. Some of that was INP, some was coincident content refreshes, some was seasonal. We can't cleanly attribute it, and we won't pretend otherwise.
What we can say: before the fix, the cluster was flagged in GSC's Core Web Vitals report, and competitor pages were outranking us for queries where our content was objectively more complete. After the fix, the flag cleared and the gap narrowed. That's consistent with "CWV as tiebreaker" and it's enough reason to invest.
Where we'd start
If you're staring at a programmatic site with a failing INP flag in GSC, do this in order:
- Ship the
web-vitalsattribution build to production this week. Log target, type, and the three sub-phases. Tag by template. - Wait seven days for enough samples, then rank interaction targets by p75 INP.
- Fix the top one. Not the top five — the top one. Programmatic templates mean a single fix touches your whole site.
- Measure in field data, not Lighthouse. Give it two weeks for CrUX to catch up.
- Only after the worst offender is Good, go hunt the next.
Most teams get stuck optimising everything in parallel and seeing no CrUX movement because p75 is dragged by the worst interaction. Fix that one. The rest can wait.
If you want a hand instrumenting this on a large template-driven site, our performance and SEO engineering team does exactly this kind of work.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Crawl Budget Forensics with Log Files: What Googlebot Actually Wastes Time On
GSC's Crawl Stats report is a toy. If you run a programmatic site above 100k URLs, you need raw log analysis. Here's the pipeline we use and the patterns that keep showing up.
Internal Linking at Scale: The Graph-Based Approach That Beat Our Rule-Based System
Rule-based internal linking on programmatic sites looks tidy in code review and dies in production. Here's how we rebuilt ours as a graph problem — and what it did to crawl depth and rankings.
Canonical Tag Drift on Programmatic Sites: How to Catch It Before It Eats Your Traffic
Canonical tags silently break on programmatic sites — usually after a template refactor nobody flagged as SEO-critical. Here's how we monitor drift and fix it before Google reshuffles our index.
