Interactivity — INP & TBT
Long tasks, LoAF, yielding with scheduler.yield, debouncing, workers, and hydration strategies.
INP is the responsiveness metric. Poor INP almost always means the main thread was busy when the user tried to interact.
INP mechanics
For each tap/click/keypress, the browser measures until the next frame is presented after handlers finish:
- Input delay — thread busy; event queued
- Processing time — your listeners + framework work
- Presentation delay — style/layout/paint before next frame
INP = p98 of interaction durations (worst interactions dominate).
Long tasks & LoAF
Tasks > 50 ms are “long.” They block input and increment TBT in lab traces.
Long Animation Frames (LoAF) — newer API surfacing scripts inside slow frames:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LoAF', entry.duration, entry.scripts);
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
Use Chrome DevTools Performance panel: bottom-up view → sort by Self time → find your bundles.
The 50 ms slicing heuristic
| Duration | Strategy |
|---|---|
| < 50 ms | Run synchronously |
| 50–250 ms | Slice + scheduler.yield() |
| > 250 ms | Web Worker or off main thread |
async function yieldToMain() {
if ('scheduler' in window && 'yield' in scheduler) {
return await scheduler.yield();
}
return new Promise((r) => setTimeout(r, 0));
}
async function processLargeList(items) {
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
if (i % 50 === 0) await yieldToMain();
}
}
INP Lab — blocking loop vs scheduler.yield()
scheduler.postTask priorities
scheduler.postTask(() => updateCriticalUI(), { priority: 'user-blocking' });
scheduler.postTask(() => syncAnalytics(), { priority: 'background' });
Priorities: user-blocking → user-visible → background.
Debounce & throttle
| Pattern | Use when |
|---|---|
| Debounce | Search input — run after pause |
| Throttle | Scroll/resize — max once per frame/window |
| rAF throttle | Visual scroll effects — sync to paint |
function debounce(fn, ms) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
}
Web Workers
Offload parsing, compression, heavy transforms:
const worker = new Worker('/worker.js', { type: 'module' });
worker.postMessage({ data: largePayload });
worker.onmessage = (e) => updateUI(e.data);
Workers don’t touch DOM — postMessage back for UI updates (still schedule those updates thoughtfully).
Framework hydration & INP
Hydration = attaching client listeners to server HTML. Full-page hydration on load competes with first interactions.
| Strategy | INP impact |
|---|---|
| Islands / partial hydration | Only interactive regions hydrate |
client:visible / client:idle | Defer hydration until needed |
| Resumability (advanced) | Serialize state; less re-execution |
| Event delegation | Fewer listeners |
DON’T hydrate entire dashboards on landing if only one widget is interactive.
Third-party scripts
Analytics, chat, ads — defer and self-host when possible:
<script defer src="https://cdn.example/analytics.js"></script>
Audit with Performance panel → Third-party lane. Remove or delay anything not needed for first interaction.
INP debug workflow
- RUM attribution — which element/event?
- Local trace — reproduce interaction, find long task
- Fix: yield, worker, defer hydration, or remove work
- Verify p98 in RUM after deploy
Next: Module 04 — CLS and visual stability.