← Markwork · Learning notes · October 3–4, 2026

Learning performance from the Markwork editor

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.

Typing per character, mixed 100 blocks
±1.5 ms
from ±286 ms · 541a967 → 8d976da
Moving the cursor 20 lines, mixed 100 blocks
29.6 ms
from 2,362 ms · 541a967 → 44cbc11
Freeze pause when opening a 651 KB manuscript
71 ms
from 669 ms · ce74a34 → d7afe2f
First open, 500 tables
2.0 s
from 8.2 s · 7080c4e → 8d976da
The big picture

The journey from one baseline to the next

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.

Typing one character: mixed 100 blocks (~30 KB)

Moving the cursor 20 lines: mixed 100 blocks

A note on method. The first two baselines (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).
  1. 541a967The first baseline is saved; bench:save and bench:compare are born.
  2. 50dd86bChapter 1 · Tags are applied by diff (LineTagger, setTagRanges).
  3. a7e4444The bench is strengthened: a sample per keystroke, p95, environment metadata, paste/delete/undo/redo.
  4. 9f324c7Chapter 2 · Parsing only the edited range, a line cache holding relative offsets.
  5. 472c298Chapters 3, 4, 6 · The 651 KB book manuscript fixture, MarkerConcealer, a single-copy splice, auto save on a worker thread.
  6. d7afe2fChapter 4 · Tags are fed in at idle, ≤8 ms per turn, when opening.
  7. 44cbc11Chapter 5 · The table layer only changes tables that switched state and only computes the position of visible grids.
  8. 7080c4eChapter 7 · Diagnosing GJS callbacks that get rejected during GC.
  9. 8d976daChapter 5 · A table grid is created when it enters the screen, its size taken from the measurer's cache.
  10. 295d7b9Chapter 7 · Migration to GTK 4.14: the bench environment changes, the baseline is replaced. The regression is recorded in Chapter 7.
Before optimizing

How to measure so the numbers can be trusted

Optimization without stable measurement is just guessing. The bench in tests/bench.ts grew along with the code:

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
LessonNoise is real: 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.
Chapter 1 · commit 50dd86b · src/editor/tagsync.ts

Don't remove everything and reapply: apply the difference

The 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.

Before no memory

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

Touched per keystroke: all lines. GTK lays out again: all lines.

After remember what is applied

class LineTagger {
  // applied[i] = the tags applied on line i
  // null = not yet known → apply again
  applied: (LineSpan[] | null)[]
}
type LineSpan = [tag, startRel, endRel]

Touched per keystroke: lines whose tags differ from applied.

Illustration: edited(first, last, count) when a line is inserted

The 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.

applied (before Enter, 7 lines)
applied (after Enter, 8 lines)

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.

Benchmark results

Before (541a967)After (50dd86b)

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).

Simulation 1: how many lines are touched? Interactive · model

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.

Remove everything + reapply
Apply the difference
Lines touched (old)
–
Lines touched (new)
–

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.

Chapter 2 · commit 9f324c7 · src/editor/highlighter.ts

Incremental parsing: parse only between two safe points

The 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.

Before the snapshot is thrown away on every keystroke

text  = buffer.get_text(start, end)  // the whole doc
lines = text.split('\n')
result = parseLines(lines)            // all lines
// starts, markers, headings: rebuilt

After a snapshot + a cache

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

Illustration: editing line 6

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.

A cache keyed by text, holding relative offsets

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].

key
"Hello **world** this"
spans
[[bold, 6, 15], [hidden, 6, 8], [hidden, 13, 15]]
markers
[[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.

Benchmark results

Before (baseline a7e4444)After (9f324c7)
Moving the cursor went up from 150 → 186 ms in this run. The cause only becomes visible in Chapter 5: the table layer. This result is recorded as it is, not covered up by replacing the baseline.
LessonThe important question: "how far can the effect of this edit spread?" If the answer can be bounded, for instance by a checkpoint, the cost per edit stops depending on the length of the document. Randomized tests (200 sequences of edits compared against the full parser) make sure the incremental result is exactly the same as the full one.

Simulation 2: full vs incremental parser, measured in your browser Interactive · real measurement

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().

Full: median per keystroke
–
Incremental: median per keystroke
–
Faster by
–

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".

Chapter 3 · commit 472c298 · decorations.ts, highlighter.ts, outline.ts

Fewer allocations: change what needs changing, copy once, shift in place

The 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.

3a. MarkerConcealer: from "all lines" to "lines that may have changed"

Markdown markers (**, #, `) are hidden except on the cursor line. The old code recomputed the markers for all lines on every keystroke.

Before

// 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);

Long, 20,000 lines ≈ 20,000 arrays + tens of thousands of tuples per keystroke.

After

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
Moving the cursor from line 3 to 9 (the old way)
Moving the cursor from line 3 to 9 (the new way)

3b. A single-copy splice + binary search + shifting in place

After an edit, the document-sized arrays (starts, markers, spans) have to be joined: the old part + the new part + the shifted remainder.

Before 3–4 intermediate copies

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

After a single copy

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.

3c. The outline: compare directly, don't serialize

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.

Benchmark results (651 KB book manuscript)

Before (src ce74a34)After (472c298)

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.

Simulation 3: allocations per keystroke and the latency tail Interactive · real measurement

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.

All lines (old)Lines that may have changed (new)
Old: median / p95 / max (ms)
–
New: median / p95 / max (ms)
–
Chapter 4 · commits 472c298 & d7afe2f · view.ts, tagsync.ts

Opening a document: what matters is not the total, but the longest pause

The 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.

4a. An unexpected culprit: bottom_margin

GTK 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.

An experiment that was rejected: removing 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.

4b. Incremental highlighting: LineTagger.deferFrom

The 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;
});

Benchmark results: open, longest pause

Before (baseline 472c298)After (d7afe2f)

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.

LessonTotal time and responsiveness are two different metrics. Splitting up work does add overhead, but it keeps the main loop able to draw and accept input. That is what users feel.

Simulation 4: the main loop while opening, and the fate of the user's keystrokes Interactive · calibrated model

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.

One synchronous block
Fed in ≤8 ms at idle
Old: longest pause
–
New: longest pause
–

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.

Chapter 5 · commits 44cbc11 & 8d976da · src/editor/tablelayer.ts

Tables: do only what changed and what is visible

Markdown 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.

5a. Moving the cursor (44cbc11)

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.

Before

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!
  }
}

After

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();
}
BeforeAfter (44cbc11)

5b. Opening a document with 500 tables (8d976da)

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.

Before (7080c4e)After (8d976da)

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.

Simulation 5: grids all created vs when visible Interactive · calibrated model

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.

Build everything (+ rebuild when the width changes)
Build when visible
Old: build() / estimated cost
–
New: build() / estimated cost
–

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.

Chapter 6 · commit 472c298 · MainWindow.autosave()

Auto save: take I/O off the main thread

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.

SynchronousBackground (the main-thread part)
Chapter 7 · commit 7080c4e · bench/GC-DIAGNOSIS.md

GC and the limits that are not finished

Extreme 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.

What is honestly not proven: the connection between the drop in allocations and the disappearance of the symptom has not been isolated through an A/B per change. The suspicion of a bug in the incremental GC sweeping flag is still a reasoned guess, not an upstream conclusion. Reopening 500 tables still has a pause of ±0.6 s, and paste on mixed 25 went up 26.5% (162 → 205 ms) in one comparison that has not been isolated.

Regressions on record

BeforeAfter

Optimization almost always moves cost somewhere else. This list exists so the next developer knows where to look.

Migrating to GTK 4: an inherited regression

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.

GTK 3 (cairo)GTK 4 (OpenGL on llvmpipe)

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:

GTK 4 (OpenGL on llvmpipe)GTK 4 (cairo)

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():

GTK 3GTK 4 (GPU)
Not measured yet: Wayland. The Xvfb numbers above are the worst case (equivalent to a machine without a GPU). The new baselines (bench/baseline.json, bench/baseline-book.json) use GTK 4 on Xvfb so that bench:compare again compares equivalent environments.
LessonChanging the toolkit changes the measurement environment. Compare both versions side by side in the same session, and then separate the toolkit's cost (the renderer, set_text) from the cost of your own code before concluding anything.
Summary

Patterns that carry over to other code

PatternData structureCost per editIn Markwork
Remember the last state, apply the difference(LineSpan[] | null)[]O(N) → O(changed lines)LineTagger, setTagRanges
Bound the reach of the effectcheckpoints: number[] + binary searchO(N) → O(segment)HighlightCache.update
A cache keyed by contents, relative offsetsMap<string, CachedLine> with two generationsre-parse → lookupHighlightCache
Only what may have changedactive[], recheck[]N arrays → 2–3 linesMarkerConcealer
Copy once, shift in placenew Array(n) + mutation3–4 copies → 1splice, spliceByLine
Split up long workdeferFrom + idle ≤8 mspause 162 → 71 msLineTagger.fill
Lazy: create when visiblewidget: Widget | null + a bounded size cache1,000 builds → 1TableLayer
I/O off the main threada single timer + lastEditfsync → queuedautosave()
Checklist before writing an optimization 1) Measure first with a scenario the user feels, including p95 and the longest pause. 2) Ask "what actually changed?" and look for a data structure that can answer it without scanning everything. 3) Write a test that compares the fast path with the slow path that is already correct. 4) Measure again before/after back to back, and record regressions and failed experiments too.

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.