A form that passes Core Web Vitals can still feel sluggish to type in: the page’s INP is judged against 200 ms for its worst interaction, but typing starts to feel laggy well before that, and a form has hundreds of interactions per session.

Performance and scale for large forms describes how to make fields cheap. A budget turns “cheap enough” into a number that code review, CI and field monitoring can all check — so performance does not quietly regress one feature at a time.


Context and prerequisites

Interaction to Next Paint (INP) measures, for each click, tap or key press, the time from the input to the next frame painted, and reports roughly the worst one on the page. Google’s thresholds are “good” at or under 200 ms and “poor” over 500 ms. Those thresholds are designed for whole pages; typing is more demanding because keystrokes arrive every 100–200 ms and the user is watching characters appear.

A practical form budget, on a mid-range phone (or 4× CPU throttling on a laptop):

  • Keystroke to paint: 50 ms target, 100 ms hard limit. Under 50 ms typing feels immediate; above 100 ms characters visibly lag.
  • Per phase: input delay under 10 ms, handlers under 10 ms, render and commit under 20 ms, style/layout/paint under 10 ms.
  • Blur and submit may cost more — up to the 200 ms INP line — because the user is not mid-word. Submit should show feedback within that window even if the network takes longer.
A 50 ms keystroke budget, split by phase Of a 50 millisecond target, input delay is allowed 10 milliseconds, event handlers 10 milliseconds, framework render and commit 20 milliseconds, and style, layout and paint 10 milliseconds. The hard limit for any single keystroke is 100 milliseconds. input delay 10 ms event handlers 10 ms render + commit 20 ms style, layout, paint 10 ms Budget measured at 4× CPU slowdown. Render gets the largest share because it scales with how many components a keystroke touches.

The core pattern: measure keystrokes in the field

// Collects per-interaction timings for form fields using the Event Timing API
// (the same data INP is computed from) and reports the ones over budget.
type Report = { field: string; type: string; duration: number; inputDelay: number; processing: number; presentation: number };

export function watchFormLatency(form: HTMLFormElement, budgetMs = 100, send: (r: Report) => void) {
  if (!("PerformanceEventTiming" in window)) return () => {};
  const observer = new PerformanceObserver((list) => {
    for (const e of list.getEntries() as PerformanceEventTiming[]) {
      // interactionId is non-zero only for discrete user interactions
      // (keydown/keyup, pointer, click), which are what INP counts.
      if (!e.interactionId || e.duration < budgetMs) continue;
      const target = e.target as HTMLElement | null;
      if (!target || !form.contains(target)) continue;
      send({
        field: (target as HTMLInputElement).name || target.id || target.tagName,
        type: e.name,
        duration: Math.round(e.duration),                                  // rounded to 8 ms by spec
        inputDelay: Math.round(e.processingStart - e.startTime),
        processing: Math.round(e.processingEnd - e.processingStart),
        presentation: Math.round(e.startTime + e.duration - e.processingEnd),
      });
    }
  });
  // durationThreshold lowers the default 104 ms reporting floor so near-misses
  // are visible too (16 ms is the minimum the API accepts).
  observer.observe({ type: "event", buffered: true, durationThreshold: 16 } as PerformanceObserverInit);
  return () => observer.disconnect();
}
The timestamps in one PerformanceEventTiming entry The entry's startTime is when the key was pressed, at 0 milliseconds. processingStart, at 14 milliseconds, is when the first handler began, so input delay is 14 milliseconds. processingEnd, at 22 milliseconds, is when handlers finished, so processing is 8 milliseconds. The duration ends at 72 milliseconds with the next paint, so presentation delay, which includes the framework's scheduled render, is 50 milliseconds. input delay startTime → processingStart processing handlers run presentation render, layout, paint → next frame 0ms 20ms 40ms 60ms 80ms Frameworks that batch state updates render after the handler returns, so their render cost often shows up as presentation delay, not processing.

Send reports with navigator.sendBeacon in batches, tagged with release version and device class. The field name tells you which input is slow — usually the one whose change fans out to the most subscribers.


Step-by-step walkthrough

  1. Write the budget down. Put the numbers in the repository (a PERFORMANCE.md or a config file your tests read) so they are reviewable and not tribal knowledge.
  2. Measure the lab baseline. Record the slowest keystroke in each important form at 4× CPU throttling, following profiling form re-renders in DevTools.
  3. Add an automated lab check. A Playwright test types into the heaviest field under CPU throttling and asserts the Event Timing durations stay under the hard limit.
  4. Collect field data. Ship the observer above to production with sampling, and track the 75th and 98th percentile keystroke duration per form and per field.
  5. Attribute regressions. Because reports include the field name and phase breakdown, a regression points at a component and a phase rather than “the page got slower”.
  6. Spend the budget consciously. A feature that needs 15 ms of render per keystroke must find it elsewhere — isolating subscriptions, as in memoization boundaries for form fields, or moving work off the keystroke path.
// playwright: fail CI if the heaviest field's keystrokes exceed the hard limit
import { test, expect } from "@playwright/test";

test("typing in the line-items grid stays under 100 ms", async ({ page }) => {
  const cdp = await page.context().newCDPSession(page);
  await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
  await page.goto("/orders/new?rows=300");
  await page.evaluate(() => {
    (window as any).__durations = [];
    new PerformanceObserver((l) => {
      for (const e of l.getEntries() as any[]) if (e.interactionId) (window as any).__durations.push(e.duration);
    }).observe({ type: "event", durationThreshold: 16, buffered: true } as any);
  });
  await page.getByLabel("Quantity, row 150").pressSequentially("12345", { delay: 120 });
  const worst = await page.evaluate(() => Math.max(0, ...(window as any).__durations));
  expect(worst).toBeLessThan(100);
});

Failure modes and edge cases

1. Measuring only on fast machines

A form that takes 30 ms per keystroke on a developer laptop can take 150 ms on a budget Android phone. Every lab number in the budget must be at 4× or 6× CPU slowdown, and field data must be segmented by device class.

2. Input events are not interactions

Event Timing reports keydown, keyup and beforeinput/input entries, but only entries with an interactionId belong to the interaction INP counts. Filter on it, or you will double count and see confusing durations.

3. Budget blown by a one-off spike

The first keystroke after load may pay for lazy module loading or JIT warm-up. Track percentiles rather than maxima in the field, but keep the lab test on the worst case of a warmed page.

4. Main-thread contention from elsewhere

Input delay above budget often comes from work unrelated to the form: third-party scripts, analytics flushes, polling timers. Long Animation Frames (the long-animation-frame entry type) attribute that work to scripts — useful when the form’s own handlers are cheap but the numbers are still bad.

5. Budgets that nobody enforces

A budget in a document decays. Wire the Playwright check into CI for the two or three heaviest forms, and alert on the field 98th percentile crossing the hard limit.

Budget levels and what happens when they are crossed Under 50 milliseconds typing feels immediate and no action is needed. Between 50 and 100 milliseconds typing is acceptable but noticeable on fast typists, and the change should be investigated before release. Over 100 milliseconds characters visibly lag, the CI check fails and the change must not ship. Keystroke to paint How it feels Action under 50 ms immediate none 50–100 ms noticeable to fast typists investigate before release over 100 ms characters visibly lag CI fails; do not ship

Verification checklist


Frequently Asked Questions

Why is the form budget stricter than the 200 ms INP threshold?

INP’s threshold describes the worst interaction on a page, which is often a click that opens something. Typing is continuous and rhythmic; a delay that is acceptable once per page becomes irritating when it happens on every character. Holding keystrokes to 50–100 ms keeps the page’s INP comfortably good as a side effect.

Does the Event Timing API work in all browsers?

It is available in Chromium-based browsers and Firefox; Safari support has been arriving more recently. Feature-detect PerformanceEventTiming and treat missing data as missing, not as zero. Lab tests in Chromium cover the regression-detection job regardless.

Should I debounce input handlers to meet the budget?

Debounce expensive reactions — async checks, heavy validation, saving — never the update of the field’s own displayed value. The character must appear on the frame after the key press; everything else can wait.


Related

← Performance and Scale for Large Forms