Search... ⌘K
Write Log In Sign Up

Join DevSolved

Share war stories & investigate outages.

Sign Up for Free Log In to Account
ESC
HIGH Resolved

CSS Grid Layout Thrashing: The 10-Second Scroll Freeze

Avatar Devsolved Mar 25, 2024 — 14:00 UTC MTTR: 12 hours 2 min read 165003 views
Executive Summary

A highly complex React dashboard with thousands of data cells utilized nested CSS Grids. Updating a single cell triggered a cascading browser layout recalculation that completely locked up the main thread.

UX Impact: The main application thread locked for up to 10 seconds every time a user scrolled, making the app entirely unusable on low-end hardware.

The Symptom

Symptom

When users opened the "Financial Matrix" view, the browser became completely unresponsive. Attempting to scroll with the mouse wheel did nothing, until suddenly the page would jump 500 pixels 10 seconds later. Chrome DevTools Performance Profiler showed a massive red block labeled "Recalculate Style" taking 9,500ms.

Root Cause Analysis

Root Cause

The matrix was a massive 100x100 grid of financial data (10,000 DOM nodes). The developers used a deeply nested `display: grid` structure where columns were set to `auto` or `min-content`. Furthermore, a React `onScroll` listener was attempting to calculate the `getBoundingClientRect()` of the container to implement a sticky header. This combination caused "Layout Thrashing" (Forced Synchronous Layout).

5 Whys Root Cause Drill-Down

1
Why #1
2
Why #2
3
Why #3
4
Why #4
5
Why #5

Every time the user scrolled 1 pixel, the `onScroll` event fired. The JS asked the browser for the element's exact height. The browser had to pause JS, calculate the height of all 10,000 dynamically sized grid cells, return the value, and then resume JS. Doing this 60 times a second instantly killed the browser.

Code javascript
// The fatal flaw: Reading layout properties synchronously during paint events
window.addEventListener("scroll", () => {
  // Forces a massive synchronous layout calculation
  const rect = gridRef.current.getBoundingClientRect(); 
  if (rect.top < 0) setSticky(true);
});

Resolution

Resolution

We completely overhauled the architecture. First, we implemented "DOM Virtualization" (using `react-window`), meaning only the 50 rows currently visible on screen were actually rendered in the DOM. Second, we replaced the synchronous `onScroll` math with an `IntersectionObserver`, which allows the browser to calculate element visibility asynchronously off the main thread. Finally, we removed `auto` from the CSS Grid and used fixed pixel widths for cells.
Code javascript
// The fix: Asynchronous Intersection Observer
const observer = new IntersectionObserver(([entry]) => {
  setSticky(!entry.isIntersecting);
});
observer.observe(headerRef.current);

Preventive Action Items

critical Implement DOM virtualization on all tables with > 100 rows @Frontend Team
high Add a linter rule to warn against `getBoundingClientRect` inside scroll loops @Frontend Team
|

Discussions 0

Most recent