Hazel performance analysis

Notes on making Hazel responsive enough for its livelits to feel like plain apps: what we measured, what we found, what we fixed. Matthew Hammer.

Where this work is tracked, day to day: the Hazel Work Dashboard. The series' source: matthewhammer/hazel-performance-analysis on GitHub.

  1. Three rows of frame ticks over one 1.5-second slider drag: Dress Up's packed evenly with no freezes; Kids' Choice's with small gaps, frozen 0.3 s; Full Body's in bursts between long freezes, frozen 2.5 s, its picture final about a second after the finger lifts.Three rows of frame ticks over one 1.5-second slider drag: Dress Up's packed evenly with no freezes; Kids' Choice's with small gaps, frozen 0.3 s; Full Body's in bursts between long freezes, frozen 2.5 s, its picture final about a second after the finger lifts.
    ^report(15) · 2026-10-15

    The same 1.5-second slider drag in Messed Up Dress Up, the plain-JavaScript game, and in its livelits on #2635, in one browser. Dress Up never froze and its picture was final as the finger lifted, at about 1.4 ms of JavaScript per change. Kids' Choice froze 0.11 s and was final 0.13 s after; Full Body, the whole friend in Hazel, froze 2.9 s and was final 1.1 s after, at about a second per change. A week of fixes took these from 4.6 and 8.6 s. Then a guess, argued step by step, for September 2027: small livelits immediate, Full Body a short lag. Updated later on 2026-10-15: counting shapes shows the comparison is like for like only for the friend alone (46 shapes against Full Body's 49); Dress Up's places draw up to 30 times as much, and still never froze.

  2. A line rising from 399 MB to 1,787 MB over 15 slider edits on Full Body, about 92 MB per edit, above a flat band for every cache and id-keyed table.A line rising from 399 MB to 1,787 MB over 15 slider edits on Full Body, about 92 MB per edit, above a flat band for every cache and id-keyed table.
    ^report(14) · 2026-10-14

    A tab on #2597 keeps about 92 MB more after every slider edit on Full Body, without levelling off. It is not the caches (each tried in a build of its own) and not any long-lived table (each counted after every edit): what stays is whole versions of the editor's state, made by every stage of an edit. Which reference holds them is still open, since a heap snapshot kills this tab; the lead is ValueHash's WeakMap of about two million entries. Updated later on 2026-10-14: a heap snapshot named it, the undo history, which kept a whole page model per edit, uncapped by default. Andrew Blinn's compaction, ported, with the newest undo entry kept whole so undo stays as fast as before, keeps the tab level: 208 MB after 12 edits, against 1,048. Both are on #2597 and #2635.

  3. Paired bars for Full Body's four sliders, from the top of the program down: a slider release's request to the evaluation worker, before and after: shirt width 17.5 to 3.6 ms, alien fingers 20.6 to 7.3, eye angle 23.9 to 16.5, tongue width 27.5 to 20.8.Paired bars for Full Body's four sliders, from the top of the program down: a slider release's request to the evaluation worker, before and after: shirt width 17.5 to 3.6 ms, alien fingers 20.6 to 7.3, eye angle 23.9 to 16.5, tongue width 27.5 to 20.8.
    ^report(13) · 2026-10-14

    A slider release's request to the evaluation worker on #2635: building its projection of the statics now takes under a millisecond on every Livelit Playground slide, since the per-item statics say which keys they changed instead of the projection walking both maps. What is left grows with how far down the program the slider sits, since every item above it carries the program below. The three commits behind it, carried to #2469 (one change each, its quick tests whole), and what dev and #2597 need first: per-item statics.

  4. Paired bars for three slider edits on Full Body: the shapes each changes in today's flat picture against the nodes it changes in a picture of groups with transforms: shirt width 39 to 13, arm angle 30 to 8, hand angle 24 to 2.Paired bars for three slider edits on Full Body: the shapes each changes in today's flat picture against the nodes it changes in a picture of groups with transforms: shirt width 39 to 13, arm angle 30 to 8, hand angle 24 to 2.
    ^report(12) · 2026-10-13

    Full Body rebuilt as a picture of groups with transforms, flattened to today's picture exactly: every slider edit that changes the picture changes at most 14 nodes (it was up to 39 shapes). The computation follows only in part: the eye edits' trace distance falls about ten points; with the tree flattened at the end, the body edits' rises a little, the flattening's own steps; Hazel's update today does not change. Steps, not time.

  5. A scatter of 57 slider edits on three Livelit Playground slides: each edit's picture diff, the share of the picture's shapes it changed, against the update cost, as a share of a run from scratch, of a memo keyed by position on top of Hazel's own reuse; most dots lie below the diagonal, the eye sliders above it.A scatter of 57 slider edits on three Livelit Playground slides: each edit's picture diff, the share of the picture's shapes it changed, against the update cost, as a share of a run from scratch, of a memo keyed by position on top of Hazel's own reuse; most dots lie below the diagonal, the eye sliders above it.
    ^report(11) · 2026-10-13

    Every slider on three Livelit Playground slides, 63 edits: what Hazel's own reuse saves (on Full Body and Witch, little: the figure is one binding), what a memo keyed by names could save on top, and how that depends on the names. After Fumola's merge sort, four ways to name a call: by position, by caller, by operand and by content. They agree on edits that only change values; on edits that change which calls are made, naming a call for its operand comes closest to content. And the picture's own representation sets its diff: with groups and transforms instead of a flat list of shapes, every slider edit would change a handful of nodes. Preliminary: steps, with every check free.

  6. Two pictures from the Livelit Playground after a smile slider edit, Full Body and the witch, with the shapes the edit changed in colour and the rest in grey: 8 of 49 shapes and 16 of 133, the face, the hair and, on the witch, the hat.Two pictures from the Livelit Playground after a smile slider edit, Full Body and the witch, with the shapes the edit changed in colour and the rest in grey: 8 of 49 shapes and 16 of 133, the face, the hair and, on the witch, the hat.
    ^report(10) · 2026-10-13

    Which parts of the picture does a slider change, and can Hazel tell them from the rest? On three Livelit Playground slides, a slider edit changes 5 to 16% of the picture's shapes, yet Hazel evaluates the whole picture again (^report(9)). Naming each call by position, as Incremental Computation with Names names allocations in its interpreter, and comparing its inputs with the run before: 75 to 84% of the steps Hazel takes today are in calls whose inputs did not change. Preliminary: steps, with every check free, not times.

  7. For a slider edit on Full Body, 336 parts are reused with fresh ids and with names alike. Derived names kept on the values, Merkle style, cost the woods 1.7 s against 1.5 s, and keep 33,846 of the picture's 33,847 names from run to run.For a slider edit on Full Body, 336 parts are reused with fresh ids and with names alike. Derived names kept on the values, Merkle style, cost the woods 1.7 s against 1.5 s, and keep 33,846 of the picture's 33,847 names from run to run.
    ^report(9) · 2026-10-13

    How do fresh ids relate to lack of reuse? The question of Incremental Computation with Names (OOPSLA 2015), asked of Hazel. The editor names the program's syntax; statics and the evaluator mint gensyms for the rest. Analyzing a slide again renames hundreds of nodes, and naming two sites in statics cuts that by seven eighths. But the reuse check falls back to comparing structure, so the same parts are reused either way: in Hazel today, fresh ids cost time, in the check, in the 461,422 ids one woods evaluation mints, and in what crosses to the worker, not reuse.

  8. A bar chart of one slider change on the Full Body slide: #2597, a long red bar, 8.6 seconds; #2635 showing only the GUI, 3.5 seconds; the same with the reuse check's fix, a short green bar, 1.26 seconds; #2635 with the whole program, 1.23 seconds. Below: the worker's reuse check, GUI only, about 2 seconds to 1 to 4 milliseconds, with lexemes compared in the same walk as structure.A bar chart of one slider change on the Full Body slide: #2597, a long red bar, 8.6 seconds; #2635 showing only the GUI, 3.5 seconds; the same with the reuse check's fix, a short green bar, 1.26 seconds; #2635 with the whole program, 1.23 seconds. Below: the worker's reuse check, GUI only, about 2 seconds to 1 to 4 milliseconds, with lexemes compared in the same walk as structure.
    ^report(8) · 2026-10-12

    One slider change on the Livelit Playground's Full Body slide, three ways. On #2597, without modular editing, it takes 8.6 s, mostly statics on the whole program. On #2635, with it, 1.2 s. Showing only the slider and the picture should be faster still, but it took 3.5 s: opening them as cells gives the picture new ids on every change, and the worker's reuse check then compared lexemes in a walk that was quadratic over the program's lets. Comparing them in the same memoized walk as structure, now on #2635, brings it to 1.26 s, as fast as the whole program. Updated later on 2026-10-14: measured again with the same fixes in both, showing only the GUI was slower again, by the autosave, whose print of a slide with probes had become quadratic in its top-level items; with that fixed (about 1 s to 80 ms a change), the two take the same time, as ^report(15) found again on a newer head on 2026-10-15.

  9. A bar chart of the Scenes slide's first picture, cold: #2597, a long red bar, 287.5 seconds; the same with one commit, a short green bar, 17.4 seconds; #2635, 12.5 seconds. Below: worker messages the page redrew for while loading, 2,520 down to 86, from one commit from #2469.A bar chart of the Scenes slide's first picture, cold: #2597, a long red bar, 287.5 seconds; the same with one commit, a short green bar, 17.4 seconds; #2635, 12.5 seconds. Below: worker messages the page redrew for while loading, 2,520 down to 86, from one commit from #2469.
    ^report(7) · 2026-10-12

    On #2597, the full livelits branch, the Livelit Playground's Scenes slide took about five minutes to draw its first picture. It really does, 287.5 s against 12.5 s on #2635, and the reason is that the page redraws everything for every message from the worker. #2597's worker sends one after every slice of its evaluation: 2,520 while the slide loads, each costing the page about 140 ms, mostly rebuilding the code editor's view of the whole program. The worker sits idle while the page catches up. One commit from #2469, which sends at most one message every 100 ms, brings the first picture to about 17 s on a copy of #2597.

  10. Two bar charts. A time: five runs of the full test job, bars of uneven height, 1,110 to 1,453 seconds. A count: evaluator steps, five equal bars, 206,834 every time. Below: only a count can fail a build.Two bar charts. A time: five runs of the full test job, bars of uneven height, 1,110 to 1,453 seconds. A count: evaluator steps, five equal bars, 206,834 every time. Below: only a count can fail a build.
    ^report(6) · 2026-10-12

    ^report(1)–^report(5) found and fixed performance problems by hand. Nothing in Hazel's CI stops them coming back: no check is required to merge, and the two benchmark workflows never worked on dev. A time can't fail a build on GitHub's runners, where the same tests vary by about 10% from run to run; a count of the work done can. The first such check counts the evaluator's steps and holds them to budgets, and it fails when reuse breaks. Also: building on an earlier benchmarking PR, or starting fresh, compared in a table.

  11. A diagram in two lanes. On the main thread: a request carrying only what is new, and each changed view turned into Html, virtual DOM and DOM, whole. On the eval worker: evaluation that reuses by hash and answers with only what is new. Below: no DOM in a worker, only data crosses.A diagram in two lanes. On the main thread: a request carrying only what is new, and each changed view turned into Html, virtual DOM and DOM, whole. On the eval worker: evaluation that reuses by hash and answers with only what is new. Below: no DOM in a worker, only data crosses.
    ^report(5) · 2026-10-12

    Hazel evaluates a program in a web worker and draws it on the page's main thread. Each fix so far, sorted by thread, and compared with a more principled idea: the worker diffs each view and sends the page only patches. A worker cannot build real DOM, only data, and Hazel's Html value is already that data. What crosses between the threads already carries only what changed. What is still done whole on every change is the page's: evaluating each changed view, converting it to virtual DOM, and diffing it. Updated the same day: measured in the woods, evaluating the view is about 69% of the page's time, and converting and diffing about 2%. So the next step is a cheaper view.

  12. Two witches from the Hazel Witch slide: one in purple with a tuxedo cat on her shoulder and a broom, one in black with white webs, red boots and a ginger cat.
    ^report(4) · 2026-10-11

    M's witch is now a Livelit Playground slide, with her colors, her nose, her cat and her broom, and her woods and three more of the game's backgrounds are a Scenes slide. A witch redraw takes 5–7.5 s. One in the woods took 10–21 s, even when only the figure moved, against 1–3 ms in the game. Three causes were found and fixed. The eval worker sat idle more than half the time; a recursive helper's name leaking out of its let stopped anything from being reused; and the view's call memo cost more than it saved. A woods redraw now takes 2.6–6.4 s. Updated 2026-10-12: the picture's view made cheaper, a figure move in the woods redraws in a median of 1.41 s.

  13. A green witch with a hooked nose, a tuxedo cat on her shoulder and a broom, on a beach with palms, umbrellas and sand castles.
    ^report(3) · 2026-10-11

    My daughter and son asked for features all afternoon, and each went live in the game as soon as it was ready: claws and star points to 100, longer arms, backgrounds, Daniel Tiger's stripes, a witch, a wolf and a deer. The hands and the mouth have already reached the Hazel slides, where one alien hand with 99 fingers takes 32 s to redraw, against 5 ms in the game.

  14. Pictures from the Livelit Playground slides: a whole figure, a torso with hands, hair, pants and shoes, and two heads grown wide for big eyes.
    ^report(2) · 2026-10-11

    With slider releases down to about 0.13 s, the playground slides could carry more. Each feature was tried first in a one-file HTML/JS game, by its players, then formulated in Hazel: hair on the head, a neck, arms with a shirt that is a system of polygons, hands, eyes and a mouth, and two outline modes for building and for playing. What each cost, and why the heaviest slide now spends its time printing points.

  15. An animated timeline of one slider release: the main thread's stages, the eval worker, and the redraw, against Dress Up's under-a-millisecond update.An animated timeline of one slider release: the main thread's stages, the eval worker, and the redraw, against Dress Up's under-a-millisecond update.
    ^report(1) · 2026-10-10

    A livelit slider release took 4.6 s in the afternoon of 2026-10-09 and takes about 0.4 s now, against under a millisecond in a plain HTML/JS app. Every performance problem found this fall, its fix, where the remaining 0.4 s goes, and what it would take to get to "immediate".