Hazel performance analysis · ^report(14) · 2026-10-14
What the page keeps from one edit to the next
How much memory does a Hazel tab keep as you edit, and what keeps it? ^report(5) listed the forms a view takes on the page at once (its Html value, the tables of values shared with the evaluation worker, the virtual DOM tree last drawn, the DOM) and said that what memory these take had not been measured. This report measures a tab on #2597 (the full livelits branch) while one slider on the Livelit Playground's Full Body slide is moved again and again. The tab keeps about 90 MB more after every edit. The report says what that memory is, which of the page's caches and tables it is not, how that was found where the usual tool fails, and what is still open: which reference keeps it.
Short answers. One slide, Full Body, on #2597 at c4e411f9c2, in headless Chromium; after each slider edit, two forced garbage collections and the page's live JavaScript heap. The method is at the end.
- A tab keeps about 92 MB more after every slider edit, and does not level off. Over 15 edits of one slider, the live heap went from 399 to 1,787 MB (+1,388 MB), steadily (Figure 1). At that rate, about 25 more edits would pass 4 GB.
- None of the caches is what keeps it. Each was tried in a build of its own: the undo history capped at 2 states, still 92 MB per edit; the projector view cache turned off, 83; the whole-program memos held to one entry, 73; memos and undo together, 71 (Figure 2). The memos account for about 19 MB per edit while they fill up to their bounds, and stop when they are full; nothing tried touches the rest.
Update, later on 2026-10-14: the undo history is what keeps it; the build "capped at 2" never applied its cap, which needs a setting that is off by default (Update). - No long-lived table grows. A build that counts 14 of the page's tables after every edit (those keyed by ids, and HazelDB's cache) found each the same size after 8 edits as after the first, while the heap grew by 708 MB. ShareCodec's two tables of values shared with the worker rise and fall back as they reset at their cap of 200,000 entries (Figure 3).
- What stays is whole versions of the editor's state. V8's sampling heap profiler, over four edits, keeping only what is still alive: 390 MB, made by every stage of an edit. Types and statics 119 MB, ValueHash's hashes of objects 98, terms and syntax 64, the worker's replies 46, layout 41, other 23 (Figure 4). Not one cache's entries: each edit's whole product.
- Which reference keeps them is still open. A heap snapshot would show the path from a root to each object kept, but taking one kills this tab's renderer, in two builds of Chromium 155. A test page with a WeakMap of two million live entries never finished its snapshot either, and ValueHash's memo is a WeakMap of about that size; a build without it may let a snapshot through (below).
Update, later on 2026-10-14: found, with a snapshot: the undo history (Update). - Every weak table here is a JavaScript WeakMap, and none is the cause. A WeakMap's entry lives exactly as long as its key, so ValueHash's 98 MB measures how much old state is alive, not a leak of its own. The fix, once the holder is found, is an owner and a lifetime for what it holds, not a weaker reference (below).
Update, later on 2026-10-14: what keeps the versions is the undo history after all.
- The undo history keeps the whole page as it was before every edit, each with its editors' syntax, statics and layout, and nothing ever trims it. Its cap of 250 states applies only when a setting,
cap_undo_stack, is on, and it is off by default. So the build "capped at 2" in answer 2 changed a number the code never reached: that is why it changed nothing. - With the cap applied always, at 2, the tab stops growing after the third edit: it keeps 284 and 284 MB after 12 edits (two runs), against 1,048 MB as built (below).
- Andrew Blinn found and fixed this on the modular editors (#2469) in
6010128b7d: each state in the undo history drops what can be rebuilt (syntax, statics, evaluation results), and the cap always applies. Ported to #2597 (d41c7a0298), the tab keeps 159 MB after 12 edits, but a redo, and half the undos, then take about as long as an edit: about 6 s on Full Body, against 2.6 to 3 s. So #2597 also keeps the newest undo and redo entries whole (f8ae6f2b9c): the latest undo is as fast as before, for one page model more, 208 MB after 12 edits. Both are on #2597 since 2026-10-15. On #2635, which had the compaction already, every redo and most undos took about three edits' time; with the newest entry kept whole there too (032fe3aca6), each is done within 0.3 s, for no memory we could measure. - A heap snapshot named it. With ValueHash's memo kept only within one synchronous run, a snapshot of Full Body completes (answer 5's lead), and a dominator tree of two snapshots finds the kept versions at the head of the undo history.
A tab, edit by edit
The slide is the Livelit Playground's Full Body: a figure drawn with the Drawing library and posed by sliders, each a user-defined livelit. The build is #2597 at c4e411f9c2, its head on 2026-10-14, in its development profile, which keeps function names (the profiler below needs them). A driver loads the slide in headless Chromium 155, with precise memory figures and garbage collection exposed, then sets the shirt-width slider to a new value and fires its input and change events, as letting go of a slider does. It waits until the picture has redrawn and four seconds more, forces two garbage collections and reads the page's live heap (performance.memory.usedJSHeapSize). The values run 67, 74, 81 and on, each 7 more, wrapping within 60 to 99, so no two edits in a row are the same.
Every edit adds between 76 and 173 MB, most about 80 to 95, with no sign of levelling off (Figure 1). This is the page's main thread only: the evaluation worker has a heap of its own, not counted here.
| Edit | Slider set to | Live heap, MB | Added, MB | Redraw, s |
|---|---|---|---|---|
| loaded | 399 | |||
| 1 | 67 | 483 | 84 | 1.87 |
| 2 | 74 | 564 | 80 | 1.93 |
| 3 | 81 | 657 | 94 | 2.26 |
| 4 | 88 | 751 | 93 | 6.8 |
| 5 | 95 | 831 | 80 | 2.18 |
| 6 | 62 | 912 | 81 | 5.97 |
| 7 | 69 | 1,020 | 107 | 1.77 |
| 8 | 76 | 1,100 | 81 | 7.14 |
| 9 | 83 | 1,218 | 117 | 2.51 |
| 10 | 90 | 1,298 | 80 | 2.32 |
| 11 | 97 | 1,379 | 81 | 2.31 |
| 12 | 64 | 1,458 | 79 | 2.06 |
| 13 | 71 | 1,533 | 76 | 3.31 |
| 14 | 78 | 1,706 | 173 | 3.87 |
| 15 | 85 | 1,787 | 81 | 7.47 |
One run. The run with the undo history capped at 2 (below), which turns out to change nothing, read within 7 MB of this one at every edit and within 1 MB at the end: that is the noise between runs here, well below the differences that matter.
Update, later on 2026-10-14: it changed nothing because its cap never ran, so these were two runs of the same program; the noise estimate stands.
Not the caches
The page has a few caches that could keep whole programs, and each was the first suspect. Each was tried in a build of its own on the same commit, measured over the same 15 edits:
- The whole-program memos.
Statics.mkremembers the statics of the last 16 programs it saw, and MakeTerm's three memos the last 8 parses (Core.Memo.generalwith acache_size_bound). Held to one entry each, the heap still grows 73 MB per edit, and every redraw takes 18 to 31 seconds, since nothing is reused. The difference, about 19 MB per edit, is these memos filling up to their bounds over the first 15 edits; it stops when they are full. - The undo history keeps 250 states. Capped at 2, nothing changes: 92 MB per edit. Its states share structure, so they are cheap.
Update, later on 2026-10-14: not so. The cap applies only whencap_undo_stackis on (History.re), and it is off by default (Settings.re), so this build changed a number the code never reached, and the states, each a whole page model, are not cheap: they are the growth (Update). - The projector view cache (
ProjectorView.ViewCache) keeps a view for each projector's id and is never pruned. Turned off, 83 MB per edit, over six edits read three times; within what the other runs vary by. - ShareCodec's tables. To send the worker only what it does not have, the page keeps the hashes of values it has sent (a Set) and the values the worker has sent it, by hash (a Map). Counted after every edit (Figure 3), they rise, reach their cap of 200,000 entries and reset, while the heap keeps growing through each reset. A census of every Map and Set on the page (
Runtime.queryObjects) found these two to be by far its largest; the next largest had 20 entries.
| Build | What it changes | Patch | Edits | Growth per edit, MB | In all, MB | Redraw, s: median (range) |
|---|---|---|---|---|---|---|
| As built | nothing: #2597 at c4e411f9c2 | none | 15 | 91.6 | +1,388 | 2.3 (1.8–7.5) |
| Undo history capped at 2 | History.re keeps 2 states, not 250 | undo_history_capped_at_2.patch | 15 | 91.6 | +1,387 | 2.5 (2.1–7.2) |
| Whole-program memos at one entry | Statics.mk from 16 entries to 1; MakeTerm's three memos from 8 to 1 | memos_at_one_entry.patch | 15 | 72.9 | +1,101 | 22.5 (18.2–31.3) |
| Memos at one and undo at 2 | both of the above | none | 15 | 71.3 | +1,093 | 22.8 (19.4–27.3) |
| Projector view cache off | ProjectorView.ViewCache never stores a view | view_cache_never_stores.patch | 6 (read after 0, 3 and 6) | 82.9 | +498 | 6.6–8.3, another driver |
Redraw: from setting the slider to the picture's markup changing. The view-cache run used the DevTools-protocol driver (below), whose redraws are not comparable with the others'.
Not a table keyed by ids
If no cache keeps the versions, something else does. A read of every module-level store on the page's main thread (in src/web, src/haz3lcore, src/language and src/util at c4e411f9c2: every ref, Hashtbl, map in a ref, memo and WeakMap) found that nothing on the ordinary editing path adds a version per edit by its construction. Every store sized like a program holds one version and is replaced by the next. Nor does any version keep the one before it: CachedSyntax keeps the inputs it was made from, never an earlier CachedSyntax; the editor's old statics are arguments, never stored; an evaluation result's streaming fields are cleared when the result lands; and the evaluator's state and its incremental cache refer to each other, but replay_state unties the two, so caches do not chain.
Update, later on 2026-10-14: the read covered module-level stores, not the app's own model, where the undo history lives: a list in the model, not a store, adds one version per edit (Update).
That left tables keyed by ids: an entry is replaced when its id comes again, but if ids change from edit to edit, old entries stay. A probe build registers each such table with a counter, and the driver reads all of them after every edit (the probe is count_id_keyed_tables.patch). None grows:
| Table | Key: what it holds | Loaded | After 1 edit | After 8 edits |
|---|---|---|---|---|
Statics.module_livelits | a module's id: the livelits it binds, each with its elaborated definition | 6 | 6 | 6 |
LivelitProj: last_good_shape | a livelit's id: its last good shape | 43 | 43 | 43 |
LivelitProj: shape_samples | a livelit's id: shapes sampled | 43 | 43 | 43 |
LivelitProj: computed_shapes | a livelit's id: its computed shape | 9 | 9 | 9 |
LivelitProj: optimistic | a livelit's id: a value shown before the worker answers | 0 | 1 | 1 |
LivelitProj: warned_ephemeral | ids already warned about | 0 | 0 | 0 |
LivelitProj: last_good_view | a livelit's id: its last good virtual DOM, whose handlers close over the statics and the dynamics | 43 | 43 | 43 |
LivelitProj: view_memo | a livelit's id: its definition's elaboration and view | 42 | 42 | 42 |
MakeTerm: map | the last parse's ids to terms | 3 | 3 | 3 |
MakeTerm: term_data | the last parse's term data | 3 | 3 | 3 |
MakeTerm: projectors | the last parse's projectors | 0 | 0 | 0 |
| HazelDB's cache | a saved key: its value (5,730 KB throughout) | 6 | 6 | 6 |
ProjectorView.ViewCache | a projector's id: its view (never pruned) | 43 | 43 | 43 |
AppStore | a projector's id: an app's model, update and view (its gc is never called) | 0 | 0 | 0 |
| ShareCodec: values received from the worker, by hash | reset at 200,000 entries | 121,706 | 143,082 | 183,160 (max 185,747) |
| ShareCodec: hashes of values sent to the worker | reset at 200,000 entries | 119,765 | 140,359 | 139,512 (max 213,234) |
| Live heap, MB | 397 | 480 | 1,105 |
One run of 8 edits on the probe build. optimistic goes from 0 to 1 at the first edit and stays there.
What stays
With every table ruled out, the question becomes what the kept memory is. V8's sampling heap profiler answers by who made it: started after two warm-up edits (HeapProfiler.startSampling, one sample per 32 KB on average, dropping whatever the garbage collector frees) and stopped after four more, it reports the objects still alive, each with the stack that allocated it. The development build's bundle marks each function with its Reason source location, so each stack's innermost Hazel frame names a source file.
| Made by | MB | Share | Source files, largest first (MB) |
|---|---|---|---|
| Types and statics | 119 | 31% | ConstructorMap.re 39.0, Typ.re 28.0, Statics.re 26.7, StaticsBase.re 10.9, VarMap.re 3.9, CoCtx.re 2.7 |
| ValueHash's hashes | 98 | 25% | ValueHash.re 97.6 |
| Terms and syntax | 64 | 16% | MakeTerm.re 19.6, IdTagged.re 14.7, TermBase.re 6.9, Skel.re 5.9, TermMap.re 4.8, Aba.re 3.8 |
| The worker's replies, decoded | 46 | 12% | WorkerServer.re 46.0 |
| Layout | 41 | 10% | Measured.re 32.3, Point.re 4.5, Code.re 3.7 |
| Other | 23 | 6% | OptUtil.re 8.7, ShareCodec.re 7.4, HazelDB.re 5.6, ListUtil.re 0.3, and the rest under 0.5 MB each |
| In all | 390 | 100% | over four edits |
The 390 MB, about 98 MB per edit, is the growth itself, and it comes from every stage of an edit: the parse into terms, the statics and the types they make, the layout of the code, the evaluation worker's reply as decoded on the page, and the hashes of all of these. Nothing one cache would hold; rather, the editor's whole state as of each edit, kept. The profiler says who made what stays, not who holds it. That takes a heap snapshot.
Why not a heap snapshot
A heap snapshot (HeapProfiler.takeHeapSnapshot, the DevTools Memory panel) records every object and every reference, so it would show the chain of references from a root to the kept versions, and with it the holder. On this page it does not work:
| Page | Result |
|---|---|
| Full Body on #2597, as loaded (about 400 MB) | the tab's renderer dies at once |
| Full Body after two edits (about 560 MB) | the tab's renderer dies at once |
| A trivial page | snapshot taken |
| A page holding a chain of arrays a million deep | snapshot taken |
| A page holding a WeakMap with two million live entries | not finished after 400 seconds |
The same in snap's Chromium 155.0.8059.39 and in Chrome for Testing's headless shell 155.0.8059.79, with the renderer's hang monitor off. The last row is the lead: ValueHash's memo is a WeakMap, and by its 98 MB in the profile it holds roughly two million entries (an estimate from bytes per entry, not a count). A build with that memo off, or bounded, may let a snapshot through, and the snapshot would name the holder.
Update, later on 2026-10-14: the lead held. With ValueHash's memo kept only within one synchronous run, a snapshot of Full Body after six edits completes in snap's Chromium: 2.3 GB of JSON in 75 s (Update).
The weak tables
Weak references are the usual suspect when memory is kept by a cache, so all of them were checked. Every weak structure in #2597 is a JavaScript WeakMap, used through js_of_ocaml; there is no OCaml Weak or Ephemeron anywhere.
| Where | Key and value | Verdict |
|---|---|---|
ValueHash's memo (util/ValueHash.re) | any block, and its hash | small values; its 98 MB above is a symptom of the objects living |
ValueHash.Pairs (Equality.re) | a term, and the term compared with it and the answer: it holds the other term strongly | made fresh for each equality check, so it goes with the check |
TypHash (term/TypHash.re) | a type, and its hash | small |
IdentityTable in CallMemo (dynamics/CallMemo.re) | a function body, and its id-free form or its remembered calls (8 per body) | bounded per body; an entry's inputs could reach an environment |
IdentityTable in Ascriptions | a type, and its unrolling; a value, and a short list (reset) | small |
IdentityMemo (util/IdentityMemo.re) | an object, and a function's result on it | fine for a pure function; non-objects are not kept |
A WeakMap's entry lives exactly as long as its key. That is the trouble with weak tables in general: what they keep depends on what keeps their keys, and that is written nowhere. So the fix, once the holder is found, is not a weaker reference. It is an owner and a lifetime: entries for the program shown now, dropped when the program moves on. Matthew's lesson from Adapton is the same: working within OCaml's garbage collector through weak pointers was a headache and error-prone, and the later memo system, with its demanded computation graphs, was built in Rust, with no garbage collector to work around.
Found on the way
The read of every store turned up five things that do not cause this growth but are worth a look of their own:
- A memo with no bound, in derivation mode only:
stitch_term(derivation/DerivationExercise.re) keeps whole editors throughCore.Memo.generalwithout acache_size_bound. AppStore.gcis never called; a TODO inPage.resays so. Where an MVU app's projector ids change, its model, update and view closures stay.- StructureShareSexp's two maps are reset after each root, but not if a converter raises, and then they grow for good.
- HazelDB opens an IndexedDB connection for every save and never closes it.
- An agent's tool results keep two whole-program segments per edit in its chat.
What next
- Snapshot without ValueHash's memo. Build #2597 with the memo off (or bounded), check that the growth is unchanged, and take a heap snapshot after a few edits. If it completes, its retainer paths from a kept version to a root name the holder.
- Find a smaller slide that grows the same way, so that a snapshot fits even with the memo: one whose slider edits a livelit's model, measured as here.
- Look outside the program's values: closures the browser holds on the render path (event listeners, timers, observers) keep everything they close over, and a snapshot would show them as roots.
- Measure the same on #2635 (livelits with modular editors) and on
dev, to see whether the growth is #2597's own. - Fix it with an owner and a lifetime for whatever holds the versions, and measure the same 15 edits again.
Update, later on 2026-10-14: steps 1, 3 and 4 are done and step 2 is not needed (Update): the snapshot worked; what the browser holds does not grow; #2635 keeps about 8 MB per edit and falls back, and dev has no Livelit Playground slides to measure but has the same undo history. Step 5 is Andrew's compaction, ported.
Update, later on 2026-10-14: the undo history
What keeps the versions. The page's undo history, History.Model.undo_stack in src/web/app/History.re, is a list of whole page models: before every edit, the page as it was, with every slide's editor and that editor's syntax, statics and layout. On Full Body that is about 90 MB per edit, kept by nothing else. The list is meant to stop at 250 states, but it is cut only when the setting cap_undo_stack is on, and Settings.re sets it off. So the history grows with every edit, and the build above that "capped it at 2" changed the 250 that this code never reaches.
| Build | What it changes | Runs | Kept after 12 edits, MB | Growth per edit, edits 4–12, MB | Redraw, s: median (range) |
|---|---|---|---|---|---|
| As built | #2597 at c4e411f9c2, nothing changed | 1 | 1,048 | 89.0 | 2.41 (2.14–7.59) |
| Undo always capped at 2 | History.re cuts the history to 2 states whatever the setting (a probe, not a fix) | 2 | 284 and 284 | 4.4 and 4.2 | 2.21 (1.82–6.68); 2.29 (2.01–6.62) |
| Compaction ported | Andrew's 6010128b7d, adapted: d41c7a0298 on #2597, measured as the same change alone on c4e411f9c2 | 1 | 159 | -0.4 | 2.36 (1.92–6.43) |
| Compaction, the newest entry kept whole | on top of the port, the newest entry of each stack keeps its page model whole until the next is pushed: f8ae6f2b9c on #2597, measured likewise | 1 | 208 | 3.5 | 2.34 (2.23–7.15) |
| #2635 | livelits with modular editors, at 9fb2bdf067: has Andrew's fix | 1 | 52 | 1.6 | 0.99 (0.91–1.58) |
Growth per edit is the slope from the 4th edit to the 12th, after the history has filled to its cap in the capped build. In every #2597 build redraws range from about 2 to 7 s, as in Figure 1, and do not separate the builds. #2635 was measured over 30 edits: it keeps 9 MB after the first and 94 MB after the tenth, falls back to 46 MB at the eleventh, and does so again at the 22nd; the likely cause is ShareCodec's tables starting over at their cap, not confirmed.
How it was found. In five steps, each taking one suspect off the list:
- Not what the browser holds. Over six edits the heap grew from 400 to 900 MB while the page's event listeners (609), DOM nodes, detached ones included (47,923), observers and timers stayed exactly the same (counted with
Memory.getDOMCountersand wrappers installed before the page's own scripts). - Not on #2635. The same sampled profile as Figure 4, over the same four edits, finds 37.5 MB kept on #2635: the worker's replies 14.5, ValueHash 9.8, ShareCodec 5.4, and almost nothing in types, statics, terms or layout, where #2597 keeps 390 MB.
- A snapshot, at last. A probe build keeps ValueHash's memo only within one synchronous run (it starts over on the next microtask). The tab still grows 68 MB per edit, and a snapshot after six edits completes: 2.3 GB of JSON in 75 seconds.
- Two snapshots, one session. V8 keeps an object's snapshot id for the life of the tab, so the objects in a snapshot after edit 6 whose ids a snapshot after edit 3 lacks were made by edits 4 to 6: 12.2 million objects, 257 MB. A dominator tree of the second snapshot (29 million nodes, computed in 4.6 s by a small C program) shows three copies of one 35 MB structure, Full Body's editor with its derived data. Two hang from the first and second entries of the undo history (
Logged.Model.current, thenundo_stack, then each entry's page model, its list of slides, and Full Body). The third is the projector view cache's one view, which is replaced, not added to. - Confirmed by a build: the undo history always cut to 2 states, the same 12 edits, twice (the table above).
The fix is Andrew's. Andrew Blinn met the same growth on the modular editors, on a much larger program, and fixed it in 6010128b7d (2026-08-26, "memory: kill the exponential co_ctx leak + compact undo snapshots"): each page model saved for undo has its syntax replaced by a small shared placeholder marked out of date, its statics and evaluation state emptied, and its result reset (since d93e482166, also his, it keeps what autosave keeps); undo rebuilds them from the program. And the cap always applies. That is why #2635, which has #2469 merged in, does not grow. dev has the same undo history as #2597.
Ported to #2597 (d41c7a0298; #2597's slide list is simpler than the modular editors', and the rest follows Andrew's code, less two caches #2597 does not have, as it stands on #2635), the tab keeps 159 MB after 12 edits, level from the third. The six Undo tests pass, as changed by Andrew to check the restored program text rather than the very same model. In the browser, an edit, an undo, a redo, a second edit and another undo restore the same picture and slider value as as built does, in five runs of each, alternating between the builds (median and range; on #2597 the five with the newest entry kept whole ran later, alternating with five more as built, which agree with these; the two #2635 rows ran one after the other):
| Build | Runs | Edits, s | Undo, s | Redo, s | Undo after a second edit, s | Restores | Console errors |
|---|---|---|---|---|---|---|---|
| #2597 as built | 5 | 6.7 (6.3–7.2) | 2.6 (2.6–2.6) | 3.0 (2.7–3.1) | 2.6 (2.5–2.7) | yes, every step | 387 |
| #2597, compaction ported | 5 | 6.9 (6.2–8.4) | 5.8 (2.0–5.9) | 6.1 (6.0–6.5) | 3.0 (2.5–6.5) | yes, every step | 828–882 |
| #2597, the newest entry kept whole too | 5 | 6.6 (6.1–7.4) | 2.6 (2.5–2.6) | 3.0 (2.5–3.0) | 2.6 (2.5–3.2) | yes, every step | 387–414 |
#2635, as it was (653ebc4adf: Andrew's compaction) | 5 | 1.2 (1.1–1.4) | 3.6 (1.2–3.9) | 3.9 (3.6–4.2) | 3.9 (1.1–5.1) | yes, every step | 378 |
#2635 with the newest entry kept whole (032fe3aca6) | 5 | 1.1 (1.1–1.7) | 0.3 (0.2–0.3) | 0.3 (0.2–0.3) | 0.3 (0.3–0.3) | yes, every step | 120 |
Seconds from the key press (or the slider's change) to the picture's markup changing, read by the DevTools-protocol driver (undo_check.mjs), whose times run longer than heap_per_edit.py's, as in the caches table: compare them within this table only. On #2597, two of the as-built undos, and two of those with the newest entry kept whole, read 0.24 s or less where every other step there took 2.5 s or more: the driver catching a redraw from before the key press, so they are left out. On #2635 with the newest entry kept whole, every step reads 0.25 to 0.31 s: the redraw is there at the check's second look, 0.3 s after the key press. The console errors are one message, "HazelDOM: unrecognized attribute: Disabled", in every run, as built too; it comes about twice as often with the compaction, which fits more redraws.
What it costs. An undo now rebuilds what the saved state dropped. With the compaction, every redo took 6.0 to 6.5 seconds, about an edit's time, against 2.7 to 3.1 as built; half the undos did too (5 of 10, 5.8 to 6.5 s), and the other half took 2.0 to 3.0 s, as built does. Which undos stay fast was not examined; the whole-program memos, which remember the last few programs, may still hold the program being restored. So f8ae6f2b9c, also on #2597, keeps the newest entry of each stack whole, compacting it only when the next is pushed above it: the latest undo and redo then take what they take as built (median 2.6 s over five runs), for one page model more, 208 MB kept after 12 edits instead of 159. An undo further back still rebuilds. Andrew's note on the cap names the step after this one: undo entries that keep the program alone, not a page model.
#2635 and dev. #2635 had Andrew's compaction alone: every redo took 3.6 to 4.2 s and most undos 3.5 to 5.1 s, about three edits' time (an edit 1.2 s there, five runs); with the newest entry kept whole (032fe3aca6, on #2635 since 2026-10-15), every undo and redo is done within 0.3 s, the check's polling interval, and the memory kept over 12 edits is the same within about 1 MB (the undo times in the table above, the memory in the report's data). dev, which has the same undo history, is left to #2469's merge, which brings Andrew's fix (Matthew's call, 2026-10-15).
The update's data, in 014/data/update-2026-10-14/: each run's readings, the undo checks, the listener and node counts, #2635's profile by file, and, in holder/, the dominator reports and the tools (the snapshot index, the dominator tree in C, the drivers). The snapshots themselves, 4 GB, are not kept. In this page, under update_2026_10_14 in the report's data.
How this was put together
- Build: #2597 at
c4e411f9c2, development profile. Each experiment is one build of that commit with one patch from this repository'spatches/:memos_at_one_entry.patch,undo_history_capped_at_2.patch(and both together),view_cache_never_stores.patch, and the probe,count_id_keyed_tables.patch. Each applies toc4e411f9c2as it is. - Browsers: snap's Chromium 155.0.8059.39, headless, with
--enable-precise-memory-infoand--js-flags=--expose-gc; for the snapshot tests also Chrome for Testing's headless shell 155.0.8059.79. - Drivers (in
014/drivers/):heap_per_edit.py, Python over chromedriver, for the 15-edit runs; and, in node 22 over the DevTools protocol with its built-in WebSocket,count_tables.mjs(the probe's counts),census_maps_sets.mjs(every Map and Set, with the view-cache run),sample_live_heap.mjs(the sampling profile) andsnapshot_test.mjs.live_heap_by_file.pygroups the profile by source file. - Runs: one per build: 15 edits each, except the view-cache run (6, read after 0, 3 and 6), the probe (8) and the profile (2 to warm up, then 4). The heap is the page's main thread's, read after two forced collections and four seconds after the picture redrew. The two runs expected to match, as built and undo capped at 2, agree within 7 MB at every edit.
Update, later on 2026-10-14: the update's runs are 12 edits each, in the same browser and with the same driver; its builds and patches are listed in its section. - The profile's grouping: each sample is counted under the innermost frame on its stack that lies in Hazel's own code, skipping js_of_ocaml's runtime helpers (
caml_*), and that frame is named by the Reason source location the bundle gives just above it. Generic helpers (OptUtil,ListUtil) count as their own file, under "other". The raw profile is 31 MB and not kept here; its summaries are in014/data/, with every run's numbers. - The audit of every store and weak table read the source at
c4e411f9c2; it is the basis of the tables in Not a table keyed by ids and The weak tables. - The numbers, for programs too: every number behind the figures and tables is also in this page, as JSON in its
<script type="application/json" id="report-data">element, so a reader or an agent that fetches the page alone has the dataset: each run's readings, the probe's counts, the census, the profile by file and the snapshot tests. The same numbers, and the profile's summaries, are files in014/data/. - Matthew Hammer and Claude, 2026-10-14.