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 chunks
      const 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 chunks
      let 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.