Rendering huge pull requests in the GitHub Copilot app

Teams often have to merge broad refactors and migrations as a single change. While stacked pull requests help split work into smaller,…

By Vane September 23, 2026 7 min read
Rendering huge pull requests in the GitHub Copilot app

Teams often have to merge broad refactors and migrations as a single change. While stacked pull requests help split work into smaller, safer chunks, some changes simply cannot be divided cleanly. This leaves developers facing a single, massive pull request where the review conversation causes the diff to grow even larger.

The review experience must remain fast and smooth even when the diff and its associated comments are enormous. To address this, the engineering team rebuilt the pull request view within the GitHub Copilot app, prioritising performance for these extreme cases.

To demonstrate the scale of the challenge, the team tested the system with the largest open source pull request available. That specific request contained 2,200 files, over a million changed lines, and more than 400 inline review comments.

The scope of the problem

Rendering a large diff at speed is a well-understood task. Engineers typically virtualise rows, keep the mounted DOM small, and rely on the fact that every row is a line of code at a known height.

Comments present a harder problem. A comment’s height depends on how its markdown wraps, whether expandable sections are open, if a reply box is present, and whether images have finished loading. Determining these details happens only at render time, which forces a different architectural approach.

The team identified three core issues:

  • Measurement. You cannot determine the height of a comment until you render it. This breaks the design that allows large diffs to remain responsive while scrolling.
  • The data pipeline. A fast diff surface is useless if the data feeding it stalls or discards work it has already performed.
  • Debugging. These problems emerge under load, on specific engines, and at specific scroll positions. The team defined what a healthy experience meant, instrumented the interface to measure it, and ran a continuous loop of change, measurement, and improvement without manual intervention.

Part 1: Virtualization, and why comments break it

The first step is understanding the geometry that makes a code-only diff fast. Once comments enter the picture, that geometry is no longer sufficient.

What makes big diffs fast

You cannot place a million DOM nodes on a single page. The standard solution is virtualisation: mount only the rows currently on screen plus a small margin, and recycle those same DOM elements as the user scrolls. The list behaves as if all million rows exist, the scrollbar is the correct size, and scrolling to a specific row works. In reality, only about 100 rows are ever active at once.

For this illusion to hold, something must supply the geometry. The scrollbar height is the sum of all row heights. The position of row N is the sum of the heights of the rows above it. Calculating the scrollbar position, drawing it, and deciding what is visible relies entirely on arithmetic over a table of heights. You can build that table from estimates and correct it as rows get measured, and general-purpose variable-height virtualisers do exactly that.

However, if every row is a line of code at a known font size, you do not need estimates. You can compute the whole table up front and it never changes, so there is nothing to correct later.

This is the “all heights known before paint” contract. The diff surface is built around it:

  • An imperative, recycled code-row renderer (no React component per row)
  • Typed-array geometry for the offset math
  • Backend-owned diff documents streamed structure-first
  • An imperative scroll API with exact “scroll to row N” functionality

None of this scales badly because no per-frame work grows with the total row count. On pure code, this design is correct, and the team kept all of it.

How comments change the contract

Now place a review thread in the middle of the diff. How tall is it?

You do not know, and you cannot know without rendering it. Its height depends on factors that only exist at render time and can change after the first paint:

  • Markdown that wraps differently at different widths
  • <details> blocks the user can expand or collapse in place
  • A reply composer that opens inside the existing thread and grows as you type
  • Suggested-change diffs, reactions, edit mode, and resolution banners
  • Images and async assets that change height when they finish loading

The obvious answer is to reserve a fixed-height slot for each comment, sized by an estimator. This fails on a large pull request. An estimator that is correct on average is still wrong at the extremes. It over-reserves most comments, leaving gaps of whitespace, and under-reserves the expensive ones, which clip content or spawn a nested scrollbar. If you measure the real height after paint and write it back into the shared offset table, everything below moves, while the user is already scrolling. That is a scroll jump, and on a large pull request, it is a large one.

Comments need a different contract. “All heights known before paint” is unachievable for this content. The new promise is: heights are bounded, measured lazily, and corrections are small and anchored to whatever the user is looking at.

Two geometries instead of one

The idea that made this tractable was to stop forcing one geometry to serve both kinds of content. The team split the document’s height into two independent domains:

total height = deterministic code height (exact, known up front) + Σ dynamic block effective heights (estimated, then measured) + scroll padding

Code geometry keeps the original world. It is deterministic, prefix-summed, exact, and never rebuilt when a comment resizes.

Dynamic block geometry covers everything whose height cannot be predicted, such as review threads, drafts, and reply composers. Each one is a block identified by what it is rather than where it currently sits. It has a stable key that survives its content loading and is anchored to a file, line, and side rather than a pixel coordinate, so a reflow cannot lose track of it. The system also keeps a fingerprint of everything that could change the block’s height: its content, whether a <details> is open, and whether a composer is active. It records the width the block was last measured at, rounded into buckets, so an ordinary window resize does not invalidate every measurement in the document.

A block’s effective height is then simple: the measured height if a valid one exists, a cached height if the fingerprint and width still match, and the estimate otherwise. Those heights live in their own index, separate from the code rows, so a resizing comment never forces the code geometry to be rebuilt. The number of blocks is bounded by comments, not by rows. A few thousand blocks is fine, as long as first paint never mounts or measures all of them at once.

The measurement scheduler, and the mistake we made first

This part took the longest to get right, because the first design was wrong in an instructive way.

The obvious way to measure dynamic content is one ResizeObserver per block, which watches the element and writes its measured height back into the layout whenever it changes. This was the initial design, but it was rejected during performance hardening. It is the feedback loop that big virtualised surfaces must avoid. An observer that writes a height back into the layout of the element it is watching can retrigger itself, and the cost grows with every mounted block.

What shipped instead is a single idle- and scroll-gated measurement pass, held to the same discipline as the deterministic side:

  • Off the hot path. It runs when the visible range settles, never once per scroll frame, and waits entirely while a scroll is in flight. A reflow mid-scroll is exactly the jank the system is avoiding. It runs again once scrolling stops.
  • Scoped to the viewport. Only blocks within roughly 2400px of the viewport are candidates, so the work is O(viewport). Distant blocks keep riding their estimate and get corrected as they approach.
  • On-screen reads win. A mounted block is on screen, so its rendered height is ground truth. The pass reads every mounted candidate in one batch, a single reflow with no writes in between, and records what it finds. A mounted block is never skipped in favor of a stale estimate. This one rule fixed the nastiest bug encountered: comments that rendered with a strip of blank space underneath, because a mounted block had been filtered out of measurement and left sitting on a too-tall estimate.
  • Off-screen measurement is a bounded fallback. For a nearby block that has not mounted yet, the pass does at most one off-screen render to correct its reservation before it scrolls into view. Blocks taller than the viewport skip even that. Their over-reservation hides below the fold, so the render is not worth paying for.
  • An observer catches the rest. Some height changes do not move the fingerprint and do not coincide with a scroll: typing in a reply composer, an image finishing loading, and similar events. For these, a lightweight observer handles the update.
Scroll to Top