cf.completefrontend
My progress
SYSTEM DESIGN · #110

Design a code review diff viewer

The "Files changed" page of a pull request: every changed file as a diff, inline comment threads anchored to lines, a file tree, and a review you build up in drafts and submit at once. The hard parts are showing a 1,000-file, 100,000-line change without freezing the tab, and keeping each comment attached to the right line while new commits keep moving the code under it.

The question

Design the page where engineers review a pull request: the list of changed files, each shown as a diff with added and removed lines, comments on any line, and a way to approve the change or ask for more work. Most pull requests are small, but some touch thousands of files.

Walk me through where the diff comes from, how the page stays fast on a huge pull request, how a comment stays on the right line when the author pushes new commits, how a review goes from drafts to submitted, and what happens when someone pushes while you are reading.

Real-life examples: GitHub pull request "Files changed" tab, GitLab merge request "Changes" tab, Gerrit change screen, Phabricator Differential, Bitbucket pull request diff

Requirements: what to ask before drawing anything

A diff viewer looks like a simple table: old line on the left, new line on the right, red and green. The trouble hides in the sizes and in time.

A pull request (PR) can change one line or regenerate 4,000 lockfile entries. It can sit open for two weeks while the author pushes 30 commits, and every push moves the lines that reviewers have already commented on. So the questions to ask first are about scale, about how long a review lives, and about who else is on the page at the same time.

A few words we will use all the way through. A diff is the list of differences between two versions of a file. The base is the version the PR wants to merge into (the left side), the head is the PR's latest commit (the right side).

A hunk is one block of nearby changes with a few unchanged lines around it for context. A review is a set of comments plus a verdict (approve, request changes, or just comment) that the reviewer submits together.

QuestionAnswer we assumeWhat it changes
How big are PRs?Median: 5 files, 150 changed lines. Long tail: 3,000 files and 200,000 lines (renames, generated code, vendored libraries).The long tail forces lazy loading per file and line virtualization. A design for the median alone freezes on the tail.
Who computes the diff?Our server has the git repository. The browser does not.Diffs arrive from the server. The browser only diffs words inside a line, and maps draft comments through hunks the server computed.
How long does a review live?Hours to weeks. Authors push new commits while reviews are in progress.Comments must be anchored to a commit, and must survive or be marked outdated as the code moves.
How many people at once?1 to 10 reviewers on a PR, sometimes at the same moment.New comments and new commits must reach open tabs, but must not rearrange the page under the reader.
Which devices?Desktop first, 1280 pixels and wider. Phones can read and comment, not the main target.Split view (two columns) is the default on wide screens. Unified view is the fallback on narrow ones.
Syntax highlighting?Yes, for about 200 languages.Highlighting is expensive. It runs off the main thread and only for lines near the viewport.
Accessibility bar?WCAG 2.2 AA. Reviewers who use screen readers and keyboard only must be able to review.Added and removed must be announced in words, not only in colour. Every action has a keyboard path.

Functional requirements

  • Show every changed file of a PR as a diff, in unified (one column) or split (two columns) view, with syntax highlighting and word-level highlights inside changed lines.
  • Expand hidden context above, below, or between hunks on demand, up to the whole file.
  • Handle renames, moves, binary files, deletions, mode changes, and generated files sensibly.
  • A file tree sidebar: filter by name or extension, jump to a file, show comment counts, and a "viewed" checkbox per file.
  • Comment on one line or a range of lines, on either side. Reply in threads, resolve and unresolve threads, and propose a suggested change that the author can apply as a commit.
  • Build a review: draft comments are saved as you go, then submitted all at once as Comment, Approve, or Request changes.
  • Compare any two commits of the PR, not only base to head ("show me only what changed since my last review").
  • See new comments from others without reloading. Learn about new commits through a banner, not a sudden reflow.
  • Keyboard shortcuts for moving between files, hunks, and comments, and deep links to a single line.

Non-functional requirements

  • Time to first diff: the first file's diff visible as plain text within 1 second on a typical PR, and within 1.5 seconds for a 1,000-file PR on a mid-range laptop.
  • Interaction to Next Paint (INP, how long the page takes to show a response to a click or key) under 200 ms, also on huge PRs. Typing in a comment box must never lag.
  • Memory: stay under about 500 MB for the tab even on a 100,000-line PR, which means we cannot keep every line as a live DOM node.
  • No lost work: a half-written draft comment survives a reload, a crash, and a new push.
  • Correct anchoring: a comment never silently jumps to a different line. If we cannot place it with confidence, we say it is outdated.

Out of scope

We leave out the PR conversation tab, CI checks, merge buttons, and the git server itself. We also leave out editing files in the browser, except for applying suggestions. Mention them, then move on: the interviewer wants the diff, the comments, and the review.

Check your understanding · Exercise 1

Which answers to your scope questions would change the design the most? Select all that apply.

What makes a diff viewer hard

Before choosing anything, name the forces that will push on every later decision. A diff viewer has five of them, and each one rules out a design that would be fine for a normal page.

  1. The size is unbounded and very uneven. One PR has 3 lines, the next has 3,000 files. Worse, size is uneven inside one PR: 2,990 tiny files plus one 40,000-line generated schema. Any step that costs time per line, done for every line up front, will freeze the tab on the tail. So nothing may be per-line and eager: not parsing, not highlighting, not DOM.
  2. Rows have different heights. A diff line is one row of text, but a comment thread under it can be 40 pixels or 900 pixels tall, an expanded context gap adds 20 rows, and a collapsed file is one header. Long lines can wrap. That breaks the simple fixed-height virtualization trick, as the virtualized-list guide explains for variable heights.
  3. The code moves under the comments. A comment is written on line 42 of commit a1b2c3. The author then pushes a commit that inserts 10 lines above it, or deletes it. The viewer must decide where the comment now belongs, or admit that it no longer has a place. The google-docs guide solves a cousin of this for live text; here the text changes in big jumps between commits, not keystroke by keystroke.
  4. Reviews are long, stateful, and shared. A review is built over minutes or hours: draft comments, viewed checkboxes, a scroll position. Other reviewers comment at the same time. The author pushes. All of this must survive reloads and must not yank the reader's place on the page.
  5. Reading is the main job, and it's a keyboard job. Reviewers read hundreds of lines, jump between files and threads, and type long comments. Smooth scrolling, instant key response, and a working screen reader path matter more here than flashy features.

These five forces give us the plan for the rest of the guide. First, where diffs come from and in what shape (forces 1 and 3), then how to load and render huge diffs (forces 1 and 2). Then the file tree, inline comments and anchoring (force 3), the review state machine and real-time updates (force 4), and keyboard, deep links, and accessibility (force 5). Architecture, data model, and API follow once the hard parts are clear.

Wireframe of the Files changed page for pull request 4182. The top bar has the PR number, a commit range picker set to all commits, split and unified toggles with split selected, and a Review button showing 3 pending comments. The left sidebar is the file tree with a filter box and four files: api.ts marked viewed, cart.ts highlighted as the current file, pay.ts, and package-lock.json. The main column shows the header of cart.ts with plus 12 minus 4 and a viewed checkbox, a hunk header, split diff rows with a removed line on the left and added lines on the right, an inline comment thread with 2 replies, and below it a collapsed generated file.PR 4182: Retry failed payments#4182all commitssplitunifiedReview (3)filter .tsapi.tscart.tspay.tslock.jsonfile treeviewed, currentcart.ts +12 -4 [ ] viewed@@ -120,7 +124,9 @@thread: user can be null2 replies, unresolvedpackage-lock.json generated
Every part maps to a chapter: the tree is built from metadata alone, each file section has its own load state, and threads are full-width rows between diff lines, which is why row heights vary.

The wireframe above names the parts we will build. A top bar holds the PR title, the commit range picker ("all commits" or "changes since your last review"), the split or unified toggle, and the review button with a count of pending comments. The left sidebar is the file tree.

The main area is a long column of file sections, each with a sticky header (path, lines added and removed, viewed checkbox, a menu) and a body of hunks. Comment threads sit between diff lines, as full-width rows.

Think first

If you had only 10 minutes in the interview, which of the five forces would you spend them on, and why?

Getting the diff: who computes it, and in what shape

The naive plan: download the old and new version of every changed file and run a diff in the browser. It sounds clean, and for a 3-file PR it works. Now take the 1,000-file PR. The browser must download 2,000 full files (maybe 60 MB, when the actual changes are 2 MB), and diff them on the main thread before it can show anything.

A diff of two 20,000-line files with many changes can take hundreds of milliseconds by itself. And the browser still can't detect renames well, because rename detection needs to compare every deleted file with every added file.

So the first decision is where the diff is computed and what travels over the wire. A short reminder of the algorithm first, because it shapes the options. Most diff tools, including git diff by default, use Myers' algorithm (Eugene Myers, 1986). It finds a shortest edit script: the fewest line insertions and deletions that turn the old file into the new one.

Myers' cost grows with the file length times the number of differences, so it is fast when two files are similar and slow when they differ a lot. Git also offers patience and histogram variants, which produce more readable hunks for code where many lines look alike (lone braces, blank lines).

Decision Where is the diff computed, and what does the client receive?

Client diffs full files

Fetch old and new contents of every file.

Run Myers (for example the diff npm package) in a worker, build hunks locally.

Pros

  • The server stays simple: it only serves file contents.
  • Any two versions can be compared without a new endpoint.

Cons

  • Downloads whole files instead of changes: often 10 to 30 times more bytes.
  • Rename and move detection across 1,000 files is slow and weaker than git's.
  • Results may differ from what git diff and the CLI show, which confuses reviewers.

Server sends raw unified diff text

The server runs git diff base...head and streams the text with @@ hunk headers.

The client parses it into files, hunks, and lines.

Pros

  • Small payload: only changed lines plus 3 lines of context.
  • Same output as git, so renames and binary files are handled by git.

Cons

  • Parsing a 50 MB patch on the main thread blocks it.
  • No per-file metadata (comment counts, generated flag, viewed state) without a second request.
  • All or nothing: hard to load one file at a time.

Server sends structured diffs per file

A file list endpoint returns metadata for every file: path, old path, status, added and removed counts, size, flags.

A diff endpoint returns parsed hunks for a batch of files, each line typed as added, removed, or context, with old and new line numbers.

Pros

  • The page can show the file tree and the first files at once, then load the rest lazily.
  • The server caches each file's diff by its two blob hashes, so it's computed once for all reviewers.
  • Flags like generated or too large let the client collapse a file without downloading it.

Cons

  • More API surface to design and version.
  • JSON per line is bigger than raw text, so it needs compression and batching.

What we pick: Structured diffs per file, computed on the server with git. Git already does renames, binary detection, and the readable diff variants, and the result is the same for every reviewer, so caching it by the pair of blob hashes pays off. The client keeps one small diffing job of its own: word-level highlights inside a changed line, where Myers on two short lines is cheap. To re-anchor unsaved drafts it maps lines through hunks the server computed between the two head blobs, fetched with the same batch endpoint.

Hunks, context lines, and expanding context

A unified diff groups changes into hunks. Each hunk starts with a header like @@ -120,7 +124,9 @@: in the old file it covers 7 lines starting at line 120, in the new file 9 lines starting at 124. A count of 1 is left out, so a hunk with one removed and one added line reads @@ -14 +16 @@.

By default git keeps 3 unchanged lines of context on each side of a change, and merges two changes into one hunk when their context would overlap. Everything else is hidden in gaps between hunks.

Anatomy of one hunk with header at -120,7 +124,9. Above it is a hidden gap with an expand button. The hunk has 3 context lines, old 120 to 122 and new 124 to 126; 1 removed line, old 123, const timeout = 30; 3 added lines, new 127 to 129, const timeout = 60, const retries = 3, let tries = 0; and 3 more context lines, old 124 to 126 and new 130 to 132. Below is another gap where old line 127 is new line 133.hidden gap: expand 20 lines up@@ -120,7 +124,9 @@120124function charge(o) {121125const cfg = load();122126log("charge");123-const timeout = 30;127+const timeout = 60;128+const retries = 3;129+let tries = 0;124130return send(o, {125131timeout,126132});3 context1 removed3 added3 contextgap: old 127 is new 133
The header -120,7 +124,9 says the hunk covers 7 old lines from 120 and 9 new lines from 124. Inside the gap below, every old line number plus 6 is its new number, so one fetch fills both columns.

Expanding a gap means fetching unchanged lines from the file at the head commit (or the base: they are identical inside a gap). The client knows the gap's range on both sides from the neighbouring hunk headers, so it asks for exactly those lines: GET /blobs/{blobSha}/lines?from=133&to=152. Expand in steps of 20 lines up or down, plus "expand all" for small gaps, so a reviewer never pulls a 10,000-line file by accident.

Inside a gap, old and new line numbers differ by a fixed offset. Below the hunk in the figure, new = old + 6: the file already had 4 extra lines before line 120, and this hunk added 2 more. So old line 127 is new line 133, and one fetch fills both columns.

Word-level highlights inside a changed line

Line diffs say "line 124 was replaced". The reviewer wants to see that only timeout: 30 became timeout: 60. For that we pair each removed line with the added line that replaced it (in order, within a run of removals followed by a run of additions), then run Myers again on the two lines, this time on tokens: words, numbers, and punctuation.

Changed tokens get a darker background. Two guards keep it useful: skip pairs where less than about 40% of the text is shared, because highlighting everything is noise, and skip lines longer than about 1,000 characters (minified files), because the cost is no longer small.

ts
type Token = { text: string; changed: boolean };

function pairRuns(removed: string[], added: string[]) {
  const pairs: [string, string][] = [];
  const n = Math.min(removed.length, added.length);
  for (let i = 0; i < n; i++) pairs.push([removed[i], added[i]]);
  return pairs; // extra lines on either side stay unpaired: fully red or green
}

function wordDiff(oldLine: string, newLine: string): [Token[], Token[]] | null {
  if (oldLine.length > 1000 || newLine.length > 1000) return null;
  const a = oldLine.match(/\w+|\s+|[^\w\s]/g) ?? [];
  const b = newLine.match(/\w+|\s+|[^\w\s]/g) ?? [];
  const script = myers(a, b); // same algorithm, on tokens instead of lines
  if (script.sharedRatio < 0.4) return null; // mostly different: don't paint noise
  return [script.oldTokens, script.newTokens];
}
Pairing a run of removed lines with the added lines that replaced them, then diffing the words.

Renames, moves, binary files, and generated files

  • Renames. Git pairs a deleted file with an added file when they are at least 50% similar (the -M default). The file list then says status: renamed, with previousPath, and the diff shows only the lines that changed. Without this, a renamed 2,000-line file shows as 2,000 red lines plus 2,000 green ones, which nobody can review.
  • Moved code inside or across files. Git's --color-moved marks blocks of lines that were removed in one place and added in another. Showing moved blocks in a separate colour tells the reviewer "this is not new code, skip it".
  • Binary files. Git reports only that they differ. The viewer shows the size change, and for images a before and after view (side by side, swipe, or onion skin) using the two blob URLs.
  • Generated and vendored files. Lockfiles, snapshots, compiled output. GitHub lets repositories mark them with linguist-generated in .gitattributes and collapses them by default. The server sets a generated flag, the client shows a one-line header with a "load diff" button.
  • Mode and whitespace changes. A file that only became executable shows a mode line, not an empty diff. A "hide whitespace changes" toggle asks the server for a diff computed with -w, a different cached result.
Example
ViewContext
Gaps

4 removed, 6 added, so the edit distance D is 10. 2 hunks with 1 line of context, 6 unchanged lines hidden in gaps. unified view, word marks on.

Diff lab result. 4 removed, 6 added, so the edit distance D is 10. 2 hunks with 1 line of context, 6 unchanged lines hidden in gaps. unified view, word marks on.3 unchanged lines hidden@@ -4,6 +4,8 @@44   const cfg = load();5-   log("charge", o.id);6-   const timeout = [-30-];5+   metrics.count("pays");6+   const timeout = {+60+};7+   const retries = 3;78   return send(o, {89     timeout,10+     retries,911     url: cfg.payUrl,3 unchanged lines hidden@@ -13,5 +15,5 @@1315 function log(tag, id) {14-   console.[-info-](tag, id);16+   console.{+debug+}(tag, id);1517 }1618 17- export const VERSION = [-1-];19+ export const VERSION = {+2+};
4 removed, 6 added, so the edit distance D is 10. 2 hunks with 1 line of context, 6 unchanged lines hidden in gaps. unified view, word marks on. Word marks use git's word-diff style: [-old-] and {+new+}. Lines that share less than 40% of their text get no word marks, because marking everything is noise.

Check your understanding · Exercise 2

Why does this design compute file diffs on the server but word-level highlights in the browser? Select all that apply.

Loading a 1,000-file PR without freezing

Picture the naive page: one request returns the diff of all 1,000 files, then React renders every line. Say 100,000 lines in split view. At about 15 DOM elements per line before highlighting, that's 1.5 million elements. The tab can spend many seconds in layout, the heap can pass 1 GB, and every click can take hundreds of milliseconds to respond.

This is not a made-up case. GitHub's engineers wrote in 2026 that before their rework of the React Files changed page, extreme PRs pushed the JavaScript heap past 1 GB and the DOM past 400,000 nodes, with sluggish interactions.

Two levers fix this, and we need both. Loading: don't fetch or parse what the reader hasn't reached. Rendering: don't keep DOM for lines that aren't on screen. This chapter is the loading lever; the next chapter is rendering.

Decision How should the page fetch diffs for a PR with 1,000 files?

One request for everything

GET /prs/42/diff returns all files and hunks in one JSON document.

Pros

  • Simplest code. One loading state.
  • Native find-in-page works, if it's all rendered.

Cons

  • Nothing shows until the slowest, biggest file is diffed, sent, and parsed.
  • A 20 MB JSON parse alone can block the main thread for hundreds of ms.
  • Most of it is never read: reviewers often look at 20 files and skim the rest.

File list first, then diffs in batches by scroll position

Request 1: the file list with metadata only (about 200 bytes per file, 200 KB for 1,000 files).

Then fetch diffs in batches of about 20 files or 2,000 lines, starting with the first files and any file named in the URL.

Fetch the next batch when the reader scrolls within a few screens of it, and in idle time.

Pros

  • The tree, counts, and first file show fast.
  • Each batch is small enough to parse quickly, ideally in a worker.
  • Big and generated files can be skipped until asked for.

Cons

  • More states per file: not loaded, loading, loaded, failed, too large.
  • Scroll height is unknown until a file loads, so placeholders need estimated heights.

One file at a time (review one file per screen)

Show only the selected file's diff. The tree switches files, like Gerrit's per-file view.

Pros

  • Tiny memory and DOM, no matter the PR size.
  • Very simple rendering.

Cons

  • Reviewers lose the long scroll that lets them read related changes across files.
  • Every file switch is a wait unless you prefetch the next one.

What we pick: File list first, then diffs in batches driven by scroll position and idle time. It keeps the long scrolling page that most reviewers expect, shows the first diff quickly, and bounds the parse work per step. We keep a per-file view as an option for giant PRs, and we treat the per-file states as a small state machine instead of a pile of booleans.

What starts collapsed

Even with lazy loading, some files should never auto-render. The file list carries enough metadata to decide before fetching. GitHub publishes its own limits, which are a good sanity check for ours. It auto-loads 400 lines or 20 KB of a single file, and stops one file's diff at 20,000 loadable lines or 500 KB of raw diff.

A whole pull request diff on GitHub stops at 20,000 loadable lines or 1 MB of raw diff, and one diff shows at most 300 files. Our rules follow the same idea:

File kindInitial stateWhy
Normal, under 400 changed linesLoaded and expanded when near the viewportThis is what review is for.
Large, 400 to 20,000 changed linesHeader only, with a "Load diff" button and the countsOne big file can cost more than 100 small ones. Make loading it a choice.
Over 20,000 lines or 500 KB of diffHeader and a link to the raw diff or a local checkoutNo browser view helps here. Be honest instead of slow.
Generated or vendoredCollapsed, labelled "Generated"Lockfiles and snapshots are rarely reviewed line by line.
Deleted fileCollapsed with line countHundreds of red lines carry little information.
Binary or imageSize change, image preview on expandThere are no lines to diff.
Marked viewed by me, unchanged sinceCollapsedThe reviewer already read it.
State machine for one file section. listed goes to loading when it comes near the viewport, to collapsed when it is big or generated, and to tooLarge when it is over the limit. collapsed goes to loading when the user clicks Load diff. loading goes to ready when the batch succeeds or to failed on an error; failed goes back to loading on retry. ready goes to stale when a push changes the file, and stale goes back to loading when the reader refreshes.collapsedlistedtooLargeloadingreadyfailedstalebig, generatedover limitnear viewLoad diffbatch okerrorretrypushrefreshonly ready has rows
Only ready holds lines and rows. collapsed and tooLarge are resting states that only a click leaves, and stale waits for the reader instead of reloading by itself.

The state machine above is what each file section goes through. listed means we only have metadata. When the section comes within about 2 screens of the viewport (an IntersectionObserver with a large rootMargin), it moves to loading and joins the next batch request. A response moves it to ready.

A failed batch moves its files to failed with a retry button, without breaking the other files. collapsed and tooLarge are resting states that only a user click leaves. When a new commit changes a file, it goes back to stale until the reader chooses to refresh, which we cover in the real-time chapter.

Batching and parsing off the main thread

Batches are sized by lines, not files, because a 3,000-line file and a 3-line file aren't the same work. A batch closes at about 20 files or 2,000 lines, whichever comes first. The response is compressed JSON, often 5 to 10 times smaller on the wire.

We parse it and build the per-line view model (line numbers, kind, pairing for split view) in a Web Worker, then post a compact structure back. Parsing a 2,000-line batch is a few milliseconds even on the main thread, but a worker keeps the occasional giant batch from causing a long task, and the same worker later does syntax highlighting.

Priority matters. The order of fetches is: the file in the URL hash (a deep link), then files in the viewport, then the next few files below, then everything else in idle time with requestIdleCallback (with a setTimeout fallback for browsers that lack it), capped at 2 requests in flight. A fast scroll to the bottom cancels queued batches for files it flew past with AbortController, so the network serves where the reader is, not where they were.

Check your understanding · Exercise 3

A reviewer opens a 1,200-file PR with a link to #diff-9f2c...R88 in file number 870. What should happen first? Select all that apply.

Rendering lines: cheap rows, virtualization, and highlighting

Lazy loading stops us from fetching 100,000 lines at once. But a reviewer who scrolls through a big PR still ends up with tens of thousands of loaded lines, and if each one stays in the DOM, we're back to a slow page. Two things decide the cost: how heavy one rendered line is, and how many lines are rendered at a time.

First, make one line cheap

GitHub's write-up on their React Files changed page is the best public data here. In their first version, a unified line was about 10 DOM elements and a split line about 15, before syntax highlighting, built from 8 to 13 small React components with 20 or more event handlers per line.

Their second version cut it to 2 components per line, moved all mouse handling to one handler at the top that reads data- attributes, moved comment and menu state into child components that only exist on lines that need them, and replaced array searches with Map lookups like comments[path][line]. On a 10,000-line split diff, memory dropped from about 150 to 250 MB to about 80 to 120 MB, and INP from about 450 ms to about 100 ms (measured with a 4 times CPU slowdown).

  • One component per row type (unified line, split line, hunk header, thread row), not a tree of tiny wrappers shared by both views.
  • No per-line event handlers. One pointerdown and one click listener on the file body find the line from event.target.closest('[data-line]').
  • No per-line state. "Is this line selected?" is a selector over a range stored once per file, so a selection change re-renders only the rows whose answer changed.
  • No useEffect inside line components. Effects run per file, not per line.
  • Line content is plain text plus highlight spans, rendered once and memoized on the line's id and the highlight version.

Then, render only what's visible

Decision How many lines should be in the DOM at once?

All loaded lines, with content-visibility

Render every loaded file normally.

Put content-visibility: auto with a contain-intrinsic-size estimate on each file section, so the browser skips layout and paint for off-screen files.

Pros

  • Native find-in-page (Ctrl+F) and text selection across files keep working.
  • Very little code. Supported in all major browsers since 2024.

Cons

  • DOM nodes and memory still grow with every loaded line: it saves rendering work, not memory.
  • React still has to build and diff every row on updates.

Full virtualization of rows

Flatten the PR into one list of rows: file headers, hunk headers, lines, comment threads, expand buttons.

Render only the rows in the viewport plus an overscan of about 1 screen above and below, using measured heights (TanStack Virtual or similar).

Pros

  • DOM size is constant: a few hundred rows whatever the PR size.
  • Memory grows only with data, not DOM.

Cons

  • Breaks native find-in-page, so we must build our own search.
  • Variable heights (threads, wrapping) need measurement and scroll anchoring.
  • Screen readers can't see off-screen rows, so we must expose total counts.

Hybrid by size

Under a line threshold (say 10,000 rendered lines): render normally with content-visibility.

Above it: switch to full virtualization and our own search.

Pros

  • Most PRs keep every native behaviour.
  • The tail stays usable.

Cons

  • Two rendering paths to build and test.
  • The switch must happen at load, not mid-scroll, or the page jumps.

What we pick: The hybrid. It's what GitHub ended up shipping: optimized normal rendering for most PRs, and TanStack Virtual for the largest ones (their p95 and up, over 10,000 diff lines), which cut heap and DOM nodes by about 10 times and brought INP from 275 to 700 ms down to 40 to 80 ms. Most reviewers keep native find and selection; the tail stays responsive. We decide the mode from the file list's total line count, before rendering.

The virtualization lesson covers the basic idea. Virtualizing a diff is the variable-height case from the virtualized-list guide, with two extra twists. First, rows change height after render: a thread expands, a reply is posted, a context gap opens 20 lines. We measure each row with a ResizeObserver and keep a running offset table, so the total height and the position of any row are known.

Second, when a row above the viewport grows, the content the reader is looking at must not move. We keep a scroll anchor: the id of the first fully visible row and its distance from the top. After any height change above it, we set scrollTop so that row is back where it was. Browsers do something similar for normal pages (CSS scroll anchoring, which Safari only added in 2026), but a virtualizer that swaps rows for spacers has to do it itself anyway.

A flattened list of rows of different heights: the cart.ts header, lines 118 and 119, a 3-comment thread, lines 120 to 122, an expanded gap, lines 140 and 141, the pay.ts header, and line 1 of pay.ts. Lines 120 to 140 are in the viewport and rendered. Line 119, the thread, line 141, and the pay.ts header are overscan, also rendered. The cart.ts header, line 118, and pay.ts line 1 are not in the DOM; only their heights are known. A note says the scroll anchor is line 120, kept 6 pixels from the top when rows above change height.cart.ts headerline 118line 119thread, 3 commentsline 120line 121line 122expanded gapline 140line 141pay.ts headerline 1height onlyoverscanviewportoverscanheight onlyanchor: line 120kept 6 px from topwhen rows abovechange height
File headers, hunk headers, lines, gaps, and threads are all rows of one list. Only the viewport and about a screen of overscan are in the DOM; everything else is just a number in the offset table.

Split and unified views

Unified view is one column: removed lines then added lines, each with two line-number gutters. Split view is two columns: old on the left, new on the right, with removed and added lines paired on the same row and blank filler cells where one side has more lines. Both come from the same hunk data.

The split view needs one extra step, pairing, which is exactly the run-pairing we already do for word highlights, so we compute it once in the worker and store it with the hunk. On screens narrower than about 900 pixels we default to unified, because two 60-character columns don't fit.

Syntax highlighting in a worker, for visible lines only

Highlighting is the most expensive per-line step, and it has a trap: a line's colours depend on the lines before it. Line 300 might be inside a multi-line comment or a template string that started on line 250, in a gap the client never loaded. Highlighting each hunk on its own, from a blank state, gives wrong colours in exactly those cases.

The fix is to highlight with state, and state needs the earlier lines. For files under a size limit (say 5,000 lines), the worker fetches the old and new versions by blob hash when the file comes into view. Blobs never change, so they cache forever. It tokenizes each version from line 1, carrying the tokenizer's state from line to line, and saves that state at the top of every hunk and gap.

When the reader scrolls to a hunk, the worker highlights it from the saved state and posts back tokens for those lines only. An expanded gap starts from the state saved at its top. For bigger files we start each hunk from a blank state and accept a rare wrong colour, and for files with no grammar or lines over 1,000 characters we show plain text.

The order is always: plain text first, colours a moment later. A diff that appears in 100 ms without colours and gets them 50 ms later feels fast. A diff that waits 400 ms for colours feels slow, even though it's the same work.

Libraries like Shiki (which uses TextMate grammars) or Lezer-based highlighters can run in a worker. The codepen guide covers how to choose an editor and grammar engine; here we only need read-only highlighting.

Check your understanding · Exercise 4

After switching a 40,000-line PR to full virtualization, which new problems must you solve? Select all that apply.

The file tree and per-file viewed state

On a 5-file PR the tree is decoration. On a 600-file PR it's the main way to move around: "show me only the .ts files under billing/", "which files still have unresolved threads?", "what haven't I looked at yet?". It's built entirely from the file list, so it's ready long before most diffs load.

Building and filtering the tree

  • Build a folder tree from the paths in the file list. Compress chains of single-child folders into one node (src/billing/invoices/ instead of three levels), the way most editors do. 3,000 paths build in a few milliseconds.
  • Filter by a text query (fuzzy match on the path), by extension chips (.ts, .css, .md), and by state: unviewed only, with comments, with unresolved threads, owned by me (from the repository's code owners file). Filters hide files in the tree and, optionally, in the diff column too.
  • Badges per file: added and removed counts, a dot for unresolved threads, a count of comments, a renamed or deleted icon. All come from metadata, not from loaded diffs.
  • Jump: clicking a file scrolls the main column to its header and updates the URL hash, even if its diff isn't loaded yet (the placeholder has an estimated height, and loading starts at once).
  • Follow: as the reader scrolls, the tree highlights the file whose header is currently at the top, using an IntersectionObserver on file headers. Don't scroll the tree on every change, only when the active file leaves the tree's visible area.
  • Virtualize the tree too when it has more than a few hundred visible rows. Every row is the same height, so this is the easy fixed-height case.

For accessibility, the tree follows the WAI-ARIA tree view pattern: role="tree", items with role="treeitem", aria-expanded on folders, aria-level, and roving focus with arrow keys (Up and Down move, Right opens a folder, Left closes it or goes to the parent). The aria-patterns lesson covers this pattern in detail. With virtualization, also set aria-setsize and aria-posinset so a screen reader can say "file 12 of 340".

Viewed: per file, per version

Ticking "Viewed" collapses the file and marks it done in the tree. It's how reviewers track progress through a long PR, and Gerrit has had the same idea for years as a "reviewed" checkbox per file. The subtle part is what viewed means when the author pushes again. If I viewed api.ts yesterday and today's commit changed it, my tick is now a lie.

So we never store "viewed: true" per path. We store the blob hash of the file version that was viewed: (user, pr, path) maps to blobSha. The file is shown as viewed only if that hash equals the file's hash at the head of the current comparison.

When a push changes the file, its hash changes, the tick clears by itself, and the header says "Changed since you viewed it". Files the push didn't touch keep their ticks, which is exactly what a reviewer wants after a small fix-up commit. The tick is saved to the server (so it follows you between machines) with an optimistic update: the checkbox flips at once and rolls back with a toast if the save fails.

ts
type ViewedMap = Map<string, string>; // path -> blobSha the user viewed

function isViewed(viewed: ViewedMap, file: { path: string; headBlobSha: string }) {
  return viewed.get(file.path) === file.headBlobSha;
}

function viewedSummary(viewed: ViewedMap, files: { path: string; headBlobSha: string }[]) {
  const done = files.filter((f) => isViewed(viewed, f)).length;
  const changedSinceViewed = files.filter(
    (f) => viewed.has(f.path) && !isViewed(viewed, f),
  ).length;
  return { done, total: files.length, changedSinceViewed };
}
Viewed state keyed by content, so a change to the file clears it.

Check your understanding · Exercise 5

A reviewer marked 40 of 60 files as viewed. The author pushes a commit that changes 3 of those 40 and renames 1 of them without changing its content. What should the reviewer see? Select all that apply.

Inline comments: anchoring to a line that keeps moving

A reviewer writes "this can throw if user is null" on line 42 of auth.ts. The naive store is { path: 'auth.ts', line: 42 }. The next day the author adds an import at the top. Line 42 is now a blank line, and the comment points at nothing, or worse, at an unrelated line that someone then "fixes".

The same thing happens in reverse: the author deletes the function, and the comment hangs next to whatever moved up into its place. A comment's place is only meaningful together with the version of the file it was written on.

Decision What should a comment's anchor store?

Position in the diff

Store the index of the line inside the PR's diff text: "the 17th line after the first hunk header".

This is what GitHub's REST API originally used (position). GitHub now marks it as closing down in favour of line and side.

Pros

  • Easy to compute from the rendered diff.
  • Works for lines on both sides with one number.

Cons

  • Changes meaning whenever the diff changes: a new commit, a different base, or a whitespace toggle.
  • Useless once the PR is compared at a different commit range.

Commit, path, side, and line

Store the commit the comment was written on, the file path, the side (LEFT for base, RIGHT for head), and the line number (or start and end for a range).

To show it on a newer commit, map the line through the diff between the two versions of the file.

Pros

  • Exact and permanent: the original place can always be shown.
  • Mapping is mechanical and testable: every line either has a new number or was changed.

Cons

  • Needs a diff between the comment's commit and the current one, per file, for every push.
  • Can't follow a line whose text changed slightly.

Content: the line's text plus surrounding lines

Store the commented line's text and a few lines of context, and search for them in the new version.

Pros

  • Follows code that moved far, even to another place in the file.
  • No git needed.

Cons

  • Ambiguous for repeated lines like } or return null;.
  • Can attach the comment to a lookalike line that is not the same code, which is worse than saying "outdated".

What we pick: Commit, path, side, and line as the source of truth, which is also what GitHub's REST API uses today (commit_id, path, side, line, plus start_line and start_side for ranges). GitHub's comment objects also keep original_line next to line, the same split between where a comment was written and where it sits now. We also keep a snapshot of the line text and 3 lines of context, but only to show the original code next to an outdated comment, not to guess a new place. A wrong anchor misleads; an honest "outdated" doesn't.

Re-anchoring after a push

When the author pushes, the server takes every open thread on a file that changed and maps its line from the thread's commit to the new head, using the line diff between those two versions of the file. The rule is simple. Lines outside any hunk shift by the total of additions minus deletions in the hunks above them, and context lines inside a hunk also map.

A line that was removed or replaced has no place in the new version: the thread becomes outdated. It stays visible in the conversation, with the old snippet, and can still be replied to and resolved; it just isn't drawn inside the current diff. Files that didn't change keep every anchor as it is.

ts
type Hunk = { oldStart: number; oldLines: number; newStart: number; newLines: number;
  lines: { kind: "context" | "add" | "del" }[] };

function mapLine(oldLine: number, hunks: Hunk[]): number | "outdated" {
  let shift = 0;
  for (const h of hunks) {
    // A hunk with no old lines inserts after oldStart, so it starts at the next line.
    const first = h.oldLines === 0 ? h.oldStart + 1 : h.oldStart;
    if (oldLine < first) break; // before this hunk: earlier shift applies
    if (oldLine < first + h.oldLines) {
      let o = h.oldStart, n = h.newStart;
      for (const l of h.lines) {
        if (l.kind === "context") { if (o === oldLine) return n; o++; n++; }
        else if (l.kind === "del") { if (o === oldLine) return "outdated"; o++; }
        else n++;
      }
    }
    shift += h.newLines - h.oldLines;
  }
  return oldLine + shift;
}
Mapping a line number from an old version to a new one through the hunks between them.

Doing this on the server, once per push, means every client gets the same answer and the old file doesn't have to be downloaded. The client runs the same function for one case: drafts. If you are halfway through a review when a push lands and you choose to refresh, your unsent drafts are re-anchored locally first, so you can see before submitting which of them went outdated.

The lab below compares commit-plus-mapping and text search from the decision with the naive "same line number" anchor, on the same pushes. Watch the line number land on the wrong code after an insert, and text search pick the wrong closing brace after a move. Mapping either finds the line or says outdated, even when a function moved unchanged, which is the safe way to fail.

Comment on
Commit B
Strategy

Comment on line 4 (sum += i.price;) at commit A. After "insert 2 lines at top", commit + mapping puts it on line 6 (sum += i.price;). That's the right place.

Anchoring lab. Comment on line 4 (sum += i.price;) at commit A. After "insert 2 lines at top", commit + mapping puts it on line 6 (sum += i.price;). That's the right place.commit Acommit B1function total(items) {2  let sum = 0;3  for (const i of items) {4    sum += i.price;5  }6  return sum;7}89function tax(sum) {10  return sum * 0.2;11}1import { round } from "m";23function total(items) {4  let sum = 0;5  for (const i of items) {6    sum += i.price;7  }8  return sum;9}1011function tax(sum) {12  return sum * 0.2;13}lands on line 6: correct
Comment on line 4 (sum += i.price;) at commit A. After "insert 2 lines at top", commit + mapping puts it on line 6 (sum += i.price;). That's the right place.

Ranges, threads, and resolving

  • Multi-line comments store startLine and line (the end), both on one side, which keeps mapping simple. GitHub's API is more general: start_side lets a range begin on the old side and end on the new. Click a line number, then Shift and click another, the same gesture GitHub documents. Re-anchoring maps both ends; if any line inside the range was changed, the thread goes outdated, because the reviewer was talking about all of it.
  • File-level comments have no line at all ("this file should be split"); GitHub's API marks them with subject_type: file. In our design they never go outdated.
  • Threads are a root comment plus replies, flat in order. The comments-thread guide covers nesting and pagination; inside a diff, one level is enough, because the thread already has a place: the line.
  • Resolving is a thread flag with who resolved it. Resolved and outdated threads render collapsed to one line, so they don't push the code apart. GitHub documents Alt and click (Option on a Mac) to expand or collapse all outdated or resolved comments at once.

Suggested changes that become commits

A suggestion is a comment whose body contains a fenced block marked suggestion. Its contents replace the commented line or range. The diff viewer renders it as a tiny diff inside the thread (old lines red, suggested lines green) with an "Apply" button for the author. Applying asks the server to create a commit on the PR's branch that replaces exactly those lines in the file at the head commit.

Two guards matter. The suggestion can only be applied while its anchor is still current: if the lines changed since, the button is disabled and the thread says why. And several suggestions can be added to a batch and applied as one commit, as GitHub allows, so accepting 8 typo fixes doesn't create 8 commits and 8 CI runs.

Check your understanding · Exercise 6

A comment sits on new line 42 of auth.ts at commit A. Commit B inserts 5 lines at line 10, and changes the text of the commented line. What does the viewer show at commit B? Select all that apply.

The review: drafts, submit, and a small state machine

A reviewer rarely writes one comment. They read the whole PR, leave 12 notes, revise two of them after seeing a later file, delete one that turned out wrong, and then decide: approve, request changes, or just comment. If every note were published the moment it was typed, the author would get 12 notifications, start replying to a note the reviewer is about to delete, and see half a review with no verdict. So review comments are drafts until the reviewer submits the whole review at once.

Decision Where do draft review comments live until the review is submitted?

Publish each comment immediately

Every comment is a normal, visible comment the moment it's saved.

Pros

  • Simplest model. Good for quick one-off remarks.

Cons

  • One notification per comment.
  • No way to revise before others see it.
  • The verdict arrives separately from its reasons.

Drafts only in the browser

Keep drafts in memory and IndexedDB; send them all on submit.

Pros

  • No server state for half-done reviews.

Cons

  • Drafts don't follow the reviewer to another device or browser.
  • Clearing site data or a crash between saves loses an hour of work.
  • The server can't re-anchor drafts when a push lands.

A pending review on the server

The first draft creates a pending review, visible only to its author.

Each draft is saved to it as it's written (debounced), with a local copy of unsaved text.

Submit turns the pending review and all its drafts into published comments with one verdict, in one request.

Pros

  • Drafts survive reloads, crashes, and device changes.
  • One notification with the verdict and all reasons.
  • The server re-anchors drafts like published comments.

Cons

  • More server states and endpoints.
  • Draft saves are frequent writes, so they must be cheap and debounced.

What we pick: A pending review on the server, which is how GitHub (pending reviews) and Gerrit (draft comments) both work. We keep "Add single comment" as a shortcut that publishes one comment at once, for quick remarks outside a review. Unsaved keystrokes also go to IndexedDB every second or so, so even a draft that never reached the server survives a crash.

State machine for a review. idle goes to drafting on the first draft. drafting goes back to idle when the review is discarded, and to submitting on submit. submitting goes to submitted on a 200 response, or back to drafting on an error with all drafts kept. submitted returns to idle. A note says a staleHead flag is set when a push lands while drafting; submit is still allowed and the dialog warns.idledraftingsubmittingsubmittedfirst draftdiscardsubmiterror: drafts kept200 OKback to idlestaleHead: a push landedsubmit allowed, dialog warns
The failure arrow is the one that matters: an error returns to drafting with every draft intact. staleHead is a flag, not a state, because it doesn't change what the reviewer can do.

The machine has few states, and naming them prevents bugs. idle: no pending review. drafting: a pending review exists with one or more drafts; the top bar shows "Finish your review (3)". submitting: the submit request is in flight; the form is locked so a double click can't send twice.

submitted: success, drafts become published comments, the machine returns to idle. On failure it goes back to drafting with an error, and every draft is still there. A separate flag, staleHead, is set when a push lands during drafting; it doesn't block submission but shows a warning in the submit dialog: "3 new commits since you started. Your approval will apply to commit f00d12, not the latest."

Submitting, and what happens when it fails

Submit is one request: POST /prs/42/reviews/{reviewId}/submit with the verdict (COMMENT, APPROVE, or REQUEST_CHANGES, the same three events GitHub's API uses), a summary body, the commit the reviewer was looking at, and an idempotency key. The commit matters because an approval is an approval of specific code. Many teams turn on a branch rule that dismisses approvals when new commits are pushed; GitHub has exactly such a setting.

The idempotency key matters for the failure path: if the response is lost, the client retries with the same key and the server returns the first result instead of publishing the review twice.

Sequence diagram for submitting a review, with four columns: Reviewer, Review UI, Store, and API. The reviewer clicks Approve. The UI sets the store to submitting and posts the submit request with idempotency key k7. The API replies 200 and the review is published; the UI marks drafts as published and shows Approved. Failure path: the API times out, the UI returns the store to drafting with all drafts kept, retries with the same key k7, and the API returns the first result instead of publishing twice.ReviewerReview UIStoreAPIclick Approvesubmittingsubmit, key k7200 publisheddrafts publishedApprovedon a timeout:timeoutback to draftingretry, same key k7200, first result
Submit is not optimistic: nothing is shown as published until the server says so. The idempotency key turns a scary timeout into a safe retry.

Should submit be optimistic? Saving a draft, ticking viewed, and resolving a thread are all optimistic: the UI changes at once and rolls back on failure, as the optimistic-updates lesson describes, because they are small, private, and easy to undo.

Submitting a review is different. It notifies people, it can unblock a merge, and the author may act on it within seconds. So the dialog shows a spinner for the 200 to 500 ms it takes, and the review appears as published only after the server confirms. Optimism is for actions where being briefly wrong is harmless.

Check your understanding · Exercise 7

A reviewer clicks Approve. The request times out on a flaky train Wi-Fi. What should the client do? Select all that apply.

Real-time updates without moving the page under the reader

Two kinds of news arrive while someone is reviewing. Other people comment, reply, and resolve threads: small events that touch one thread. And the author pushes commits: a big event that can change any file, any line number, and any anchor.

The naive approach treats them the same and applies both the moment they arrive. For comments that's fine. For a push it's a disaster: the file you're reading is replaced, the line you were about to comment on moves 30 rows down, and your half-typed comment now sits next to different code.

Decision How should the open page react to a new push?

Ignore it until reload

Fetch once on load. Show whatever was current then.

Pros

  • Nothing moves, ever.

Cons

  • Reviewers approve code that's no longer the code.
  • They comment on lines that were already fixed.

Apply it at once

On a push event, refetch the file list and every loaded diff, and re-render.

Pros

  • Always current.

Cons

  • Rows jump while reading.
  • Open comment boxes lose their line.
  • A burst of pushes (amend, force-push, fix-up) means several full refetches in a minute.

Banner, then refresh on the reader's terms

On a push event, show a banner: "2 new commits. 5 files changed. Refresh."

Mark files that changed as stale in the tree.

On refresh, refetch only what changed and keep the scroll anchor.

Pros

  • The reader decides when the page changes.
  • The refresh is cheap: only files whose blob hash changed.
  • Comment boxes stay attached until the reader is ready.

Cons

  • The page can be briefly behind, so the submit dialog must warn about it.
  • One more UI state to design.

What we pick: Banner, then refresh. A diff is a document the reader is studying, so the reader controls when it changes, the same way a news feed shows "new posts" instead of shoving them in. Comment events, which only touch one thread and never move code, are applied live.

Sequence diagram for a new push during review, with four columns: Reader, Page, Live channel, and API. The API sends a push event with sequence 1205 to the live channel, which passes it to the page. The page shows a banner to the reader and marks changed files stale, without moving anything. The reader clicks Refresh. The page fetches the new file list with new blob hashes, then requests a batch for only the 5 changed files and gets diffs and new thread anchors. The page re-renders those files and keeps the scroll anchor.ReaderPageLive channelAPIpush, seq 1205push eventbanner, staleclick RefreshGET file listnew blob hashesbatch: 5 filesdiffs, new anchorsanchor kept
Nothing moves until the reader asks. The refresh compares blob hashes, so only the 5 changed files are refetched; the other 995 keep their rows, colours, and viewed ticks.

The refresh in the diagram is smart because blob hashes make change detection exact. The new file list carries each file's head blob hash. Files whose hash didn't change keep their loaded diffs, highlights, and DOM. Only changed files go back to loading.

Threads and drafts get their new anchors (from the server for published threads, from the local mapLine for drafts not yet saved). The scroll anchor is the row at the top of the viewport; if that exact row still exists we keep it in place, otherwise we use the nearest surviving row above it.

The connection and the events

Updates only flow from server to client here; writes go through normal HTTP requests. So Server-Sent Events (SSE) would be enough, and a WebSocket is fine if the app already has one for other features. The websockets lesson covers reconnecting and heartbeats.

What matters more is the event design: small, typed events per PR channel, each with a sequence number, so a client that reconnects can say "I have everything up to 1,204" and get only the gap. With SSE this is built in: the browser sends the last event id it saw in the Last-Event-ID header when it reconnects. If the gap is too old, the server says so and the client refetches the PR's metadata.

ts
type PrEvent = { seq: number } & (
  | { type: "push"; headSha: string; commits: number; changedPaths: string[] }
  | { type: "comment.created"; threadId: string; commentId: string; authorId: string; clientId?: string }
  | { type: "comment.edited"; commentId: string; bodyVersion: number }
  | { type: "thread.resolved"; threadId: string; resolved: boolean; by: string }
  | { type: "review.submitted"; reviewId: string; state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" }
  | { type: "anchors.moved"; headSha: string; threads: { id: string; line: number | null }[] }
);

function onEvent(e: PrEvent) {
  if (e.type === "comment.created" && e.clientId === myClientId) return; // our own echo
  if (e.type === "push") return banner.show(e); // never reflow the page by itself
  store.apply(e); // threads, counts, badges
}
Events on one PR channel. Small and typed, with a sequence number for gap recovery.

Comment events carry ids, not bodies; the client fetches the thread (or uses a small payload if under a few KB). When a new thread appears above the viewport, the virtualizer inserts a row, measures it, and the scroll anchor holds the reader's place. When it lands in a file that isn't loaded, only the tree badge and counts change. The client sent its own comments with a clientId, so their echoes are ignored instead of appearing twice.

Check your understanding · Exercise 8

While a reviewer types a comment on line 88 of cart.ts, the author force-pushes and cart.ts changes. What does a good viewer do? Select all that apply.

Architecture, data model, and API

With the hard parts decided, the architecture mostly writes itself. This chapter looks at it from three sides: the parts and their boundaries, the entities they share, and the API between browser and server. The rule behind every boundary is the same: keep work that scales with the number of lines away from the components and off the main thread, and keep the components dumb about where data came from.

The parts and where the boundaries go

Architecture of the diff viewer. In the browser main thread: File tree, Diff column with the virtualizer, and Review controller in the top row; Diff loader, normalized Store, and API client in the second row. The Diff column tells the loader which files are visible. The loader sends file ids to the Diff worker, which fetches and parses raw batches and posts parsed rows to the Store. The Store feeds rows to the Diff column. The Review controller writes drafts to the Store and submits through the API client, which also applies events to the Store and talks to the API server over HTTPS and SSE. Unsaved text goes from the Store to a draft backup in IndexedDB.Browser main threadFile treefilter, badgesDiff columnvirtualizerReview ctrlstate machineDiff loaderpriority queueStorenormalizedAPI clientHTTP and SSEvisible filesrowsdraftssubmiteventsWeb WorkerDiff workerparse, highlightfile idsparsed rowsDraft backupIndexedDBunsaved textServerAPI serverREST, SSE, gitHTTPS
Everything that grows with the number of lines (parsing, pairing, word diffs, highlighting) sits in the worker. Components only read the store through selectors and send intents; none of them calls the network.
PartResponsibilityWhy it's separate
Page shell and routerReads the URL (PR, commit range, hash), renders the top bar, sidebar, and diff column, and code-splits the heavy pieces.The URL is the source of truth for what's being compared, so it's read in one place.
Diff loaderTurns visibility and the deep link into prioritized batch requests, with cancelling and retries.Loading order is a policy. Keeping it out of components lets it change without touching rendering.
Diff workerParses batches, pairs lines for split view, computes word diffs, and highlights hunks on request.All per-line CPU work lives here, so the main thread stays free for input.
Normalized storeFiles, hunks, threads, comments, drafts, viewed map, and per-file load states, keyed by id, with selectors.Many views read the same data: the tree, the diff, the top bar counts. One copy, many readers.
Diff column (virtualizer)Flattens loaded files into rows, measures heights, keeps the scroll anchor, renders the window.Rendering is the hottest path. It reads the store and owns nothing else.
File treeBuilds the folder tree, filters, badges, follows the scroll.Built from metadata only, so it works before any diff loads.
Review controllerThe review state machine, draft autosave, submit with idempotency key.One owner for the rules about when drafts become public.
Live channelOne SSE or WebSocket connection per PR, sequence numbers, gap recovery, event routing.Transport details stay out of the store and the UI.
Draft backupUnsaved comment text in IndexedDB, restored on load.A safety net that doesn't depend on the network.

Why a normalized store rather than keeping data in each file component? Because the same thread is shown in three places (inline in the diff, as a badge in the tree, and in the review summary) and is changed from three places (your reply, someone else's event, a re-anchor after a push). With one entity per id, every change is one write and every view updates through a selector.

The normalization lesson covers the mechanics. GitHub's write-up makes the same point from the performance side: their faster version replaced array searches with Map lookups keyed by path and line.

Server data (files, diffs, threads) arrives through a cache layer with request deduplication, so the tree and the diff column asking for the same file list make one request. Client-only state lives in small local stores or component state, as the data model table below lists. The view mode and "hide whitespace" are user preferences saved to the account, because a reviewer who likes split view wants it on every PR.

Data flow from server to screen and back. On the server: the git repository produces a git diff into a diff cache keyed by blob pair, which serves hunks to the API. The API sends a compressed batch to the worker in the browser, which posts parsed rows to the store, which gives visible rows to the diff rows on screen. Writes go the other way: a draft or viewed tick goes from the diff rows to the store, which saves it to the API. The API sends push and comment events through the live channel, which applies them to the store. A note says each file is diffed once and shared by all reviewers.Servergit repobase, head blobsDiff cachekey: blob pairAPIREST and SSEgit diffhunksBrowserDiff rowsvirtualizedStorenormalizedWorkerparse, highlightLive channelseq numbersdiffed once,shared by allbatchrowsvisibledraft, ticksavepush and comment eventsapply
Reads are cached at the most expensive step: one git diff per file, shared by every reviewer. Writes are optimistic in the store first, then saved, and come back to other tabs as events.

The data flow diagram follows one read and one write. Reads: the browser asks the API for the file list and diff batches, and the API serves file diffs from the cache keyed by blob pair. Batches go to the worker, then into the store, and the virtualizer renders what's visible.

Writes: a draft or a viewed tick goes to the store first (optimistic), then to the API; the API emits an event on the PR's channel, which other open tabs apply. Pushes go the other way: the git server notifies the API, which re-anchors threads and emits one push event.

Check your understanding · Exercise 10

Which responsibilities belong in the diff worker rather than the main thread? Select all that apply.

Data model: entities, ids, and where each one lives

The data model has two halves that change at very different speeds. The code half (commits, files, hunks, lines) is immutable once computed: a diff between two blob hashes never changes, so it can be cached forever. The conversation half (threads, comments, reviews, drafts, viewed ticks) changes all the time, from several people. Keeping the halves apart is what lets us cache the expensive part hard and update the cheap part live.

Data model in two halves. The code half is immutable and cached forever: a Pr has many FileEntry records with blob hashes, each has many Hunks with old and new starts, each has many Lines with kind and line numbers. The conversation half is live and shared: a Viewed map from path to blob hash, Threads that point to a file through an anchor of commit, path, side, and line, each with many Comments, and a Review that holds draft comments. A note says a thread's anchor never changes, while its current position is a line or outdated.Code: immutable, cachedPrbase, headFileEntryblob shasHunkstarts, countsLinekind, numbers1:n1:n1:nConversation: live, sharedViewedpath: blobShaThreadanchor, lineCommentdraft or notReviewdrafts, verdictanchor: commit, path, side, line1:n1:nanchor never changes; current is a line or outdated
Keeping the halves apart lets the heavy half be cached by blob hashes with no invalidation, while the light half is updated by events, writes, and re-anchoring. Arrows marked 1:n read one to many.
ts
type Pr = { id: string; number: number; baseSha: string; headSha: string; commitShas: string[] };
type Range = { fromSha: string; toSha: string }; // what is compared; lives in the URL

type FileEntry = {
  id: string; // sha256(path), same as the deep-link hash
  path: string; previousPath?: string;
  status: "added" | "modified" | "deleted" | "renamed" | "binary";
  oldBlobSha: string | null; newBlobSha: string | null;
  additions: number; deletions: number;
  flags: { generated: boolean; tooLarge: boolean };
  load: "listed" | "loading" | "ready" | "failed" | "collapsed" | "tooLarge" | "stale";
};
type Hunk = { id: string; fileId: string; oldStart: number; oldLines: number;
  newStart: number; newLines: number; lineIds: string[] };
type Line = { id: string; kind: "context" | "add" | "del"; oldNo: number | null;
  newNo: number | null; text: string; pairId?: string };

type Anchor = { commitSha: string; path: string; side: "LEFT" | "RIGHT";
  startLine?: number; line: number } | { commitSha: string; path: string; file: true };
type Thread = { id: string; anchor: Anchor; current: { line: number } | "outdated";
  resolved: boolean; commentIds: string[]; snippet: string[] };
type Comment = { id: string; threadId: string; authorId: string; body: string;
  createdAt: string; reviewId?: string; state: "draft" | "published" };
type Review = { id: string; state: "PENDING" | "COMMENTED" | "APPROVED" | "CHANGES_REQUESTED";
  commitSha: string; draftIds: string[] };
Client entities, normalized by id. Diffs are immutable; threads and reviews are live.
DataLives inLifetime and invalidation
Commit range, file hash, lineURLShareable. Changes on navigation.
File listServer cache, keyed by rangeRefetched on refresh after a push.
Hunks and linesNormalized store, keyed by the two blob hashesImmutable. Evicted for files far from the viewport on huge PRs.
Highlight tokensWorker cache, keyed by blob hash and hunkImmutable. Dropped under memory pressure and recomputed.
Full file text, for highlightingWorker cache, keyed by blob hashImmutable. Only for files under the size limit; dropped first under memory pressure.
Threads and commentsNormalized storeLive: events, re-anchoring, own writes.
Pending review and draftsServer, mirrored in the storeUntil submitted or discarded.
Unsaved draft textIndexedDBUntil saved to the server.
Viewed mapServer, mirrored in the storePer user and PR, keyed by blob hash.
Focused line, selection, open boxesComponent or local UI stateThis tab only.

Two details pay off later. The file's id is the SHA-256 of its path, the same value as the deep-link hash, so a link resolves with one lookup. And a thread keeps both its permanent anchor, which never changes after creation, and its current position, which is recomputed on every push and can be outdated. Rendering reads only the current position, and the outdated list in the review summary reads the anchor and snippet.

Memory needs a rule too. A loaded line costs its text plus a small object, roughly 150 to 250 bytes in JavaScript. 100,000 lines is about 20 MB: fine. Highlight tokens can be 5 to 10 times that, so the worker keeps tokens only for hunks near the viewport and drops the rest under pressure, since they can be recomputed.

Check your understanding · Exercise 11

Why key diff data by the pair of blob hashes instead of by PR and path? Select all that apply.

Interface: the API and the main component

The API follows the loading plan: cheap metadata first, heavy diffs in batches, small writes for the conversation, and one event channel. Plain REST works well here, because the heavy responses are immutable and cache well over HTTP. GraphQL would also work for the conversation half, but the diff batches gain nothing from it.

EndpointPurposeCaching
GET /prs/42/files?range=a1b2..f00d&cursor=File list with metadata, 1,000 per pageShort private cache, refetched after a push
POST /diffs/batchHunks for up to 20 files or 2,000 lines, by blob pairsImmutable per blob pair, so the server caches forever
GET /blobs/{sha}/lines?from=&to=Context lines for expanding a gapCache-Control: immutable, since a blob never changes
GET /prs/42/threads?range=Threads with current positions for the rangeNo cache; kept fresh by events
POST /prs/42/reviews and PUT .../drafts/{id}Create the pending review and save draftsNot cached
POST /prs/42/reviews/{id}/submitPublish with a verdict, idempotentNot cached
PUT /prs/42/viewed/{fileId}Save the viewed blob hash for a fileNot cached
GET /prs/42/events?after=1204SSE stream of PR events from a sequence numberStreaming

The batch endpoint is a POST only because the list of blob pairs is too long for a URL. Responses still carry a strong ETag, and the client keeps them in memory by blob pair. A GET per blob pair would let a CDN cache them too; with HTTP/2 or HTTP/3, 20 small parallel requests cost little. Either is defensible: say which you pick and why.

json
{
  "files": [{
    "fileId": "9f2c4e...",
    "oldBlobSha": "3a1f09", "newBlobSha": "c77e21",
    "hunks": [{
      "oldStart": 120, "oldLines": 7, "newStart": 124, "newLines": 9,
      "lines": [
        [" ", 120, 124, "function charge(o) {"],
        [" ", 121, 125, "  const cfg = load();"],
        [" ", 122, 126, "  log(\"charge\");"],
        ["-", 123, null, "  const timeout = 30;"],
        ["+", null, 127, "  const timeout = 60;"],
        ["+", null, 128, "  const retries = 3;"],
        ["+", null, 129, "  let tries = 0;"],
        [" ", 124, 130, "  return send(o, {"],
        [" ", 125, 131, "    timeout,"],
        [" ", 126, 132, "  });"]
      ]
    }],
    "truncated": false
  }],
  "errors": [{ "fileId": "77ab01...", "code": "DIFF_TIMEOUT", "retryable": true }]
}
One file in a diff batch response. Lines are compact arrays to keep the JSON small.

Errors are per file, not per request. One file whose diff timed out on the server shouldn't fail its 19 neighbours; it goes to failed with a retry button, and retryable: false errors (like a file over the size limit) go straight to tooLarge. Writes return the full saved entity, so the optimistic copy is replaced by the real one, including server-assigned ids and the current anchor.

ts
// PUT /prs/42/reviews/rev_9/drafts/tmp_51
type DraftRequest = {
  body: string;
  anchor: { commitSha: string; path: string; side: "LEFT" | "RIGHT"; startLine?: number; line: number };
  clientId: string; // for echo suppression on the event channel
};
type DraftResponse = { id: string; threadId: string; current: { line: number } | "outdated"; savedAt: string };
Creating a draft: the anchor names the commit the reviewer was looking at.

For the main component, the props mirror the data model, and events go up instead of fetching down: the file component never calls the API itself. That keeps it reusable for the commit page, the compare page, and a single-file blame view.

ts
type DiffFileProps = {
  fileId: string;              // reads file, hunks, threads from the store
  view: "split" | "unified";
  highlight: boolean;
  onExpandGap(hunkId: string, direction: "up" | "down" | "all"): void;
  onStartComment(anchor: { side: "LEFT" | "RIGHT"; startLine?: number; line: number }): void;
  onToggleViewed(fileId: string): void;
  onLoad(fileId: string): void; // for collapsed or too-large files
};
The file diff component: data in, intents out.

Check your understanding · Exercise 12

A diff batch of 20 files comes back with 19 diffs and one DIFF_TIMEOUT error. What is the right client behaviour? Select all that apply.

Performance budgets, accessibility, and safety

A budget is a number you check on every release, not a hope. For a diff viewer, the budgets must be set per PR size, because a page that's fast on the median and broken on the tail is what we're trying to avoid. GitHub's team reports doing exactly this: INP tracked per interaction and segmented by diff size, with memory tagging. The runtime-performance and code-splitting lessons cover the general techniques.

MetricMedian PR (5 files)Huge PR (1,000 files, 100,000 lines)
File tree visibleunder 800 msunder 1.2 s
First diff visible, plain textunder 1.0 sunder 1.5 s
Highlighting on visible linesunder 1.2 sunder 2.0 s
INP (click, key, type), p75under 100 msunder 200 ms
DOM nodesunder 10,000under 30,000 (virtualized)
JS heapunder 100 MBunder 500 MB
JavaScript for first renderunder 200 KB compressedsame: the worker and highlighter grammars load on demand
Loading timeline for a 1,000-file PR, from 0 to 2.5 seconds. The page shell loads from 0 to 0.5 seconds. The file list loads from 0.1 to 0.7 seconds in parallel. The tree is visible by 0.9 seconds. The first diff batch loads from 0.7 to 1.2 seconds, and plain diff text is on screen by 1.4 seconds, inside the 1.5 second budget. Highlighting finishes by 1.8 seconds. Idle loading of later batches runs from 1.8 seconds on.shellfile listtree1st batchdiff textcoloursidle loads0 s0.5 s1 s1.5 s2 s2.5 s1.5 s budget
The file list needs no diff work, so it runs alongside the shell. Plain text beats the 1.5 s budget, colours follow, and everything after the first screen waits for idle time.

The timeline shows how the pieces line up on the huge PR. The page shell and the file list come first and in parallel, because the list needs no diff work. The linked or first batch follows, and plain text paints before colours.

Grammars are code-split per language, so a PR with only Go and YAML never downloads the Rust grammar. Everything after the first screen happens in idle time and is cancelled if the reader jumps elsewhere.

Accessibility: a diff without colour

  • Not colour only. Every added or removed line has a + or - marker in its own cell, and the line-number cells say which side they belong to. In Windows high contrast mode (forced-colors: active) background colours vanish, so the markers and a border on changed lines must carry the meaning.
  • Announced in words. Screen readers handle ins and del elements inconsistently, so we don't rely on them. Each changed line has visually hidden text: "Added line 125" or "Removed line 121". Word-level highlights add "changed: 30 to 60" as hidden text for paired lines, because a darker background is invisible to a screen reader.
  • Table semantics. Each file body is a table with a caption naming the file and counts, so screen reader table navigation (next row, column header) works. With virtualization, rows carry aria-rowindex and the table carries aria-rowcount, so "row 88 of 1,204" is announced.
  • Headings per file, so a screen reader's heading list becomes a list of changed files. Each hunk header is announced in words, such as "Old lines 120 to 126, new lines 124 to 132", not as @@ -120,7 +124,9 @@.
  • Keyboard commenting. Enter on a focused line opens the comment box, focus moves into it, Escape closes it and returns focus to the line. Submitting announces "Comment saved as draft" through a polite live region.
  • Calm live updates. New comments from others are announced once per batch ("2 new comments in cart.ts"), not per event, and never steal focus. Jumps from j and k scroll instantly when prefers-reduced-motion is set.

Safety

Everything in a diff is untrusted: file contents, file names, commit messages, and comments are written by whoever opened the PR, possibly an outsider on a public repository. Code lines are rendered as text nodes and highlight spans built from tokens, never via innerHTML on raw content. Comment Markdown is rendered on the server with a strict allow-list sanitizer and a Content Security Policy as a second wall. Applying a suggestion is a write to the branch, so the server checks that the person applying it has push rights, whatever the UI showed.

Check your understanding · Exercise 13

Which make added and removed lines understandable without seeing colour? Select all that apply.

Running it in the interview

This question is big, and the trap is spending 20 minutes on the file tree. Interviewers ask it to see whether you notice the two hard parts on your own: size and anchoring. Get to both early, then let the follow-ups decide where to go deeper.

MinutesWhat to doWhat to draw
0 to 5Scope: PR sizes, how long reviews live, server has git, desktop first. State the numbers out loud.Nothing yet, or a list of requirements.
5 to 10Name the hard parts: unbounded size, variable row heights, code moving under comments, shared long-lived reviews.The wireframe: top bar, tree, file sections, an inline thread.
10 to 20Diffs from the server per file, file list first, batches by scroll, collapse rules, virtualization with a scroll anchor, highlighting in a worker.The architecture: loader, worker, store, virtualizer.
20 to 30Anchoring: commit, path, side, line; re-anchoring on push; outdated threads. Then the review state machine.The mapping of one line through a hunk, and the state machine.
30 to 40Real-time: banner for pushes, live comments, scroll anchor. Data model and API shapes.A sequence diagram for a push or for submit.
40 to 45Budgets, accessibility, keyboard, and what you'd measure.A short list.

Say the trade-offs as trade-offs. "I'm choosing a banner over auto-refresh, which means the page can be a bit behind, so the submit dialog warns about it." "Virtualization breaks find-in-page, so I'd keep normal rendering below 10,000 lines and add our own search above it." A design with its costs named sounds senior; a design with no costs sounds untested.

Follow-ups to expect

Think first

How would you build "show changes since my last review"?

Think first

The author moves a commented function from line 40 to line 400 without changing it. Can the comment follow?

Think first

What changes on a phone?

Check your understanding · Exercise 14

Which statements would strengthen an interview answer to this question? Select all that apply.

Summary

  • Diffs are computed on the server with git and cached by the pair of blob hashes. The browser only diffs words inside a changed line, and re-anchors drafts through hunks the server sends.
  • Load the file list first, then diffs in batches driven by the deep link, the viewport, and idle time. Collapse large, generated, deleted, and already viewed files by default.
  • Make one diff line cheap (few components, no per-line handlers or state, map lookups), then virtualize only when the PR is big enough to need it, keeping a scroll anchor for rows that change height.
  • Highlight in a worker, with tokenizer state carried across lines, only for hunks near the viewport. Plain text first, colours a moment later.
  • Anchor comments to commit, path, side, and line. Map them forward on every push; if the line changed, the thread is outdated, not guessed into a new place.
  • Reviews are server-side pending drafts submitted at once with a verdict. Submitting is pessimistic and idempotent; small private actions are optimistic.
  • Pushes show a banner and refresh only files whose blob hash changed. Comments from others apply live. Viewed ticks are keyed by blob hash so they clear when a file changes.
  • Keyboard paths, words instead of colour, table semantics with row counts, and deep links built from a hash of the path make the viewer usable by everyone.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next