How GitHub Cut Pull Request Rendering Time by 78%

GitHub rebuilt the Files changed tab from the ground up, cutting React components per diff line from 8 to 2 and slashing interaction latency by 78%. Here's how they did it—and what it means for scaling React apps.

How GitHub Cut Pull Request Rendering Time by 78%

TL;DR

  • GitHub rebuilt the Files changed tab, cutting React components per line from 8 to 2 and reducing DOM nodes by 10%
  • INP dropped from 450ms to 100ms on large PRs—a 78% improvement in interaction responsiveness
  • Memory usage halved (150-250 MB down to 80-120 MB) through O(1) lookups and virtualization for massive diffs
  • Matters if you review PRs with hundreds or thousands of changed lines—the lag is gone

The Big Picture

Pull requests are where developers live. At GitHub's scale, that means handling everything from single-line typo fixes to multi-thousand-file refactors. The problem? The Files changed tab was choking on large PRs.

Before optimization, extreme cases saw JavaScript heaps balloon past 1 GB, DOM node counts hit 400,000, and interactions became unusable. Interaction to Next Paint (INP) scores—the metric that measures how quickly a page responds to your clicks—were well above acceptable thresholds. Users could feel the lag.

GitHub's engineering team just shipped a complete rewrite of the diff rendering system. The new React-based experience is now default for all users, and the performance gains are dramatic. This wasn't a single clever trick. It was a methodical teardown of an architecture that made sense in v1 but couldn't scale to the reality of unbounded data.

The work matters because it's a case study in performance engineering at scale. Small changes compound. Removing two DOM nodes per line sounds trivial until you multiply it across 10,000 lines. That's 20,000 fewer nodes. The team didn't chase one silver bullet—they built a layered strategy that addressed different PR sizes with different techniques.

How It Works

The original v1 architecture was a classic React port from Rails. Each diff line was wrapped in 8 to 13 React components, depending on whether you were in unified or split view. Each component carried 20+ event handlers. A single line in unified view required 10 DOM elements; split view needed 15. Add syntax highlighting, and the DOM count exploded.

This worked fine for small PRs. But at scale, the abstraction cost was brutal. Components were thin wrappers designed to share code between views, but they carried logic for both views even though only one rendered at a time. Event handlers were attached at every level. State management was scattered across the tree with useEffect hooks triggering re-renders unpredictably.

The v2 rewrite flattened everything. GitHub gave each view its own dedicated component. Yes, that meant some code duplication. But it also meant simpler data access and no more logic for unused views. The component count per line dropped from 8 to 2.

Event handling moved to a single top-level handler using data attributes. When you click and drag to select multiple lines, the handler checks each event's data attribute to determine which lines to highlight. No more per-line mouse enter functions.

Complex state—commenting, context menus—moved into conditionally rendered child components. In v1, every line carried commenting state even though only a tiny fraction of lines ever have comments. In v2, the diff-line component's only job is rendering code. Commenting state lives in nested components that only mount when needed.

Data access shifted from O(n) lookups to O(1) constant-time operations using JavaScript Maps. Any diff line can now check for comments with a simple map lookup: commentsMap['path/to/file.tsx']['L8']. No iteration, no scanning.

The team also restricted useEffect hooks to the top level of diff files and added linting rules to prevent their reintroduction in line-wrapping components. This enabled accurate memoization and predictable behavior.

For the largest PRs—those in the 95th percentile with over 10,000 diff lines—GitHub added window virtualization using TanStack Virtual. Only the visible portion of the diff list exists in the DOM at any time. As you scroll, elements swap in and out dynamically. This cut JavaScript heap usage and DOM nodes by 10X for massive PRs. INP dropped from 275-700ms to 40-80ms.

Beyond the core rewrite, GitHub optimized CSS selectors (removing heavy :has(...) patterns), re-engineered drag and resize handling with GPU transforms, and implemented server-side rendering that hydrates only visible diff lines. Progressive diff loading means users see and interact with content before the entire PR finishes loading.

What This Changes For Developers

If you review PRs with hundreds or thousands of changed lines, the experience is now usable. The lag when clicking to expand a file or add a comment is gone. The browser doesn't freeze when you scroll through a massive diff.

The memory improvements matter for anyone running multiple tabs or working on a machine with limited RAM. A 50% reduction in heap usage (150-250 MB down to 80-120 MB) means the Files changed tab no longer competes with your IDE, Slack, and 47 other browser tabs for resources.

For teams that generate large PRs—refactors, dependency updates, generated code—this changes the review workflow. Reviewers can actually navigate the diff without waiting for the page to catch up. That means faster review cycles and less friction in the merge process.

The architectural lessons apply beyond GitHub. If you're building a React app that renders unbounded lists or tables, the same principles hold: flatten your component tree, move state to where it's needed, use O(1) lookups, and virtualize when necessary. The v1-to-v2 evolution is a blueprint for scaling React apps that hit performance ceilings.

The Numbers

GitHub measured these improvements on a PR with 10,000 line changes in split diff view:

  • Lines of code: 2,800 down to 2,000 (27% reduction)
  • Unique component types: 19 down to 10 (47% fewer)
  • Components rendered: ~183,504 down to ~50,004 (74% fewer)
  • DOM nodes: ~200,000 down to ~180,000 (10% fewer)
  • Memory usage: ~150-250 MB down to ~80-120 MB (~50% reduction)
  • INP on large PR (M1 MacBook Pro, 4x slowdown): ~450ms down to ~100ms (78% faster)

For p95+ PRs with virtualization enabled, INP dropped from 275-700ms to 40-80ms. JavaScript heap usage and DOM nodes saw a 10X reduction.

The Bottom Line

Use this approach if you're building React apps that render large datasets and you're hitting performance walls. The strategy—flatten components, move state down, use constant-time lookups, virtualize at the extremes—is portable.

Skip the rewrite if your app doesn't render unbounded data or if your performance metrics are already acceptable. Premature optimization is still a trap. But if you're seeing INP scores above 200ms, memory bloat, or user complaints about lag, this is the playbook.

The real risk is ignoring the compounding cost of small inefficiencies. Two extra DOM nodes per line don't matter until you have 10,000 lines. Eight components per line are fine until you render 50,000 of them. GitHub's work proves that methodical, incremental optimization—removing unnecessary tags, consolidating event handlers, restricting hooks—delivers measurable wins. The 78% INP improvement isn't magic. It's discipline.

Source: GitHub Blog