Why do sites that strictly enforce a 200KB JavaScript budget sometimes still fail INP when they rely heavily on CSS animations and complex DOM trees?

Because INP is driven by main-thread contention and rendering cost at the moment of interaction, not by how many bytes of JavaScript were downloaded and parsed. A JS budget controls download, parse, and compile cost. It does nothing to control the separate, and sometimes much larger, cost of style recalculation, layout, and paint that CSS animations and large DOM trees generate every time a user clicks, types, or taps. A site can ship a lean 150KB JS bundle and still blow through the INP “poor” threshold if a single interaction triggers a cascading style recalc across a deeply nested DOM or animates a layout-affecting CSS property across dozens of elements.

Why CSS animations and DOM size bypass JS budgets entirely

INP measures the full latency of an interaction: input delay, processing time, and presentation delay until the next frame is painted. Processing time isn’t exclusively “JavaScript execution time,” it’s whatever work the browser has to do in response to the interaction, and that includes the browser’s own style and layout engine work, which happens regardless of whether it was triggered by a JS event handler or a CSS transition/animation.

Two specific mechanisms explain why a small JS budget doesn’t protect you:

CSS animations that trigger layout, not just compositing. Not all CSS animations cost the same. Animating transform and opacity can typically be handled by the compositor thread, largely bypassing main-thread layout and paint work, which is why these two properties are the standard recommendation for smooth animation. Animating layout-affecting properties instead, such as width, height, top, left, or margin/padding, forces the browser to recalculate layout and repaint on every animation frame. If that animation is triggered by or overlaps with a user interaction (a hover-triggered expand, a click-triggered filter panel), the layout/paint cost gets counted against that interaction’s INP, even though the site’s JavaScript payload never grew.

DOM complexity multiplies style recalculation cost. Every time something changes (a class toggled, an attribute set, a CSS custom property updated), the browser has to figure out which elements’ computed styles are affected and recalculate them. In a shallow, simple DOM this is cheap. In a deep, complex DOM tree with many nested elements and complex selectors, style recalculation and layout become inherently more expensive per change, independent of script size. Lighthouse’s “avoid an excessive DOM size” audit exists specifically because DOM complexity has real, measurable performance cost that has nothing to do with JS bytes.

A third mechanism worth separating out is layout thrashing caused by “forced synchronous layout.” Even a small, well-optimized script can be expensive if it reads a layout-dependent property (offsetHeight, getBoundingClientRect, getComputedStyle) immediately after writing a style change, because the browser is forced to flush any pending layout work synchronously to answer that read, rather than batching it with the next natural render. This pattern is common in code that measures an element after toggling a class on it (a common pattern for CSS-driven animations kicked off by JS), and it can produce main-thread cost wildly disproportionate to the amount of code involved. A 5-line event handler that alternates writes and reads across several elements can generate more forced layout work than a much larger script that never touches layout-dependent properties at all, which is another way total bundle size fails to predict INP outcomes.

It’s also worth distinguishing “long tasks” from the specific interaction being measured. A background long task (a large recalculation, a third-party script executing) that overlaps with, but wasn’t caused by, the user’s click still delays that interaction’s processing, because the main thread can only run one task at a time. This means a page can pass every script-size and script-execution-time budget for its own first-party code and still show poor INP if unrelated CSS/layout work or another queued task happens to be running when the user interacts, which is one more reason INP has to be measured at the interaction level (via Chrome DevTools or field data), not inferred from static bundle-size budgets.

A 200KB JS budget is a real and useful control, but it’s targeting a specific cost category (download, parse, compile, and the execution cost of whatever logic is in that bundle). It was never a control on CSS rendering cost, DOM-size-driven style/layout cost, or forced synchronous layout from read/write interleaving, so enforcing it strictly can create a false sense of security about INP specifically, since INP responds to total main-thread and rendering work, not to bundle size alone.

A worked example of a lean bundle still failing INP

Suppose a site keeps its first-party JavaScript at 140KB, comfortably under a 200KB budget, and INP still comes back at 340ms in the field, squarely in “needs improvement” territory. The interaction in question is a filter panel that expands on click: the click handler is only a few lines of code, toggling a class, but that class change animates the panel’s height from 0 to its full value, and the panel sits inside a product-listing page with roughly 4,000 DOM nodes. Every frame of that height animation forces the browser to recalculate layout across a meaningful portion of that DOM tree, and a DevTools trace shows the interaction dominated by purple “Recalculate Style” and “Layout” bars, not script execution.

Swapping the animation to a transform: scaleY() and opacity combination, which the compositor can typically handle without triggering main-thread layout, along with trimming the listing page’s DOM depth, brings INP down to around 150ms without changing the JavaScript bundle size at all. The 140KB budget was never the constraint that mattered here; the layout and style-recalculation cost the CSS animation and DOM size were generating on every click was.

Fixing INP problems rooted in CSS and DOM complexity

Audit CSS animations for which properties they animate. Where possible, convert layout-affecting animations (width/height/top/left) to compositor-friendly equivalents (transform: scale/translate, opacity) that avoid triggering layout and paint on the main thread.

Reduce DOM complexity on interaction-heavy templates, particularly ones flagged by Lighthouse’s DOM size audit. Fewer, flatter, less deeply nested structures reduce the cost of every style recalculation and layout pass, which compounds across every interaction on the page, not just initial load.

Check first-party code for read/write interleaving that forces synchronous layout. Batch DOM reads before writes within an event handler rather than alternating them, and where a measurement is genuinely needed after a style change, defer it to the next animation frame with requestAnimationFrame rather than reading immediately. This avoids forcing the browser to flush layout mid-task, and it’s often a cheaper, more surgical fix than restructuring markup or rewriting animations, since it changes ordering rather than removing functionality.

Use Chrome DevTools’ Performance panel to record an actual interaction and inspect the breakdown: look specifically at “Recalculate Style” and “Layout” entries triggered around the interaction, not just script evaluation time. A purple “Recalculate Style” or “Layout” bar with a warning icon in the trace typically indicates forced synchronous layout, and DevTools will usually point at the specific call stack responsible. If those entries dominate the trace on a page with a small JS payload, that confirms the CSS/DOM-complexity mechanism rather than a JS execution problem, and tells you the fix belongs in CSS and markup structure, not in trimming already-small script further.

Finally, validate against field data, not just lab traces. The Chrome UX Report and the web-vitals JavaScript library both report INP from real users, including the specific element that was interacted with in some breakdowns. Since CSS-animation and DOM-complexity costs can vary by device class (a low-end Android phone will show layout/recalc cost far more prominently than a high-end desktop for the exact same markup), confirming the fix actually moves field INP, not just a local DevTools trace on a fast machine, is the only way to know the diagnosis was right for the audience that actually matters.

Leave a Reply

Your email address will not be published. Required fields are marked *