Highlighting: make `--fast` the default with warm workers
What do you want to change?
Warm the highlight worker during bootstrap and route highlighting through it wherever the worker is supported, with the existing inline path kept as the fallback for Windows compiled binaries, custom themes, and scope overrides. Keep accepting --fast so existing invocations and configs do not error, but make it a no-op, and drop the flag in a later release.
Why?
On a small diff, the default highlight path blocks keyboard input for roughly a quarter second after the review is already on screen. The plain frame appears, then nothing responds until color lands.
MeasurementsMeasured on hunk diff HEAD~1 in this repo (5 files, 12 changed lines, Linux, 0.21.1 binary and source both), key sent at first paint, watch = false:
| Config | Key answered | Main-thread stall after first paint | Colors and word-diff emphasis |
|---|---|---|---|
| default | +52ms, then blocked | 190ms | +280ms |
--fast |
+54ms | 41ms | +90ms |
A CPU profile of the default run's post-paint window shows ~230ms inside renderDiffWithHighlighter:
- ~150ms is one-time Oniguruma regex compilation for the TypeScript grammar (
OnigScanner/CompiledRule), paid on first use of each language - the rest is tokenizing whole files, because
sourceBackedHighlight.tsgrafts full old/new source onto partial diffs so grammar state is right (#669). A 2-line change in a 417-line file tokenizes 800+ lines. - ~80ms of highlighter and WASM init precedes that
queueHighlightedWork yields between files, but each file's render is one uninterruptible call, and the first file of each language carries the grammar compile. The WASM engine is the right choice: the JS regex engine took 1.3s to compile and tokenize the same TypeScript file.
Why this was not caught
The worker shipped behind --fast in #759 and its threshold dropped to 40 lines in #810. #810 was explicit about keeping the tradeoff opt-in: "the plain review frame appears sooner while syntax color finishes roughly 45-50ms later as the worker starts," plus scroll p95 +4.75ms and RSS +20MB on its fixture. Those numbers are real, but every measurement behind that decision targeted large files (#754 measured 1,000+ line files, #755 measured 2,000+), and the release benchmark's first_frame_ms starts its timer after the highlighter is already imported. The cost that dominates small diffs, init plus grammar compile plus the full-source graft, was never benchmarked on the small file path. The one highlighter warm-up attempt (2b73efd9, reverted in 1c45daa2) predates both source-backed highlighting and the worker.
The #810 tradeoffs, revisited
- Color lands 45-50ms later on a cold diff. That is worker startup cost paid after first paint. Creating the worker and preloading the diff's grammars during bootstrap, before OpenTUI loads (see #1062), moves that work to a window where the main thread is idle waiting on git. Expected: color arrives no later than today, likely earlier, because the grammar compile no longer follows the first paint.
- Scroll p95 and RSS. Real, and worth re-measuring with a warm worker. Against it: the default path today costs ~250ms of blocked input on every launch that touches a new language.
- Windows compiled binaries cannot resolve the embedded worker entrypoint (Bun 1.3).
supportsHighlightWorkerOffloadalready gates this; those runs keep the inline path. - Custom themes and
syntaxScopeOverridesare not supported in the worker.shouldOffloadHighlightalready falls back to inline for them. - Worker failure recovery. #785 bounds retries so a repeating failure does not re-run on every scroll. It should land first or alongside.
Note that the 40-line minimum is not what gates small diffs today. It is checked against the highlight metadata's line count, which for a source-backed file is the whole file, so nearly every real file already qualifies. The gate is the --fast flag.
I built the warm-up as a prototype: every file routed to the worker, and a preload message sent before the OpenTUI import that loads each grammar in the changeset and tokenizes a sample so Oniguruma compiles the common rules. A second variant prefetches real highlight results for the first 20 files instead. PTY runs on AC power, source mode, key sent at first paint, watch = false, two runs per variant on the small diff and three on the large one.
5-file diff (12 changed lines, markdown + typescript):
| Variant | First frame | Visible colors after frame | Word-diff emphasis after frame | All files colored | Main-thread stalls after frame |
|---|---|---|---|---|---|
| inline (today) | 449 / 420ms | +111 / +99ms | +124 / +127ms | +316 / +301ms | ~220-290ms |
| worker, cold | 425 / 436ms | +129 / +118ms | +146 / +134ms | n/a | ~33ms |
| worker, warmed | 491 / 466ms | +57 / +41ms | +57 / +70ms | same step | ~35ms |
| worker, prefetched | 467 / 464ms | in first frame | +18 / +18ms | n/a | ~42ms |
Worker preload took 74-85ms of worker time for markdown + typescript and finished at 132-151ms, about 320ms before the first frame.
24-file diff (1,226 insertions, markdown + typescript + tsx), three paired runs:
| Variant | First frame | Visible colors after frame | Word-diff emphasis after frame | All files colored | Main-thread stalls after frame |
|---|---|---|---|---|---|
| inline (today) | 508 / 499 / 525ms | +122 / +106 / +110ms | +300 / +265 / +264ms | +300 / +265 / +280ms | 140-160ms stall, ~190-230ms total |
| worker, warmed | 522 / 542 / 541ms | +72 / +78 / +73ms | +89 / +83 / +84ms | +106 / +113 / +107ms | ~35-41ms |
| worker, prefetched | 586 / 580 / 591ms | in first frame | +19 / +26 / +19ms | +179 / +160 / +161ms | ~45-60ms |
Worker preload took 322-353ms of worker time for three grammars and finished at 380-413ms, still before the first frame.
Takeaways:
- Post-paint main-thread blocking drops from ~220-290ms (5 files) and ~190-230ms (24 files) to ~35-40ms. What remains is applying compact results plus the re-render for the key press itself.
- Visible color arrives 50-60ms earlier than today with a warm worker on the small diff, and 30-50ms earlier on the large one; against a cold worker the gain is 70-80ms. The #810 tradeoff ("color finishes 45-50ms later as the worker starts") inverts once the worker is warm before first paint. Word-diff emphasis, which today waits for the whole file to tokenize, arrives 60-70ms earlier on 5 files and ~180ms earlier on 24 files.
- The whole diff finishes coloring ~2-3x sooner (+107ms vs +265-300ms on 24 files), because grammar compile happens in the worker before paint instead of serially on the main thread after it.
- First-frame cost of warming in this placement: about +40ms on 5 files and +24ms on average on 24 files. That is worker construction (
new Worker) plus a slightly longer first-render stall while the worker thread compiles. The prototype does this just before the OpenTUI import, serialized on the critical path; placing it inside git bootstrap (#1062) would overlap it with idle time. Prefetching real results costs more (+20-75ms) because plan building and source reads run on the main thread ahead of the import, so grammar warm-up is the better default and prefetch is an optional follow-up. - Key latency at first paint improved but is noisy in every variant (6-88ms) because the sidebar toggle used as the probe needs its own ~40-50ms relayout; the stall totals are the better signal.
How? (optional)
- Warm during bootstrap: once
loadAppBootstraphas the file list, construct the worker and send it a preload message naming the theme and the distinct languages in the changeset. Do this before the OpenTUI import so it overlaps git I/O and the dlopen. - Flip the default in
App.tsx:offloadLargeDiffis true unlesssupportsHighlightWorkerOffload()is false or the theme is custom or has scope overrides. Keep accepting--fastas a no-op so existing invocations and configs do not error, note the deprecation in the changeset, and remove the flag in a later release. - Drop
HIGHLIGHT_WORKER_MIN_LINESor measure where postMessage overhead exceeds inline cost; #810's sweep bottomed out at 40 and did not test below it. - Land #785 first.
- Add a PTY regression test that sends a key at first paint on a small multi-language diff and asserts it is answered within ~100ms, so the default path is covered the way #899 covers the worker path. Add a startup-inclusive benchmark that starts its timer at process spawn so
first_frame_msand time-to-input reflect what users see.
Source: modem-dev/hunk