← All posts

Highlighting a 5,000-line diff without freezing the tab

How Rezee's diff viewer syntax-highlights large diffs without a frozen frame - Shiki tokenization is synchronous CPU, so the trick isn't going off-thread, it's chopping one 305ms block into 125 sub-5ms bursts, measured.

Aug 7, 2026 · 7 min read · Kash Gohil

Rezee is one workspace for the whole product development lifecycle - code, planning, CI/CD, chat, and docs. Reviewing a pull request means reading diffs, and a diff without syntax highlighting is a wall of monochrome. We highlight with Shiki - the same tokenizer VS Code uses, so the colors match the editor - and highlighting a big diff has a trap that a naive implementation walks straight into: it freezes the tab. This post is how Rezee's diff viewer (apps/pochita/src/components/app/diff.tsx) avoids that, and the measurement that shows the fix isn't what you'd first guess.

Why does highlighting freeze the tab?

Because Shiki tokenization is synchronous CPU work on the main thread, and it's easy to be fooled into thinking it isn't. Our tokenize function is async:

export async function tokenizeLines(lines: string[], path: string) {
	const hl = await getHighlighter();          // the ONLY await
	return hl.codeToTokens(lines.join("\n"), { lang, theme }).tokens;
}

That async fools you into feeling safe, but the only thing it awaits is the one-time highlighter load. codeToTokens itself is a synchronous, regex-driven tokenizer that runs to completion on the main thread and blocks everything - layout, input, scrolling - while it does. Highlight a whole large diff in one call and you get one long frozen frame. We measured how long (scripts/bench/shiki-diff), tokenizing synthetic TypeScript diffs of growing size in one shot:

A 5,000-line diff is 305ms of solid main-thread block. At 60fps, a frame is 16.7ms, so 305ms is roughly 18 dropped frames - a fifth of a second where the tab is dead: no scroll, no click, no cursor. That's the naive version, and it's the version you write first, because await tokenizeLines(...) looks asynchronous.

The fix isn't a Web Worker

The instinct is "move it off the main thread with a Web Worker." That would work, but it's a lot of machinery - serializing tokens across the worker boundary, shipping grammars to the worker - and it's not what the diff viewer does. The actual fix is simpler and rides a fact about diffs: a diff is already chopped into hunks. So tokenize one hunk at a time, and let each hunk's result land in React state on its own:

useEffect(() => {
	for (let hi = 0; hi < file.hunks.length; hi++) {
		const lines = file.hunks[hi].lines.map((l) => l.content);
		const hunkIndex = hi;
		tokenizeLines(lines, file.path).then((tokens) => {
			setHunkTokens((prev) => ({ ...prev, [hunkIndex]: tokens }));
		});
	}
}, [file.path, file.hunks, file.binary]);

Each tokenizeLines(...).then(...) is a separate task. The synchronous tokenization still happens - it's the same total CPU - but now it happens in many small bursts, one per hunk, with the event loop free to breathe between them. The diff renders as raw text immediately (the hunkTokens state starts empty and each hunk falls back to plain text), then upgrades hunk by hunk as tokens arrive. You see the diff instantly and watch it colorize, instead of staring at a frozen tab and then getting everything at once.

The measured difference

Same total work, completely different feel - and the number that captures "feel" is the longest single main-thread block, because that's the one that drops frames:

Longest main-thread block per diff (ms) — lower is smoother Longest main-thread block per diff (ms) — lower is smoother per-hunk (worst burst)monolithic (one call) 0100200300400 71.44.264.63.3305.84.1 200 lines1000 lines5000 lines longest block ms

At 5,000 lines, the monolithic approach is one 305ms block. The per-hunk approach does essentially the same total tokenization (~302ms of CPU - chunking doesn't make the work smaller) but its worst single block is 4.1ms, because each hunk is ~40 lines. That's a 74x reduction in the longest block, and 4.1ms is comfortably under the 16.7ms frame budget - so the browser paints a frame, tokenizes a hunk, paints a frame, tokenizes the next, and never misses one. The total time is unchanged; the distribution of that time is the entire win. One 305ms freeze becomes 125 bursts you can't perceive individually.

This is the honest framing the numbers force: per-hunk tokenization is not faster. It moves exactly the same CPU cost from one janky lump into a smooth stream. For a UI, when the work happens matters as much as how much of it there is.

The one-time cost you pay once

There's a second cost the singleton handles. Creating the Shiki highlighter loads ~20 language grammars, and we measured that at ~27ms. If every file re-created a highlighter, a 30-file PR would pay that 20+ times. So the highlighter is a lazy module-level singleton:

let highlighterPromise: ReturnType<typeof createHighlighter> | null = null;
function getHighlighter() {
	if (!highlighterPromise) {
		highlighterPromise = createHighlighter({ themes: [...], langs: [...] });
	}
	return highlighterPromise;
}

It's a promise singleton, not an instance singleton, and that detail matters: if two hunks call getHighlighter() before the grammars finish loading, they both await the same in-flight promise rather than kicking off two loads. The first tokenize of the session pays ~27ms once; every tokenize after - across every hunk of every file of every diff - reuses the loaded grammars. Load once, tokenize forever.

What's still on the table?

The honest limits, because 4ms bursts aren't free of tradeoffs:

  • A pathological single hunk still blocks. Our win assumes hunks are small (~40 lines). A diff with one 5,000-line hunk - a generated file, a giant JSON - collapses back to one long block, because we chunk by hunk, not by a fixed line budget. The fix would be sub-chunking huge hunks; we haven't needed it, because reviewers collapse generated files anyway.
  • Total CPU is unchanged, so a giant diff is still a lot of work. Per-hunk keeps each frame smooth, but a 20,000-line diff is still ~1.2s of total tokenization spread over the scroll. A Web Worker would move that off the main thread entirely; we judged the responsiveness win from chunking sufficient without the worker's complexity, and the measurement is why we can say that with a number instead of a shrug.
  • Tokens are re-computed on every mount, not cached. Navigate away and back and the hunks re-tokenize. A token cache keyed on content hash would help; the singleton grammar load is the expensive part and that's already cached, so we left it.

FAQ

Why not just use a Web Worker and be done with it?

Because the measured responsiveness win from per-hunk chunking - worst block 4.1ms, every frame under budget - is already the outcome a worker would buy, without the worker's costs: serializing themed tokens across the boundary, loading grammars in the worker, and the added complexity of an async message protocol where a .then() already exists. A worker becomes worth it when a single unit of work exceeds the frame budget and can't be chunked smaller; hunks chunk naturally, so it doesn't here. We'd reach for a worker for the pathological-single-hunk case above, not for diffs in general.

Does rendering as plain text first cause a flash?

It causes a progressive colorization, which reads as "fast" rather than "flashing" - the text is correct and readable from the first frame, and color arrives over the next few. The alternative, waiting for all tokens before showing anything, is the frozen-tab version. Showing correct-but-unstyled content immediately and enhancing it is the same principle as progressive image loading: the useful thing first, the pretty version as it's ready.

Why tokenize each hunk's lines joined, then split, instead of line by line?

Because syntax highlighting needs cross-line context - a template literal or block comment that spans lines tokenizes correctly only if the tokenizer sees them together. So each hunk is joined, tokenized as a unit (preserving that context within the hunk), and split back into per-line token arrays for rendering. The hunk is the natural context boundary: lines within it are related, and the hunk header already marks a discontinuity in the file.