Browser · intermediate

Browser input lags while CPU is moderate: locate long main-thread tasks

When a web page's input feels sluggish despite a moderate CPU profile, the cause is usually long main-thread tasks blocking the event loop rather than sustained CPU saturation. This guide walks engineers through using the Performance API and browser performance panels to identify which scripts, layout work, or style recalculations are starving input handlers, then narrows the diagnosis to actionable fixes.

The symptoms

  • Keystrokes, clicks, and pointer events appear to "queue up" and apply in bursts rather than continuously, even though overall CPU usage looks moderate in DevTools or OS monitors.
  • Long Tasks (tasks exceeding 50 ms) are visible in the Performance panel as wide yellow or red blocks on the main thread during the input delay window.
  • Input delay metric (INP) shows a long interaction-to-next-paint gap measured against a target of 200 ms, while CPU usage metrics remain below 60 percent averaged cores.
  • Frame timing data shows dropped or delayed frames clustered immediately after the offending script's execution block on the main thread.
  • User-reported "the page froze for a second" complaints align with PerformanceObserver longtask entries rather than resource-loading events.

Likely causes

  • A single synchronous JavaScript function (data parsing, JSON deserialization, array reduction over large collections) runs longer than the 50 ms long-task threshold and blocks subsequent input event dispatch.
  • Forced synchronous layout (layout thrashing) occurs when code reads layout properties such as offsetHeight immediately after writing to the DOM in a loop, forcing repeated style and layout passes.
  • Style recalculation triggered by class toggles, large DOM subtrees, or inherited property changes on deep trees consumes the main thread before input handlers can run.
  • Long-running rendering work (paint, composite) on the main thread, often caused by complex CSS effects like large blur radii, backdrop-filter regions, or non-layer-promoted animated elements.
  • Event handlers themselves execute heavy synchronous work (synchronous XHR, large state updates in frameworks) that delays the next paint and next interaction.

First ten minutes

  1. 01Open the browser's Performance panel and record a trace that captures the exact moment of the perceived input lag; reproduce the click or keystroke once so the trace has an anchor interaction.
  2. 02Confirm CPU is not saturated: in DevTools, check the Performance panel's CPU throttle setting (should be "No throttling" for first-pass diagnosis) and observe the CPU chart staying under 60 percent.
  3. 03Add a PerformanceObserver for 'longtask' entries via the Performance API to enumerate any task whose duration exceeds 50 ms; capture the entry's startTime, duration, and name to a structured log.
  4. 04Cross-reference the longtask timestamps with user input timestamps using the Event Timing API entries (interactionId, processingStart, processingEnd, duration) to confirm the task ran inside the interaction window.
  5. 05Inspect the top of the call stack for each longtask: in the Performance flame chart, click the wide block and read the "Self Time" and "Function" columns to identify which script frame owns the time.

Evidence to collect

  • PerformanceObserver longtask records: each entry's duration, startTime, name (e.g., 'script', 'layout', 'paint'), and the containerType ('window', 'worker', 'iframe') where it executed.
  • Event Timing API entries: interactionId, inputDelay (processingStart - startTime), processingTime, presentationDelay, and the interaction's duration total.
  • Performance panel flame chart screenshots or exported JSON trace data covering the span from 1 second before to 2 seconds after the lagging interaction.
  • CPU and frame rate samples from the Performance panel's summary tab, including the CPU chart, Frames section, and Main Thread "Scripting", "Rendering", and "Painting" subtotals.
  • Script source location for the top self-time frames: file URL with line and column, plus the enclosing function name, to attribute the work to a specific module.

Where to look

  • The Main Thread track in the browser's Performance panel, specifically the Scripting (yellow), Rendering (purple), and Painting (green) subsections during the 200 ms window before and after the input interaction.
  • The PerformanceObserver 'longtask' entry list produced by code that subscribes via the Performance API, exposed in a debug console or analytics pipeline during reproduction.
  • The Web Vitals Event Timing entries exposed via performance.getEntriesByType('event') or the web-vitals library's onINP callback, accessible in DevTools console during a recorded session.
  • The Bottom-Up and Call Tree views in the Performance panel, grouped by Self Time, to identify which function owns the dominant share of the long task.
  • The "Experience" or "Web Vitals" overlay when present, which surfaces INP candidates and links each to the underlying interaction event for triage.

Diagnostic steps

  1. 01Record a Performance trace that brackets the lagging interaction and confirm the main thread contains at least one block wider than 50 ms immediately preceding the input handler dispatch.
  2. 02If the long block is yellow ("Scripting"), click into it and read the top Self Time frame; if it is purple ("Rendering"), check whether forced layout is involved by looking for alternating "Recalculate Style" and "Layout" sub-tasks in close succession.
  3. 03If the block is green ("Painting") and large, look at the layer borders in the "Layers" panel and check whether the painted region is a single large element without a compositor layer promotion.
  4. 04Compare the longtask's startTime with the Event Timing entry's processingStart: if the longtask started before processingStart and overlaps the input's processing window, it is the dominant cause; if it starts after, look for an earlier task.
  5. 05Inspect whether the long task is in the top-level document, an iframe, or a dedicated worker via the longtask entry's containerType field; cross-origin iframes will show as opaque and require separate investigation.
  6. 06If the longtask's name is 'script' but Self Time is spread across many small frames, the cause is likely cumulative framework work (React reconciliation, virtual DOM diff) rather than one hotspot.

Common mistakes

  • Attributing input lag to CPU saturation when the CPU chart in the Performance panel stays below 60 percent averaged cores; the issue is task duration, not throughput.
  • Reading "No throttling" as proof the trace is representative while ignoring that production devices have slower CPUs; reproduce with "4x slowdown" throttling to surface the issue on a fast machine.
  • Confusing network waterfall blocks (blue) on the Network track with main-thread blocks; long input lag with a moderate CPU is a main-thread problem, not a fetch problem.
  • Trusting frame rate alone and missing script blocks that do not produce a dropped frame but still delay input handler dispatch beyond 50 ms.
  • Ignoring the Event Timing API's inputDelay field and only looking at duration; a low duration with a high inputDelay still indicates a blocked main thread before the handler ran.

Safe fixes

  • If a single synchronous function dominates the Self Time, defer it across requestIdleCallback or a scheduler.postTask call with priority 'background', and chunk the work into units under 50 ms each, gated on input freshness.
  • If layout thrashing is visible as alternating "Recalculate Style" and "Layout" sub-tasks, batch all DOM writes before reading layout properties such as offsetHeight, getBoundingClientRect, or computed style, and wrap in a single read-write boundary.
  • If style recalculation cost is high due to class toggles on large subtrees, scope the change to a smaller root, use CSS custom properties, or move the toggled styles into a more specific selector that reduces the affected node count.
  • If a single painted element is large and unpainted into its own layer, add will-change: transform on the animated element or restructure the DOM so the painted region is smaller; verify in the Layers panel that a separate compositor layer exists.
  • If the work is cumulative framework reconciliation, enable the framework's profiling build, capture why each component rendered, and apply memoization or list-virtualization to the components whose render time appears in the long task's call tree.

Prove the fix

  1. 01Re-record the Performance trace after applying the change and confirm that no main-thread task within the interaction's processing window exceeds 50 ms duration.
  2. 02Re-run the PerformanceObserver longtask subscription during a session of representative user flows and verify the count of entries longer than 50 ms drops to zero (or remains only on non-blocking background tasks).
  3. 03Measure the interaction's inputDelay via the Event Timing API before and after: the new value should fall below 100 ms for the previously lagging interaction at the p75 of user sessions.
  4. 04Confirm the flame chart no longer shows a wide block overlapping the input event's processingStart timestamp, and that the Bottom-Up Self Time for the previously dominant function is now under 10 ms.
  5. 05Run the same flow under "4x CPU throttling" in the Performance panel and verify the interaction completes its handler and paint within the 200 ms INP target band.

Prevention and next steps

  • Add a CI or pre-release check that asserts no script function exceeds a Self Time budget during a representative trace, surfacing regressions before they reach users.
  • Instrument production with PerformanceObserver for longtask entries and ship only the count and total duration per route to analytics; alert when a route's p75 longtask count exceeds a threshold.
  • Establish a coding guideline that any DOM write loop must precede any layout read, enforced by a custom ESLint rule or a shared utility that batches both phases.
  • Periodically re-profile core user flows under "4x slowdown" throttling on a low-end device profile to catch long-task regressions that only appear on slower CPUs.

Safe commands and checks

// Subscribe to long tasks and log their key fields. Paste into DevTools console during reproduction. const po = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.log({ name: entry.name, duration: entry.duration, startTime: entry.startTime, containerType: entry.containerType, containerName: entry.containerName }); } }); po.observe({ entryTypes: ['longtask'] });
// Read Event Timing entries for the last interactions. Paste into DevTools console. performance.getEntriesByType('event').slice(-5).forEach((e) => { console.log({ name: e.name, duration: e.duration, processingStart: e.processingStart, processingEnd: e.processingEnd, startTime: e.startTime }); });
// Inspect raw long-task timings on the page. performance.getEntriesByType('longtask').forEach((t) => console.log({ duration: t.duration, startTime: t.startTime, name: t.name }));