Calculating INP Thresholds for Interactive Dashboards
Interactive dashboards break the assumptions behind generic Interaction to Next Paint (INP) targets. Continuous WebSocket polling, unbatched state mutations, and virtualized grid re-renders generate long tasks that saturate the main thread, so a monolithic site-wide budget under-protects the one surface where responsiveness matters most. This guide is part of the Core Web Vitals Budget Allocation reference, and it derives a dashboard-specific INP budget: desktop P75 under 180 ms, mid-range mobile P75 under 250 ms (CPU 4x slowdown, Fast 3G), and any sustained mid-range mobile P75 over 400 ms treated as a critical failure. These ceilings come from main-thread saturation limits and human-perceived latency boundaries, not from a global average that marketing pages dilute.
The workflow below is deliberately ordered: measure how far the dashboard drifts from the budget, decompose the failing interaction into its three phases, attribute the slow phase to real code, apply the yield-based fix, then gate the synthetic proxy in CI and reconcile it against field data. Each step narrows the search space so the next one is cheaper.
Why Dashboard INP Diverges From the Site Average
The first mistake teams make is trusting a single site-wide INP figure. A marketing landing page fires maybe two interactions per session against an almost-idle main thread, so its INP sits comfortably under the 250 ms mid-range mobile P75 budget. A data grid does the opposite: every filter apply reconciles thousands of rows, every drill-down repaints a canvas, and a polling loop keeps the scheduler busy in the background. When you pool those interactions into one distribution, the dashboard's slow tail hides behind the marketing page's fast bulk, and the aggregate P75 looks healthy while the surface that earns revenue feels sluggish.
The fix is to segment before you budget: tag dashboard interactions in your field data and hold them to their own ceiling. That decision maps to the broader question of whether P75 or a stricter tail matters for your traffic, covered in Choosing Between P75 and P90 Budget Targets. For a dashboard used all day by a narrow set of power users, P90 is often the honest target because the tail is where their frustration lives.
INP Sub-Part Breakdown
INP is the sum of three phases, and a dashboard regression almost always lives in one of them. Decompose the budget so a slow phase is attributable without re-profiling the whole interaction. The table below allocates a 200 ms mid-range mobile P75 budget (CPU 4x slowdown, Fast 3G) across the three phases.
| INP phase | What it measures | Dashboard ceiling (P75) | Primary cause when breached |
|---|---|---|---|
| Input delay | Time from input to handler start | ≤ 50 ms | Main thread busy with prior long task / polling |
| Processing | Event handler + state mutation | ≤ 100 ms | Unbatched reconciliation, deep equality checks |
| Presentation | Style, layout, paint to next frame | ≤ 50 ms | Canvas redraw, DOM-heavy virtualized grid |
A high input delay means the main thread was occupied before the interaction even began — usually an aggressive polling interval under 100 ms or a prior chart re-render exceeding the 50 ms long-task threshold. A high processing phase points at synchronous reconciliation, and a high presentation phase points at the charting or grid layer. Because the three ceilings sum to the full 200 ms mid-range mobile P75 budget, spending over budget in one phase forces another phase into deficit — there is no slack to borrow.
Diagnostic Steps
-
Isolate long interactions in the browser console to find which handlers exceed the 50 ms long-task threshold.
const longInteractions = performance.getEntriesByType('event') .filter((e) => e.duration > 50); console.table(longInteractions.map((t) => ({ id: t.interactionId, duration: Math.round(t.duration), processingStart: Math.round(t.processingStart), processingEnd: Math.round(t.processingEnd) })));Expected output: a table of interactions with their
interactionIdand millisecond durations. Note thate.targetis not exposed onPerformanceEventTiming; correlate byinteractionIdagainst your own listeners. Read the gap betweenprocessingEndand the entrydurationas the presentation phase, and the gap beforeprocessingStartas input delay. -
Attribute production INP to the dashboard surface using the
web-vitalslibrary, which exposes the slow phase per interaction. The samePerformanceObserverplumbing that powers this is covered in depth in Injecting Custom Metrics via PerformanceObserver.import { onINP } from 'web-vitals'; onINP((metric) => { const attr = metric.attribution; const isDashboard = attr?.interactionTarget?.closest?.('.dashboard-grid') || attr?.interactionTarget?.closest?.('.filter-panel'); if (isDashboard) { reportMetric({ value: metric.value, id: metric.id, type: attr?.interactionType, inputDelay: Math.round(attr?.inputDelay ?? 0), processing: Math.round(attr?.processingDuration ?? 0), presentation: Math.round(attr?.presentationDelay ?? 0) }); } });Expected report: a single
reportMetriccall per slow dashboard interaction carrying the INP value, interaction type, and the three phase durations so the field data tells you which ceiling broke. -
Confirm long tasks during CI with a
longtaskobserver so the synthetic run surfaces main-thread saturation.new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.warn(`Long task: ${entry.duration.toFixed(0)}ms`); } }).observe({ type: 'longtask', buffered: true });Expected output: zero
Long taskwarnings during the critical interaction path; any line over 100 ms is a budget violation on the mid-range mobile P75 profile.
Once the field data names the failing phase, route the fix with a simple decision: input delay sends you at the polling loop and pre-handler work, processing sends you at reconciliation, and presentation sends you at the grid or chart layer.
Implementation
The dominant fix is yielding control of the main thread between chunks of handler work so input delay and processing stay within budget. A single uninterrupted filter over ten thousand rows can occupy the main thread for well over 150 ms on a mid-range mobile device (CPU 4x slowdown, Fast 3G), and any tap that lands during that window inherits the full remaining task as input delay. Breaking the work into chunks with scheduler.yield() lets a queued interaction run in the first gap instead of waiting for the whole computation.
// apply-dashboard-filter.js — yield to the main thread between chunks
async function applyFilter(rows, predicate) {
const out = [];
for (let i = 0; i < rows.length; i++) {
out.push(predicate(rows[i]) ? rows[i] : null);
// Yield every 200 rows so input delay stays under budget
if (i % 200 === 0 && 'scheduler' in window && 'yield' in scheduler) {
await scheduler.yield();
}
}
return out.filter(Boolean);
}
Apply a device-tier multiplier when enforcing the budget: 1.0x desktop, 1.3x on mid-range mobile (CPU 4x slowdown, Fast 3G), and 1.6x on low-end mobile (CPU 6x slowdown, Slow 3G). Deriving those multipliers so a CI runner mirrors real silicon is its own calibration task, detailed in Calibrating CPU Throttling for CI Runners. Cap the dashboard entry point at 180 KB gzipped so parse and compile latency does not eat the processing budget; the per-route capping method lives in JavaScript Bundle Size Limits.
Two edge cases deserve their own handling. First, scheduler.yield() is not yet universal, so keep a fallback that posts a task via MessageChannel or falls back to setTimeout(fn, 0) for browsers without the Scheduler API — otherwise the await resolves synchronously and no yielding happens. Second, if the dashboard is a single-page app, a filter that also changes the route adds soft-navigation INP on top of the interaction cost, which needs its own ceiling as described in Budgeting Soft-Navigation INP in SPAs. Treat a route-changing interaction as a separate budget line, not as a rounding error on the filter.
CI Gating Assertion
Lighthouse CI cannot assert INP directly because it requires real interactions, so gate Total Blocking Time as the synthetic proxy and validate true INP from RUM. This lighthouserc.json blocks merges when synthetic TBT exceeds the calculated limit.
{
"ci": {
"collect": {
"numberOfRuns": 3,
"settings": {
"throttlingMethod": "simulate",
"throttling": { "cpuSlowdownMultiplier": 4 }
}
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"interactive": ["warn", { "maxNumericValue": 4000 }]
}
},
"upload": { "target": "temporary-public-storage" }
}
}
Pair this with a RUM reconciliation rule: when production mid-range mobile P75 exceeds the synthetic TBT proxy by more than 15%, trigger a CI replay of the exact interaction on the low-tier mobile profile (CPU 6x slowdown, Slow 3G) to isolate the environmental cause. TBT is a proxy, not a promise — it measures blocking during load, while INP measures blocking during use, so treat a green TBT gate as necessary but never sufficient.
Verification
Confirm the budget holds before sign-off:
- Synthetic gate —
npx lhci autorunreportstotal-blocking-timeunder 200 ms on the CPU 4x profile and the assertion summary shows all green. - Field P75 — RUM dashboard INP stays under 180 ms desktop / 250 ms mid-range mobile (Fast 3G) across 95% of test runs over the rolling 28-day window.
- Long tasks — zero tasks exceeding 100 ms during the five core interactions (global filter apply, table sort, chart drill-down, CSV export, tab switch).
- Variance — synthetic-to-RUM divergence stays within ±15%; beyond that, the lab environment is misconfigured rather than the code regressed.
A passing run shows the assertion summary with no error-level failures and a clean longtask console during the critical path. Re-run the field check after every dependency bump to the grid or charting library, since a minor version can quietly reintroduce a synchronous reconciliation that pushes the mid-range mobile P75 back over 250 ms.
Frequently Asked Questions
Why is dashboard INP worse than the site-wide average?
Dashboards run continuous polling, unbatched state updates, and virtualized grid re-renders that generate long tasks the rest of the site does not. Averaging dashboard interactions into a global P75 hides the problem, so isolate a dashboard-specific budget instead. The allocation method sits in Core Web Vitals Budget Allocation.
How does scheduler.yield() lower INP?
It breaks a long task into chunks and returns control to the main thread between them, so a queued interaction can start its handler instead of waiting behind the whole computation. That directly cuts the input-delay phase, which is often the largest contributor on a busy dashboard.
Why can't I assert INP in Lighthouse CI?
INP is computed from real user interactions across a session, which a synthetic Lighthouse run does not perform. Gate total-blocking-time as the lab proxy and validate true INP from RUM at the mid-range mobile P75.
Should a dashboard budget use P75 or P90?
For a dashboard used all day by a narrow set of power users, P90 is often the honest target because the slow tail is where their frustration concentrates. A mixed-traffic surface can stay on the 250 ms mid-range mobile P75 ceiling. The trade-off is worked through in Choosing Between P75 and P90 Budget Targets.
Does a route-changing filter need its own budget?
Yes. In a single-page app a filter that also changes the route adds soft-navigation INP on top of the interaction cost, so budget it as a separate line rather than folding it into the filter's ceiling. The method is covered in Budgeting Soft-Navigation INP in SPAs.