Optimizing First Input Delay (FID): Engineering Sub-100ms Responsiveness
This guide sits under the broader Core Web Vitals & Measurement program and turns input-latency tuning into a repeatable workflow: baseline, isolate, fix, validate.
First Input Delay (FID) quantifies the time between a user's initial interaction (click, tap, keypress) and the browser's ability to begin processing the corresponding event handlers. The "good" boundary is < 100ms at the 75th percentile. Although Google has promoted Interaction to Next Paint (INP, < 200ms) to the primary interactivity metric, FID remains a precise diagnostic for initial load responsiveness and is still surfaced in historical CrUX exports. The two metrics share one root cause — a main thread blocked by synchronous JavaScript — so the partitioning techniques here move both numbers at once. Every step below assumes a long-task budget of 50ms: any contiguous block beyond that delays input.
Prerequisites
web-vitalsv4+ (theweb-vitals/attributionbuild, for event-target and load-state reporting).- Chromium 129+ for native
scheduler.yield(); the snippets degrade gracefully tosetTimeout. - Source maps emitted in production builds so long tasks map back to named bundles.
- Lighthouse CI (
@lhci/cli) for build-time budget assertions, plus access to a CrUX or RUM dashboard for 75th-percentile field data.
1. Environment Setup and Baseline Measurement
Before changing code, establish a reproducible baseline from both field and lab. Deploy the web-vitals attribution build to capture real-user FID and INP with the exact event target and load state, then reproduce the worst interactions locally under throttling. Cross-reference timings with Measuring LCP with Chrome DevTools to spot resource contention shared with the critical rendering path, and anchor your numbers against the Core Web Vitals thresholds so you grade against the right boundaries.
A baseline is only useful if it is segmented. A single site-wide median hides the routes that actually fail: a content page can post a 40ms FID while the dashboard route sits at 260ms because it hydrates a data grid on mount. Capture the metric per route and per device class (low-end mobile, mid-tier mobile, desktop) so the optimization budget lands where users feel it. Field data is graded at the 75th percentile because that is the boundary Google uses for the "good" rating; tracking p50 alone will flatter you, and chasing p95 will send you optimizing the slow tail of broken networks instead of the typical slow device.
The lab and the field answer different questions and you need both. The field tells you which interaction on which route is slow for real users; the lab lets you reproduce that exact interaction deterministically and read its sub-phases. The mistake to avoid is treating Lighthouse's lab score as ground truth — its synthetic environment cannot reproduce a user's third-party tag soup, browser extensions, or a cold device under thermal throttling. Use the lab to locate the bottleneck and the field to confirm it shipped.
Diagnostic steps:
- Capture real-user FID/INP with attribution to record blocking duration and the precise
eventTarget, segmented by route and device class. - Collect
PerformanceLongTaskTimingentries in production to map blocking scripts to specific bundles. - Attribute long tasks to first-party hydration or third-party SDKs using build-time source maps.
- Validate against the 75th-percentile CrUX field data; in the lab simulate
4xCPU throttling and slow-network conditions to approximate mid-tier mobile.
2. Capture a Long-Task Baseline
Input latency is almost always synchronous JavaScript monopolizing the main thread. Quantify it before you optimize so you can prove the delta later.
// Record every long task so you can rank bundles by blocking time.
const longTasks = [];
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
longTasks.push({ start: entry.startTime, duration: entry.duration });
}
});
observer.observe({ type: 'longtask', buffered: true });
// trade-off: longtask reports the 50ms+ block but NOT which line caused it;
// for attribution down to a function you still need the Performance panel,
// so skip this in favour of profiling when you already know the culprit route.
Rank the captured tasks by total duration. The top one or two usually account for the bulk of your FID, and they are where every later step should aim first. Two refinements make this baseline actionable rather than merely interesting. First, record the startTime alongside duration: a 300ms task that runs during initial hydration hurts FID specifically, whereas the same task firing 8 seconds in only matters if a user happens to interact then. Second, correlate the long-task window with the input timestamp from attribution — when a recorded interaction's input delay overlaps a long task, you have a direct, defensible link between a named bundle and a measured regression, which is exactly the evidence you need to justify the refactor in review.
Keep this observer running in a small slice of production traffic even after the work is done. INP regressions are insidious because they arrive through dependency bumps and new third-party tags rather than through obvious code changes, and a standing long-task baseline turns a silent regression into a dashboard line you can alert on.
3. Isolate the Bottleneck: Long-Task Partitioning
Once the dominant block is identified, break monolithic initialization into yield-friendly chunks. Cooperative scheduling hands control back to the event loop so a queued click is processed within the 50ms budget instead of waiting for a 400ms hydration pass to finish. The modern primitive is scheduler.yield(), covered in depth in optimizing INP with scheduler.yield(); when the heavy work is pure computation rather than DOM mutation, move it off-thread entirely as shown in offloading work to web workers with Comlink.
function partitionTask(units, sliceMs = 50) {
let i = 0;
function runSlice() {
const start = performance.now();
while (i < units.length && performance.now() - start < sliceMs) {
units[i]();
i++;
}
if (i < units.length) {
if (typeof scheduler !== 'undefined' && scheduler.yield) {
scheduler.yield().then(runSlice);
} else {
setTimeout(runSlice, 0);
}
}
}
runSlice();
// trade-off: yielding adds scheduling overhead per slice, so for a task that
// already finishes under ~50ms this makes total wall-clock time WORSE —
// only partition work that demonstrably exceeds the long-task budget.
}
The reason scheduler.yield() is preferable to the old setTimeout(fn, 0) trick is priority. A task scheduled with setTimeout joins the back of the task queue behind any work already posted there, including lower-priority third-party callbacks, so your continuation can be starved. scheduler.yield() returns control to the browser to process pending input and then resumes your work at the front of the queue at the same priority, which is why it both keeps input responsive and finishes the overall job faster. The setTimeout fallback still matters for Safari and older Chromium, but treat it as the floor, not the target.
Not every long task should be partitioned in place. If the block is pure computation — parsing a large JSON payload, diffing two datasets, running a layout algorithm — yielding still keeps the work on the main thread, just in smaller pieces, and the total cost competes with paint. The cleaner fix is to move it to a worker so the main thread is never touched, which is the entire point of offloading work to web workers with Comlink. Reserve in-place partitioning for work that genuinely must run on the main thread, such as incremental DOM construction.
Diagnostic steps:
- Audit hydration scripts for synchronous DOM manipulation or reflow triggers during
DOMContentLoaded. - Cap continuous execution at
~50mswithscheduler.yield()and asetTimeoutfallback. - Defer analytics, chat widgets, and tracking pixels with
async/deferor dynamicimport()afterwindow.load. - Move pure-compute blocks off-thread instead of partitioning them in place.
- Re-record long tasks to verify the dominant block has dropped below
50ms.
4. Apply the Fix: Event Listeners and Input Prioritization
Inefficient event binding inflates latency before any handler logic even runs. Attach listeners only where needed, use { passive: true } on scroll and touchmove so the compositor is not blocked waiting on your handler, and keep primary interaction handlers (navigation toggles, form submit) lightweight and registered early.
// Feature-detect passive support, then apply to high-frequency events.
let passiveSupported = false;
try {
const opts = Object.defineProperty({}, 'passive', {
get() { passiveSupported = true; }
});
window.addEventListener('test', null, opts);
window.removeEventListener('test', null, opts);
} catch (e) { /* older browsers: passive stays false */ }
const passiveOpts = passiveSupported ? { passive: true } : false;
window.addEventListener('scroll', handleScroll, passiveOpts);
window.addEventListener('touchmove', handleTouch, passiveOpts);
// trade-off: a passive listener CANNOT call preventDefault(), so never mark a
// handler passive if it needs to block scrolling or suppress a gesture —
// doing so silently breaks the interaction with no console error.
Event delegation is an under-used lever in component-heavy SPAs. Attaching a distinct listener to every row of a 5,000-item list both inflates memory and lengthens the listener-registration work that runs during hydration; a single delegated listener on the container resolves the target at dispatch time for a fraction of the cost. The trade-off is that delegation breaks for events that do not bubble (focus, blur, most media events), so reach for it on click, input, and keydown, and keep direct listeners for the non-bubbling cases.
The most overlooked source of input delay is work that runs inside the handler but does not need to. A click handler that updates state, then synchronously reads layout to position a tooltip, then logs to analytics has bundled three concerns into one blocking task. Split them: do the minimum to acknowledge the interaction synchronously, schedule the visual update in requestAnimationFrame, and defer the analytics call to idle time. To find which handler is the actual offender rather than guessing, follow profiling event handlers for INP, which walks through reading the interaction track in the Performance panel.
Diagnostic steps:
- Audit every
addEventListenerfor missing{ passive: true }on scroll/touch handlers. - Replace inline
on*handlers with delegated listeners on a container element for bubbling events. - Use
requestAnimationFramefor visual updates driven by input so they align with the compositor's refresh. - Defer analytics and logging fired from handlers to
requestIdleCallbackso they never sit in the input's critical path. - Apply
touch-action: manipulationto interactive elements to drop the legacy300mstap delay.
Deconstructing Interaction Latency into Phases
Both FID and INP decompose into measurable sub-phases, each with its own budget. Profile the dominant phase before choosing a fix — partitioning won't help a slow paint, and content-visibility won't help a slow handler.
- Input delay (
target < 50ms): time from the user gesture to the handler starting. Caused by an already-busy main thread; fixed by long-task partitioning and deferral. - Processing time (
target < 100ms): your event-callback execution. Caused by synchronous state cascades and forced reflows; fixed by yielding,startTransition, and off-thread compute. - Presentation delay (
target < 50ms): time from callback completion to the next paint. Caused by expensive style/layout/paint; fixed withcontent-visibility, layout containment, and avoidingwill-changeoveruse.
The diagnostic value of this decomposition is that it tells you which class of fix is even relevant. A team that reads "INP is 320ms" and reflexively reaches for scheduler.yield() will see no movement if the actual culprit is a 250ms presentation delay from a full-page reflow after the handler returns — yielding only helps input delay and processing time. Conversely, content-visibility: auto does nothing for an interaction stuck behind a synchronous fetch in the handler. Always read the sub-bars in the Performance panel's interaction track before choosing a technique. The single most common misattribution is blaming JavaScript execution for what is actually style recalculation triggered by toggling a class that invalidates a large subtree.
A note on the relationship between FID and INP that the phase model makes precise: FID is, in effect, the input-delay phase of the first interaction only. That is why a page can have an excellent FID and a poor INP — the first click landed on an idle thread during a quiet moment, but a later click during a route transition hit a 400ms hydration block. Optimizing FID buys you initial-load hygiene; optimizing INP demands the same hygiene sustained across the whole session, including the transitions and lazy-loaded chunks that FID never observes.
Advanced Diagnostics: Framework and Mobile Failure Modes
Modern frameworks expose concurrency primitives, but each has a sharp edge. In React, wrap non-urgent updates in startTransition so the input field stays live while a list re-renders; in Vue, lean on nextTick and computed caching and avoid synchronous watchers on high-frequency inputs. Never debounce a primary click handler — it pushes the processing start later and directly inflates INP. Reserve throttling for scroll and resize.
A subtle framework failure mode is the over-large reconciliation. When a high-frequency input updates a piece of state that a thousand components subscribe to, every keystroke schedules a thousand-component diff even if only one of them visibly changes. The fix is to narrow the subscription surface: colocate the volatile state with the component that owns it, memoize the expensive children, or move the value into a store with fine-grained selectors so unrelated subtrees never re-render. startTransition makes that diff interruptible, but narrowing the subscription makes it small in the first place — and a small synchronous diff beats a large interruptible one every time the user keeps typing.
Watch for the inverse trap too: optimization that moves the cost rather than removing it. Memoizing a component whose props change on every render adds the comparison cost without ever skipping a render, and wrapping trivially cheap work in a transition adds scheduler overhead for no benefit. The phase decomposition is the referee here — if processing time is already under budget, the lever you need is in presentation, not in more aggressive scheduling.
Mobile adds its own vectors: compositor handoffs, aggressive CPU throttling, and the legacy 300ms tap delay on devices that still apply it. Set width=device-width, initial-scale=1, apply touch-action: manipulation to interactive elements, and test on a throttled mid-tier device profile rather than your desktop. A practical trap here is the developer-hardware gap: a flagship laptop processes a long task four to six times faster than the median phone in CrUX, so a handler that feels instant locally can blow the 200ms budget in the field. The 4x CPU throttle in DevTools is a deliberately conservative approximation of that gap, and treating it as the default recording condition prevents shipping regressions that only the field will catch.
There is also a class of failure unique to hydration timing. Server-rendered markup is interactive-looking before its JavaScript has attached handlers, so a user can click a button that visually exists but is not yet wired. The event is buffered and replayed when hydration completes, which means its input delay includes the entire remaining hydration time. Progressive or selective hydration — hydrating interactive islands first and deferring static regions — directly shrinks this window. If your framework supports it, hydrate above-the-fold interactive components before the rest of the tree, and measure the FID of the first plausible click target specifically.
Validation and Budgeting
A fix is not done until field data confirms it. Ship the attribution beacon, then gate regressions in CI so the gain can't silently erode.
import { onINP, onFID } from 'web-vitals/attribution';
function report(path, metric) {
navigator.sendBeacon(path, JSON.stringify({
value: metric.value,
rating: metric.rating,
target: metric.attribution.interactionTarget || metric.attribution.eventTarget,
loadState: metric.attribution.loadState
}));
}
onFID((m) => report('/analytics/fid', m));
onINP((m) => { if (m.value > 200) report('/analytics/inp', m); });
// trade-off: sendBeacon is fire-and-forget with no retry, so a flaky network
// drops the sample silently — acceptable for aggregate p75 RUM, but do NOT
// rely on it for per-session audit trails where every event must arrive.
Add a Lighthouse CI assertion so a synthetic regression fails the build before it reaches users:
{
"ci": {
"assert": {
"assertions": {
"interactive": ["error", { "maxNumericValue": 3500 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }]
}
}
}
}
// trade-off: total-blocking-time is a lab proxy for INP, not INP itself —
// it catches main-thread regressions in CI but can pass while field INP is
// poor, so treat it as a tripwire and keep CrUX p75 as the source of truth.
Budgeting works best when the threshold in CI is tighter than the public boundary. If the field "good" line is 200ms INP, set the lab total-blocking-time budget to leave headroom — real devices are slower than CI runners, so a build that just barely passes at the public boundary will regress in the field. A 30–40% buffer between the CI budget and the field threshold absorbs the gap between your runner and the median user's phone. Pair the synthetic gate with a field alert that fires when the route's p75 crosses the boundary for two consecutive days, so a slow drift through dependency upgrades surfaces before it becomes a ranking problem rather than after.
Related
- Optimizing INP with
scheduler.yield()— the modern primitive for breaking up long tasks without losing your place in the queue. - Offloading work to web workers with Comlink — move CPU-bound parsing and math off the main thread entirely.
- Profiling event handlers for INP — pinpoint the exact slow handler in the Performance panel.
- Improving INP for complex single-page applications — applying these tactics across route transitions and hydration.
- Understanding Core Web Vitals thresholds — grade your p75 numbers against the right boundaries.