In two days, typing one character in a 30 KB document dropped from about 286 ms to about 1.5 ms. Almost all of that drop came from changing data structures, not from a faster machine. This page dissects each change: the structure before and after, the benchmark numbers, and small simulations you can run yourself.
All the numbers below are medians from npm run bench on the same machine (GJS 1.80.2, GTK 3.24.41, X11/Xvfb, host c640). The vertical axis uses a logarithmic scale: each guide line means 10× faster. Hover over a bar for details.
541a967, 50dd86b) used only 3 repetitions and recorded the total of 20 keystrokes, so the per-character numbers here are the total ÷ 20. Starting with a7e4444 the bench records a sample per keystroke, 10 repetitions after a warm-up, plus p95/maximum. The jump from 286 → 22 ms is also partly because the bench stopped spinning ctx.iteration() from JS (see how we measure).bench:save and bench:compare are born.LineTagger, setTagRanges).MarkerConcealer, a single-copy splice, auto save on a worker thread.Optimization without stable measurement is just guessing. The bench in tests/bench.ts grew along with the code:
awaits the main loop and does not spin ctx.iteration() from JS. Before 50dd86b, that approach made callbacks get blocked by GC and the 50/100-block results were invalid.type via view uses the TextView keybinding signal, the same path as a real key press. open: longest pause measures the longest main loop pause, which is the feeling of "freezing", not the total time.mixed (code, tables, lists), book (long paragraphs, 100,000 words), long (20,000 lines).--timeout makes hangs detectable.PERFORMANCE.md.npm run bench:compare # mixed 25/50/100 blocks vs bench/baseline.json
gjs -m dist/bench.js --fixture=book --size=400 --sizes=50,200,400 \
--timeout=400 --compare=bench/baseline-book.json
markdownToHtml once "rose 37%" even though src/markdown/ had not changed. Run again before believing it, and compare before/after back to back in the same session.src/editor/tagsync.tsThe symptom. In a 30 KB document each keystroke took ±320 ms and moving the cursor ±130 ms.
The cause. The highlighter removed the tags across the whole buffer, and then applied them again. The code was simple, but tags that affect size (the heading font, line spacing) make GTK think all the lines changed and lay out the entire document again. The cost of each keystroke became proportional to the length of the document, even though only one line changed.
for (const tag of allTags)
buffer.remove_tag(tag, start, end);
for (const [tag, a, b] of allSpans)
buffer.apply_tag(tag, a, b);
// no structure is remembered
class LineTagger {
// applied[i] = the tags applied on line i
// null = not yet known → apply again
applied: (LineSpan[] | null)[]
}
type LineSpan = [tag, startRel, endRel]
edited(first, last, count) when a line is insertedThe user presses Enter on line 2. Lines 0–1 do not change. Lines 2–3 are now unknown. Old line 3 and onwards only shift by one index. The GTK tags shift along with the text, so their contents stay valid without being touched.
Gray = copied from the old index, blue = shifted (shift +1), dashed = null and will be recomputed. Only the dashed cells touch GTK.
For tags whose ranges span lines (dim, table, image, code color), setTagRanges() compares the existing ranges with the wanted ones using two set operations on sorted ranges: subtract(have, want) is removed, subtract(want, have) is applied. Both are O(n + m) with two pointers.
Bar width is relative to the largest value in its row. The median of 3 repetitions; the bench also changed in this commit (see the note on method).
Click any line to "type" there. The left uses the old approach, the right uses LineTagger. Orange/blue lines are lines whose tags were removed or applied, so GTK has to lay them out again.
The model: estimated time = lines touched × cost per line. The cost figure is only an assumption, not a GTK measurement. The message to take away is the shape of the curve. The old approach grows linearly with the document length, the new one stays flat.
src/editor/highlighter.tsThe symptom. After Chapter 1, touching GTK was cheap, but each keystroke still took ±20 ms. The cause: every keystroke read the whole buffer text, split it into lines, and then re-parsed all the lines.
The idea. Markdown is almost always local. A keystroke in a paragraph does not change the meaning of other lines, except inside code blocks or tables. So keep safe points (checkpoints): lines outside code/tables that do not contain |. When line k is edited, re-parse only from the checkpoint before k to the checkpoint after it.
text = buffer.get_text(start, end) // the whole doc
lines = text.split('\n')
result = parseLines(lines) // all lines
// starts, markers, headings: rebuilt
interface Parsed {
lines: string[]; starts: number[];
checkpoints: number[]; // "neutral" lines, sorted
lineWords: number[]; // words per line
markers, spans, headings, ...
}
cache: Map<lineText, CachedLine> // relative offsets
A vertical bar on the left = a checkpoint. Blue = the range that is re-parsed.
// binary search in checkpoints
before = lowerBound(checkpoints, 6) // → cp line 4
after = lowerBound(checkpoints, 7) // → cp line 8
part = parseLines(lines.slice(4, 9))
// offsets after the range are only shifted
delta = newLength - oldLength // +1
starts = splice(old.starts, 4, 9,
part.starts + base, n => n + delta)
words = old.words - oldWords + part.words
If that range contains an unclosed ``` fence, the context may change far below. The range is then expanded geometrically (at least 16 lines, then doubling) so that paragraphs are not parsed one at a time.
The parse result of one line is stored in a Map keyed by the contents of that line itself, and all the offsets inside it are relative to the start of the line. If the line moves because something was inserted above it, the result can still be used. Only the absolute offsets are recomputed by adding starts[i].
"Hello **world** this"[[bold, 6, 15], [hidden, 6, 8], [hidden, 13, 15]][[6, 8], [13, 15]]The cache uses two generations (previous, current): lines that do not appear again in the next parse are dropped automatically. There is a limit of 10,000 entries, 4,096 characters per key, and 4 MiB in total, so memory does not grow without bound.
This simulation builds a mock Markdown document, and then types 40 characters in the middle of a paragraph in two ways. The parser and the incremental code are mini versions of highlighter.ts, and the time is measured with performance.now().
Try moving the number of lines. The "full" time grows almost linearly, while the "incremental" one is almost flat. The remaining incremental cost comes from copying arrays the size of the document (starts, spans), the same as in Markwork: "adjusting the offset/metadata arrays is still proportional to the number of lines".
decorations.ts, highlighter.ts, outline.tsThe symptom. In the 651 KB novel manuscript, typing and moving the cursor still had a long p95 tail. Profiling showed that a lot of time went into creating small arrays that were thrown away immediately. In GJS (SpiderMonkey), allocations like that are not free: they all add pressure on the GC.
Markdown markers (**, #, `) are hidden except on the cursor line. The old code recomputed the markers for all lines on every keystroke.
// every keystroke / cursor move:
const perLine = starts.map(() => []); // N new arrays
for (const m of markers) // M new tuples
perLine[m[4]].push([hidden, a, b]);
tagger.apply(perLine, starts);
class MarkerConcealer {
active: number[] // lines applied as "active"
recheck: [first, last][] // lines that were re-parsed
multiLine: Marker[] // ``` fences only
}
visit = active ∪ newCursorLine ∪ recheck
tagger.applyLines(visit, ...) // usually 2–3 lines
After an edit, the document-sized arrays (starts, markers, spans) have to be joined: the old part + the new part + the shifted remainder.
starts = [...old.slice(0, from),
...mid,
...old.slice(to).map(n => n + delta)];
markers = old.markers.filter(...) // linear scan
.map(m => [m[0]+d, m[1]+d, ...]) // a new tuple
const out = new Array(from + mid.length + (len - to));
// fill out in one pass, tail(n) = n + delta
tail = lowerBoundBy(markers, afterOld, m => m[4]);
for (i = tail; i < markers.length; i++) {
markers[i][0] += delta; ... // changed in place
}
Changing in place is safe because the old snapshot is no longer used after the edit. This is an invariant that has to be kept: if any code still holds the old snapshot, mutating in place would corrupt it.
Previously the outline made a JSON.stringify(headings) on every keystroke to detect changes. Now the heading list is compared element by element, so no outline-sized string is created and then thrown away.
On mixed 100 blocks: typing 2.55 → 1.39 ms; setText 360.74 → 227.23 ms. The last two rows are also affected by the opening fix in Chapter 4.
Each point is one "keystroke" (300 keystrokes). The old approach creates an array per line and a tuple per marker. The new approach only checks the old/new cursor lines. Look at the points that spike: some are your browser's GC at work.
view.ts, tagsync.tsThe symptom. Opening the 651 KB manuscript froze the window for ±0.67 seconds. During that time GTK did not draw and did not accept input.
bottom_marginGTK 3 gives a height of 0 to lines that have not been laid out. The first draw after set_text covers an area below the lines that have been laid out because bottom_margin extends the canvas. As a result gtk_text_layout_draw lays out all lines up to the end of the document at once. This was proven with a plain TextView: a bottom_margin of just 1 px was enough. The fix: replaceAllText() zeroes the scroll position and then queues a scroll to the cursor, so GTK lays out two screens around the cursor first and the rest in the background.
bottom_margin temporarily. set_bottom_margin() forces GTK to lay out the whole document again, so a 12 KB paste into the 650 KB manuscript turned into more than 1 second of background work. Record failed experiments too, so nobody else repeats them.LineTagger.deferFromThe profile of opening the 650 KB manuscript (with a different outline): set_text 29 ms, parsing 31 ms, syntax tags 60 ms, the outline 54 ms, markers 33 ms, ±207 ms in total in a single block. The structure was changed slightly: LineTagger got a single number, deferFrom. null lines from that index on are skipped by apply() and filled in bit by bit by fill().
Blue = applied synchronously (the first 200 lines). Dashed = deferred. Orange = being filled in the next idle turn, prioritizing the lines around the cursor and the visible ones.
// view.ts (condensed). Priority 122: below drawing (120),
// above the GtkTextView background layout (125)
const FILL_PRIORITY = GLib.PRIORITY_HIGH_IDLE + 22;
GLib.idle_add(FILL_PRIORITY, () => {
const start = GLib.get_monotonic_time();
while (!done && elapsed(start) < FILL_BUDGET_MS) { // ≤8 ms
const visible = this.priorityLines(); // visible + around the cursor
this.syntaxTagger.applyLines(visible, ...);
done = this.syntaxTagger.fill(FILL_CHUNK_LINES, ...) && ...;
}
return done ? GLib.SOURCE_REMOVE : GLib.SOURCE_CONTINUE;
});
From the start (ce74a34) the longest pause for the 651 KB book dropped 668.9 → 71.45 ms. There is a cost: the total went up ±8% because the work is fed in bit by bit, and highlight() with no edits went 0.41 → 0.6–0.9 ms because it checks the deferred lines. Users do not feel either while typing.
The user starts typing as soon as the document opens (one keystroke every 40 ms). A keystroke is only processed when the main loop has finished the task it is running. The task durations are taken from the 650 KB profile above and then scaled by the size of the document.
The model: the synchronous part of the new approach = set_text + parsing + the first 200 lines (≈71 ms at 650 KB, the same as the measured result). The rest is split into 8 ms pieces with one 16 ms frame in between. A vertical line = a keystroke, and the length of its tail is how long that keystroke waited.
src/editor/tablelayer.tsMarkdown tables are shown as a Gtk.Grid widget over the TextView. Their raw text is hidden, and then their space is reserved with a tag of a given height (gapTag). There are two separate problems.
Every cursor move walked the tag ranges of all tables, built a signature string for the entire table, and asked get_line_yrange() for all grids. That position request forces GTK to lay out lines far off screen.
onCursorMoved() {
for (const t of tables) { // all of them
setTagRanges(...); // scan tag ranges
}
sig = blocks.map(b => `${b.start}-${b.end}:${collapsed}`)
.join(','); // a string for every table
for (const t of tables) {
y = view.get_line_yrange(t.start); // layout!
}
}
onCursorMoved() {
// only the tables that switch grid ⇄ raw text
for (const b of [oldTable, newTable]) toggle(b);
}
onScroll() {
for (const b of blocks) // positions only for the visible ones
if (visible(b)) place(b); else b.widget?.hide();
}
The old code built 500 grids on opening, and then built all of them again when the initial width of 700 px changed to 781 px, so build ran 1,000 times. Now every table is represented by a lightweight structure, and the widget is only created when the table enters the screen.
interface Block {
start, end: number // the line range
collapsed: boolean // grid or raw text
height: number // from the measurer's cache
widget: Gtk.Widget | null // null = not created yet
}
geometry: Map<tableMarkup, Geometry> // ≤256 tables
cellSizes: Map<cellMarkup, [w, h]> // ≤1,024 cells
A table's height is measured by two measuring cells (probe) that use the same CSS as real cells. Its space can be reserved with gapTag without creating the grid. The caches are bounded (256 tables, 1,024 cells, 1 Mi UTF-16 units of key) so large documents do not hoard memory. A width change reuses the cells with ellipsize, so there is no need to tear the grid down.
3 separate GJS/Xvfb processes per variant, so this is a diagnosis and not an estimate of the distribution. The raw samples are in bench/table-opening-profile.json.
Each box is one table in the document. Move the scroll position to see which tables have a widget. A widget that has been visible is reused, not torn down.
The model: ≈6.2 ms per build(), calibrated from (8,249 − 2,019) ms ÷ (1,000 − 1) builds in the 500-table measurement. The other base costs of opening are not counted here.
MainWindow.autosave()Auto save originally wrote and called fsync synchronously. On NVMe that is only ±4 ms, but on a slow or busy disk fsync can take tens of milliseconds, right when the user keeps typing. Now the timer calls the write through Gio on a worker thread. The next synchronous write to the same path waits for the background write to finish, and a test makes sure old contents do not overwrite new ones.
A small structure that changed along with it: the changed handler only stores lastEdit = now(). The timer is not recreated per keystroke; a single timer is enough, checking whether enough time has passed since lastEdit.
bench/GC-DIAGNOSIS.mdExtreme benchmarks (500 grids, 20,000 lines) used to hang often. The diagnosis: JavaScript callbacks were rejected by GJS's GC guard (gjs->sweeping()), not merely a slow GC. A lost callback can stop highlighting or the idle() Promise, and then the bench waits until the timeout. The native stack pointed to gdk_frame_clock_paint_idle (priority 120), which is an ordinary GTK frame.
The sources of allocation pressure were in the code fixed in Chapter 3: starts.map(() => []) on every keystroke, new marker tuples, and cache copies the size of the document. After allocations were reduced, long with 2,000 blocks and mixed with 500 grids (1 repetition) finished without warnings.
Optimization almost always moves cost somewhere else. This list exists so the next developer knows where to look.
Moving from GTK 3.24 to GTK 4.14 did not change a single line in src/markdown/, but the editor slowed down too. The comparison was measured side by side: the GTK 3 version was built from main in a separate worktree, and then both versions were run in turn on the same Xvfb, 10 repetitions.
Two measured causes. First, the renderer. Xvfb has no GPU, so GTK 4 draws with OpenGL on llvmpipe (software), while GTK 3 uses cairo. With GSK_RENDERER=cairo most of the difference disappears:
Second, GTK 4's set_text(). The longest pause when opening the book lies entirely in the synchronous setText(). Broken down: our highlight() is equivalent (±40–45 ms in both versions), but GtkTextBuffer.set_text(), which replaces the old contents, went from ±27 ms to ±45–58 ms. Turning off undo did not help. Detaching the buffer from the view during set_text() gave unstable results and added a ±25–46 ms pause afterwards, so that experiment was dropped.
On a desktop with a GPU (Intel UHD, hardware OpenGL) most of that difference disappears. What remains is mainly the cost of set_text():
bench/baseline.json, bench/baseline-book.json) use GTK 4 on Xvfb so that bench:compare again compares equivalent environments.set_text) from the cost of your own code before concluding anything.| Pattern | Data structure | Cost per edit | In Markwork |
|---|---|---|---|
| Remember the last state, apply the difference | (LineSpan[] | null)[] | O(N) → O(changed lines) | LineTagger, setTagRanges |
| Bound the reach of the effect | checkpoints: number[] + binary search | O(N) → O(segment) | HighlightCache.update |
| A cache keyed by contents, relative offsets | Map<string, CachedLine> with two generations | re-parse → lookup | HighlightCache |
| Only what may have changed | active[], recheck[] | N arrays → 2–3 lines | MarkerConcealer |
| Copy once, shift in place | new Array(n) + mutation | 3–4 copies → 1 | splice, spliceByLine |
| Split up long work | deferFrom + idle ≤8 ms | pause 162 → 71 ms | LineTagger.fill |
| Lazy: create when visible | widget: Widget | null + a bounded size cache | 1,000 builds → 1 | TableLayer |
| I/O off the main thread | a single timer + lastEdit | fsync → queued | autosave() |
Sources: PERFORMANCE.md, GC-DIAGNOSIS.md, baseline.json, baseline-book.json, tables-500.json, table-opening-profile.json, and the git history of bench/baseline.json.