Why your typing test is now an INP test, too
If your site hosts a typing test, every key press is a live audit of your responsiveness. Since March 12, 2024, Interaction to Next Paint (INP) has replaced First Input Delay (FID) as a Core Web Vital used by Google Search. That means slow keystroke-to-paint cycles can drag both UX and SEO. (developers.google.com)
INP observes all qualifying interactions in a visit—clicks, taps, and crucially for us, keyboard presses—and reports a single value representative of responsiveness. Google recommends evaluating at the 75th percentile of field data, where ≤200 ms is good, 200–500 ms needs improvement, and >500 ms is poor. (web.dev)
What INP actually measures during typing
An interaction’s latency includes three sub-parts: input delay (waiting for the main thread), processing time (your event handlers), and presentation delay (work until the next frame is painted). For keystrokes, the Event Timing API groups related events like keydown/keyup and measures until the browser can paint the next frame. (web.dev)
- INP tracks keyboard interactions explicitly—pressing a key on physical or on-screen keyboards qualifies. (web.dev)
- The worst (or near-worst) interaction within a page view becomes that page’s INP; across visits, your RUM rollup should assess the 75th percentile. (web.dev)
Why does typing often surface issues? Long tasks on the main thread (bundled JS work, heavy renders, layout thrash) block the next paint, turning a crisp keystroke into a sticky UI. (web.dev)
Step-by-step: RUM setup that focuses on keyboard input
We’ll use Google’s web-vitals library with “attribution” to filter and label keyboard-driven INP.
1) Install and load
```bash
npm install web-vitals
```
2) Start measuring INP (with attribution)
```js
// rum-inp.js
import { onINP } from 'web-vitals/attribution';
function sendToAnalytics(metric) {
// Prefer sendBeacon so it fires on page hide
navigator.sendBeacon('/rum', JSON.stringify(metric));
}
onINP((metric) => {
// metric.attribution.interactionType => 'keyboard' | 'pointer'
const { attribution } = metric;
if (attribution?.interactionType === 'keyboard') {
// Optional: strip large arrays to keep payloads small
const safe = {
name: metric.name, // 'INP'
value: metric.value, // ms
id: metric.id,
page: location.pathname,
interactionType: attribution.interactionType,
target: attribution.interactionTarget,
inputDelay: attribution.inputDelay,
processing: attribution.processingDuration,
presentation: attribution.presentationDelay,
};
sendToAnalytics(safe);
}
}, {
// Capture more events (min allowed is 16ms)
durationThreshold: 16,
includeProcessedEventEntries: false,
});
```
This uses the attribution build to access `interactionType`, `interactionTarget`, and the input/processing/presentation breakdown, making it easy to isolate slow keystrokes and the element they hit. (github.com)
3) Flush on visibilitychange
```js
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// If you’re batching locally, flush here
}
});
```
The web-vitals docs recommend flushing on visibility changes and page hide to minimize data loss. (github.com)
4) Analyze by p75, device, and page
Aggregate server-side by page path and device class. Use the 75th percentile to determine pass/fail against the INP thresholds (≤200 ms good; 200–500 ms NI; >500 ms poor). (web.dev)
Tip: If you build your own Event Timing observers for diagnostics, set `durationThreshold: 16` so you see more interactions; by default, only longer ones are buffered. (developer.mozilla.org)
Code patterns that keep keystrokes responsive
Think of every key press as two parallel tracks: urgent UI feedback (caret move, character echo) and non-urgent work (scoring, analytics, spell-checking, highlighting). Keep urgent work synchronous and defer everything else.
1) Debounce or schedule non-urgent derived updates
```js
// Update WPM/accuracy at most 5–10x per second
let t;
inputEl.addEventListener('input', () => {
// Echo is default/cheap; heavy work is deferred
clearTimeout(t);
t = setTimeout(updateDerivedMetrics, 120);
});
```
Batching side-work avoids turning every single key press into expensive computation, which increases processing time and presentation delay that inflate INP. (web.dev)
2) Yield long tasks
```js
// Minimal cross-browser “yield” helper
export async function yieldToMain() {
// Try the scheduler API if available
if (typeof scheduler !== 'undefined' && scheduler.yield) {
await scheduler.yield();
} else {
await new Promise(r => setTimeout(r, 0));
}
}
// Break an expensive loop into chunks
async function analyze(text) {
const CHUNK = 1000;
for (let i = 0; i < text.length; i += CHUNK) {
heavyWork(text.slice(i, i + CHUNK));
await yieldToMain(); // let input/paint run
}
}
```
Breaking up long work lets the browser paint between chunks so keystrokes aren’t blocked by a monolithic task. (web.dev)
3) Move heavy logic off the main thread
If you compute diffs, n-gram stats, or complex highlighting, run it in a Web Worker and post results back—keeping the main thread free to echo keys and paint the caret. (web.dev)
4) React: separate urgent from non-urgent updates
```jsx
import {useState, useDeferredValue, startTransition} from 'react';
function TypingTest() {
const [text, setText] = useState(''); // urgent: bound to input
const deferred = useDeferredValue(text); // non-urgent derivations
const onChange = (e) => setText(e.target.value); // keep this instant
// Kick off heavy analysis in a transition
function recalc() {
startTransition(() => {
expensiveAnalyze(deferred); // runs without blocking typing
});
}
useEffect(() => { recalc(); }, [deferred]);
return ;
}
```
React 18 transitions mark updates as non-urgent so input stays responsive while larger renders happen in the background; `useDeferredValue` further prevents recomputing on every keystroke. (react.dev)
Troubleshooting checklist for input-heavy pages
- Keep keystroke handlers light: do the minimum per event; defer heavy work. Long handlers raise processing time and presentation delay. (web.dev)
- Watch for long tasks in DevTools Performance and correlate with `longAnimationFrameEntries` from `web-vitals/attribution` to see which scripts overlap slow interactions. (github.com)
- Avoid forced sync layout in key handlers (measuring DOM sizes repeatedly). Batch reads then writes.
- Limit re-render scope in SPAs: colocate state with the input, memoize expensive children, and prefer controlled input + background transitions for derived UI. (react.dev)
- Defer analytics: buffer and beacon on visibilitychange instead of doing network work per key. (github.com)
- Reduce third-party overhead on test pages; consider a lean route without heavy tags for the test itself.
- Validate your thresholds: aim for ≤200 ms p75 on both mobile and desktop. Most users spend ~90% of time post-load, so responsiveness after load matters most—exactly what INP measures. (web.dev)
The takeaway
A great typing test feels instant because the keystroke-to-paint loop is short. With INP now a Core Web Vital, that loop also affects how your pages are evaluated in Search. Instrument real-user INP for keyboard inputs, fix long tasks, and use scheduling and React transitions so every key press paints quickly—and consistently. (web.dev)