Rethinking the diff line
The Files changed tab is the core of the pull request review workflow, and at GitHub’s scale, it has to hold up whether a PR touches one line or one million. The new React-based experience shipped recently as the default for all users was built with this in mind, but the hardest part of that work wasn’t the feature set—it was keeping the page responsive when the diff is enormous.
In the worst cases, the team observed the JavaScript heap exceeding 1 GB, more than 400,000 DOM nodes, and Interaction to Next Paint (INP) scores that made input lag painfully obvious. There was no single fix that would cover every case, so the approach was layered: optimize the core diff-line rendering for the everyday pull request, add virtualization for the extreme p95+ cases, and invest in foundational components that pay off everywhere. The result was a meaningful drop in memory pressure and INP across the board.
The cost of a v1 diff line
The original React implementation treated every diff line as a composition of many small, reusable components. That design made sense when it was first ported from the classic Rails view, but it scaled poorly. A unified-view line required roughly 10 DOM elements; a split-view line, closer to 15. That was before syntax highlighting added more <span> tags on top.

At the React layer, unified diffs held a minimum of eight components per line and split diffs a minimum of 13, and extra UI states like comments or hover effects could push those numbers higher. Each of those small components carried its own event handlers—often five or six per component, meaning 20+ handlers for a single line before multiplying across thousands of lines.
That strategy was unsustainable for large pull requests. As diff size grew, INP slowed and the JavaScript heap ballooned. The team needed to cut down on elements, components, handlers, and complex state—without losing the behavior developers depend on, like native find-in-page.
Streamlining to v2
The v2 approach was a deliberate exercise in subtraction. Most of the small wrapper components existed to share code between split and unified views, but that shared logic carried cost for both, even when only one rendered. Each view now gets a dedicated component, and while some code is duplicated, the rendering path is much simpler.
Small DOM optimizations compounded quickly. Removing extra <code> tags from line number cells, for instance, saves two nodes per line—20,000 nodes across 10,000 lines. The component structure went from eight components per line to two:




Event handling moved to a single top-level handler that uses data-attribute values to decide what a click or a drag-selection affects. Instead of each line subscribing to its own mouse-enter function, one handler checks each event’s target and applies the appropriate logic.
The most impactful change was moving heavy state into child components that only render when needed. Commenting state and context menus no longer live on every line. A diff line's primary role is rendering code, so it no longer carries the infrastructure for features that only a fraction of lines will ever use. This also enables accurate memoization by preventing useEffect hooks from appearing deep in the diff-line tree—linting rules now enforce that restriction.
The data layer was flattened too. Global and diff state machines now use JavaScript Map for constant-time lookups, so determining whether a line has comments is a direct access like commentsMap['path/to/file.tsx']['L8'] instead of a traversal.
| Metric | v1 | v2 | Improvement |
|---|---|---|---|
| Total lines of code | 2,800 | 2,000 | 27% less |
| Total unique component types | 19 | 10 | 47% fewer |
| Total components rendered | ~183,504 | ~50,004 | 74% fewer |
| Total DOM nodes | ~200,000 | ~180,000 | 10% fewer |
| Total memory usage | ~150-250 MB | ~80-120 MB | ~50% less |
| INP on a large pull request using m1 MacBook pro with 4x slowdown: | ~450 ms | ~100 ms | ~78% faster |
The numbers showed it working. On a test pull request with 10,000 line changes in a split diff view, JavaScript heap size collapsed and INP improved massively—not just on average, but at the p95 and p99 marks. The effort proved that returning "good enough" performance to the common case was attainable.
Virtualization for the extreme end
Even the most efficient diff-line component can’t save you when the pull request spans tens of thousands of lines of code and context. For p95+ cases, the team integrated TanStack Virtual to keep only the visible portion of the diff in the DOM, swapping elements in and out as the user scrolls. That delivered a 10X reduction in both JavaScript heap usage and DOM node count for the largest pull requests, and INP dropped from 275–700+ ms to 40–80 ms.
Beyond the diff line
Performance work continued in adjacent areas. Heavy CSS selectors like :has(...) were replaced, and drag-and-resize interactions were re-engineered with GPU transforms to avoid forced layouts. Server-side rendering now hydrates only the diff lines that are visible, cutting time-to-interactive and memory use on load. Progressive diff loading and background fetches get content in front of the user sooner, while interaction-level INP tracking, diff-size segmentation, and memory tagging in a Datadog dashboard give developers the visibility to catch regressions early.
None of these are silver bullets, but together they keep the Files changed experience fast for a one-line change and functional for a change that touches a million lines.
Why diff lines were slow
Rendering a batch of diff lines originally involved constructing a large tree of React components for every line, regardless of whether that line had any visible content changes. A single line could consume multiple nested elements — one for the line container, one for the line number, one for the content — and when scrolling through a file with thousands of changed lines, the browser had to keep all of those nodes in memory. The extra overhead slowed down initial render and made interactions like selection and hover feel laggy.
Trimming the tree
The refactor collapsed the per-line representation so that a single component handles the full row. We removed the intermediate wrapper elements that were purely structural and had no styling or behavior attached to them. By reducing the number of DOM nodes per line from several to one, we cut the overall node count for a large diff proportionally. That translates directly to less layout work and lower memory usage.
Moving state to the leaves
A major part of the slowdown came from keeping complex state at the top of the diff component tree. Every time a user hovered over a line or opened a comment thread, the state change propagated through every row in the visible viewport, causing a full re-render of that list. We relocated that per-line state into conditionally rendered child components that mount only when the interaction actually occurs. When nothing is active, the parent only needs to render a flat list of static rows, and the dynamic pieces are isolated to the specific lines that need them.
Faster lookups and a rulebook for state
We also changed how the code looks up metadata for a given line. Previously, some paths scanned a collection to find a line by its position. Those lookups now use direct index-based access where the data structure allows it, moving toward constant-time access instead of a linear scan. Alongside that, we adopted stricter conventions for what state can live where, preventing accidental sharing of mutable data across rows.
Measurable results
These changes yielded faster rendering and a more responsive UI, with notable improvements in INP metrics. The number of nodes per line dropped, the component tree is shallower, and the memory footprint for an open diff is lower. The work shows that even in a mature, large codebase, targeted refactoring of a hot path can produce noticeable gains — and that simple, surgical changes can sometimes outperform broad rewrites.
You can see the difference yourself by opening open pull requests on GitHub.



