Keep interactions fast (INP)
Paint feedback first, then yield; no long work in an input handler.
From nextjs-pagespeed. Target: the INP “good” threshold.
Filter 20,000 invoices
Press Paid only in both panes, several times fast.
Work, then paintBad
20,000 invoices
burn(300); render();Paint, 5 ms chunksGood
20,000 invoices
paint → yield → 5 ms chunksconst yieldToMain = () =>
globalThis.scheduler?.yield
? scheduler.yield()
: new Promise((r) => setTimeout(r, 0));
const afterNextPaint = () =>
new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0)));
let run = 0;
button.addEventListener("click", async () => {
const id = ++run;
paidOnly = !paidOnly;
button.setAttribute("aria-pressed", String(paidOnly)); // 1. feedback first
await afterNextPaint(); // 2. let it paint
const out = [];
for (let i = 0; i < rows.length; ) {
const deadline = performance.now() + 5; // 3. 5 ms per task
while (i < rows.length && performance.now() < deadline) {
if (!paidOnly || rows[i].status === "paid") out.push(rows[i]);
i++;
}
await yieldToMain();
if (id !== run) return; // 4. newer click wins
}
render(out);
});
Search as you type
Type summit labs fast in each box.
Search in the handlerBad
Echo:
20,000 clients
–
oninput: burn(150); count(); echo();Echo, then searchGood
Echo:
20,000 clients
–
oninput: echo(); search() in 5 ms chunkslet latest = 0;
input.addEventListener("input", () => {
echo.textContent = input.value; // cheap: lands in this frame
count.dataset.stale = "true";
search(input.value); // not awaited
});
async function search(q) {
const id = ++latest;
await afterNextPaint();
const hits = [];
for (let i = 0; i < clients.length; i += 500) {
if (id !== latest) return; // a newer keystroke owns the result
for (const c of clients.slice(i, i + 500)) if (c.includes(q)) hits.push(c);
await yieldToMain();
}
count.textContent = `${hits.length} clients`;
delete count.dataset.stale;
}
Long tasks, live
Run both ways, then drag Chunk size past 50 ms.
Main thread, last 6 sTimeline
Tasks this page ran
Long tasks the browser saw
–
scheduler.yield()// scheduler.yield() resumes ahead of queued tasks; setTimeout(0) does not
async function runInChunks(work, chunkMs = 5) {
let deadline = performance.now() + chunkMs;
for (const unit of work) {
unit();
if (performance.now() >= deadline) {
await yieldToMain();
deadline = performance.now() + chunkMs;
}
}
}
Why it matters, pros and cons
Why it matters
The browser paints between tasks, never during one; a 300 ms handler blocks it.
Pros
- Feedback lands in the next frame.
- Mid-work input waits one chunk at most.
- A run ID cancels stale work.
Cons
- Each yield adds a little total time.
- Half-done states need a stale or progress cue.
- Under 50 ms of work, skip it; heavy math belongs in a Worker.