Hazel performance analysis · ^report(5) · 2026-10-12

Two threads, one picture: the worker, the page, and diffing the view

Hazel evaluates a program in a web worker and draws it on the page's main thread. A question from a meeting with Cyrus: how do our performance fixes so far compare with a more principled approach, where the worker builds a virtual DOM of each view, diffs it against the last one, and sends the page only the patches? Did we already do some of this, and how far does the idea go? Can a worker even build real DOM nodes? And is it expensive to keep several representations of the same DOM?

Short answers.

  1. A worker cannot build real DOM nodes. It has no document, and a DOM node cannot be posted from one thread to another. A worker can only build data, such as a virtual tree or a list of patches, for the main thread to apply.
  2. Hazel's worker builds no views today. It evaluates the program and sends back values. The page evaluates each livelit's view and turns the resulting Html value into a virtual DOM tree. The virtual DOM library diffs that tree against the last one and patches the page.
  3. What crosses between the threads is already incremental. Each side sends only the parts of a value the other side does not have yet, and a hash for the rest. This is a structural diff in all but name, and it cut a Kids' Choice release's messages from about 400 KB to about 16 KB.
  4. What is still done whole, on every change, is on the page: evaluating a changed view, converting all of its Html to virtual DOM, and the diff, which walks all of it. The principled version of the idea is to skip unchanged parts by their identity or hash, wherever the diff runs. On the page, that needs no new protocol, and it is the cheapest next step. Moving views into the worker would also take their evaluation off the main thread, but it would put a round trip between an interaction and its picture.

Update, later on 2026-10-12: measured, and C is not the next step. A profile of a figure move in the woods shows what the page does with its time. Converting Html to virtual DOM is 1.1% of it, and the diff and patch 0.9%. So C would save at most about 2%, while evaluating the view is about 69%. The next step is making the view cheaper, and moving it to the worker (B) after that. The numbers.

Who does what today

The main thread is the page: it owns the DOM, takes the user's input, and keeps the editor running smoothly. The worker evaluates the program, so that a long evaluation does not freeze the page. Here is one change, such as a slider's release, in order. The links are to the code on #2635 (livelits × modular editors) at b18cef1f5a.

MAIN THREAD (the page) EVAL WORKER postMessage: Marshal strings, shared by hash 1 edit orrelease 2 statics;request: onlywhat is new 5 decode,apply 6 evaluate eachchanged view→ Html value 7 Html → vdom 8 diff, patch 3 decode 4 evaluate,reusing by hash;answer: only new red: done whole for every changed view
One change, in eight steps. The worker's steps (green) and the boundary already work only on what changed. Steps 6–8 (red) are done whole for every view that changed, all on the main thread.

Added later on 2026-10-13: the boundary does. The worker's reuse is per top-level binding: a binding that uses the edited value is evaluated again whole. After a slider edit on the Playground's figure slides, the worker's update cost is about nine tenths of a run from scratch, though the edit changes a few of the picture's shapes (^report(10); ^report(11) for every slider).

  1. The page takes the edit, or a livelit's update, and runs statics, per item, on the main thread.
  2. The page builds a request: the elaborated program and what the worker needs of statics. Before posting it, it replaces each part the worker already holds with that part's hash (ShareCodec; hashes from ValueHash, a Merkle hash kept per object). Then it turns the request into a string with OCaml's Marshal and posts that.
  3. The worker reads the string and puts back the parts it kept.
  4. The worker evaluates, reusing its own cache of earlier results wherever an expression is unchanged, which it recognizes by hash. It sends back streams of partial results and then the result, trimmed and shared the same way: the page gets only what it does not have.
  5. The page reads the answer. Unchanged parts arrive as the very objects the page already holds.
  6. The page evaluates each livelit's view, if its definition or model changed: Hazel code, run by the page's own copy of the evaluator, giving an Html value (LivelitProj: memo_view, eval_view, live_html).
  7. The page converts the Html value to a virtual DOM tree (HazelDOM.go), whole, each time the view is drawn. Event handlers become OCaml functions here.
  8. The virtual DOM library (Jane Street's virtual_dom) diffs the new tree against the last one and patches the real DOM. Its walk skips a subtree only when the old and new are the same object. Below that, it compares tag, key and properties, node by node. Then the browser lays out and paints.

Can a worker build real DOM nodes?

No. A web worker runs JavaScript without a page. Its global scope has no window and no document, so there is nothing to create an element with and nowhere to attach one. Messages between threads carry only data: strings, numbers, plain objects and arrays, and a few kinds of buffer. A DOM node is none of those.

Hazel's code is built on this. The worker's program links only the language library (src/web/dune), and even calls setTimeout through the global object, since it has no window (WorkerServer.re). HazelDOM, which turns Html into virtual DOM, is in haz3lcore and is not in the worker at all.

A worker could build virtual DOM: the library is plain OCaml data, and the worker already links its types. But it could not send that tree to the page. A virtual DOM node holds its event handlers as functions, and a function cannot cross from the worker's program to the page's. What can cross is a description of the DOM whose handlers are data. Hazel has one already: an Html value is exactly that, a Hazel value whose handlers are Hazel expressions. So a worker-side diff would diff Html values and send patches to Html, and the page would still build the virtual DOM, or the DOM, from them.

Each fix so far, by thread

The fixes from ^report(1)–^report(4) and #2609, grouped by where they act. Commits are on #2635; most also have a dev PR or a port on #2597.

WhereFixWhat it does
Workere0a5b16555The incremental cache stays in the worker, instead of crossing in every request and back in every answer: an answer of 332 KB became 0.9 KB.
Worker5237161017, 97c910f69c (dev: #2673)An unchanged expression is recognized by its Merkle hash, not by comparing it whole: about 410 → 145 ms per release.
Worker9129cd2774Reusing a result copies cache entries without walking function bodies: the worker's evaluation 47 → 25 ms.
Worker25e515536dEvaluation slices run back to back for up to 30 ms, not one per timer tick: the worker's time per woods change, 10.0–10.7 → 2.1–2.6 s.
Worker (found in statics)4b964e6660 (dev: #2675)A recursive let's own names are not free in it, so the code around it is reused.
Worker, for the pagea05f8e5791 (from #2469)Streams of partial results come at most every 100–1000 ms, by program size, so they do not swamp the page: opening a 2,000-item program, the page's busy time 46.7 → 10.2 s. Correction, later on 2026-10-12: this commit made the interval grow with the program. The throttle itself came a day before, in c07bdacead, also from #2469: stream posts at most every 100 ms, and the page's projection of the program made lazy, so a streamed result no longer re-projects it (opening a 1,000-item program, 45.5 → 5.4 s). #2597 has neither; ^report(7) measures c07bdacead there.
Boundary993e3d5f91A request carries only what the worker does not have: 478 → 18.5 KB.
Boundary90994d4443An answer carries only what the page does not have: 393 → 16 KB. The worker's encoding 44 → 5 ms, the page's decoding 14 → 1 ms. Unchanged parts arrive as objects the page already holds, so its caches keyed by identity hit.
Boundaryb481a876e1A release's answers reach the page in two actions, not four, so it redraws less often.
Boundary6b7d53b3e8 (dev: #2652)The page starts the worker at load, and does not time out an answer that is queued.
Main thread, views4239ecf04f, c64c558e86, 126f595ce9A view is evaluated again only when its definition or model changed. Inside it, calls whose inputs did not change are reused, and a function whose calls never repeat stops being remembered.
Added later on 2026-10-13: calls of at least 40 steps, keyed by their content (^report(11)), at most 8 per function, and given up on after 24 misses per hit (^report(4)).
Main thread, viewsdd892bc331 (dev: #2661), efaaee244f, d706d4d1ecThe evaluator runs views faster: types are compared by hash, the recursive Html type is unrolled once, and views are not run to the very end. These help the worker too, since it runs the same evaluator.
Main thread, redraws51df91d5d7, cefa5c2c5c (dev: #2671)Redraws that show nothing new reuse what they drew, and editing keeps unchanged pieces of the program as the same objects.
Main thread, statics#2469; 8fbc86c644Statics per item, and the worker's share of it updated rather than rebuilt per change, which also leaves fewer new objects to hash.
Main thread, scrolling47f7f48397 (dev: #2672)Measuring the font no longer scrolls the editor to the caret, which had swallowed a click.

The pattern: the worker and the boundary have had the most work, and now handle only what changed. The page's own work after the answer, steps 6–8, has had memos added in front of it, but each step that runs still runs whole.

Added later on 2026-10-13: the worker, per top-level binding (see the note above, and ^report(10)).

Where a diff could go

Diffing two trees exactly is expensive in general: the best algorithms for the smallest edit between two trees take time cubic in their size. Virtual DOM libraries settle for something linear. They walk both trees together, keep a node when its tag and key match, and replace it otherwise. Hazel can do better than linear on the parts that did not change, because its values carry Merkle hashes and keep their identity when unchanged. An unchanged subtree can be recognized in one step and skipped. A diff then costs about the size of what changed, plus the path down to it, not the size of the tree. That is the principled version, and there are three places it could go.

Corrected later on 2026-10-13: values do not carry their hashes: ValueHash computes a value's hash when asked and keeps it in a side table (^report(9)). And a value evaluated again is a new object, with fresh ids: equal in structure, not in identity. Only what the worker reuses, or the boundary shares, keeps its identity. Keeping a name on each value, so that an unchanged part could be recognized in one step, is ^report(11)'s design question.

A. At the boundary (done)B. In the worker: views, diffed thereC. On the page: skip what is the same
What changesMessages carry new parts only, with hashes for the rest (ShareCodec).The worker evaluates each livelit's view, keeps the last Html it sent for it, and posts patches to that Html.Converting Html to virtual DOM is remembered per Html subterm, by identity, so an unchanged part gives back the same virtual DOM node.
Main-thread work savedDecoding: 14 → 1 ms per answer.Evaluating views (in the woods, about half the page's time), converting them, and the diff below unchanged parts.Converting unchanged parts, and the diff below them, since the library skips an identical node at once.
What it needs—The view runner (ViewCmdRunner) in the worker; a patch format; on the page, an element that owns each livelit's DOM and applies patches (a virtual DOM "widget": none today); handlers dispatched by id.A memo keyed by Html identity, and handlers that do not close over the model, so that a part stays the same when only the model changed; keys for children, so that one insertion does not shift its siblings.
RisksThe two sides' tables can drift (handled: the page asks again, and both start over).An interaction redraws today on the page at once, from the page's own evaluation of the view. A view in the worker puts a round trip between the drag and the picture, unless the page keeps its own path for that, which means the view logic runs in two places.Memory for the memo; handlers that capture the model would defeat it.

So far we have done A, and part of what B would bring: the worker already does the evaluation that is not a view. C is the cheapest next step. It needs no protocol, and the boundary already keeps unchanged objects the same (90994d4443), which is what C keys on. B is the bigger change, and it is worth it only if evaluating views stays the page's largest cost after C. It probably will in the woods: there, the picture's view evaluates about 600 shapes on every change.

Update, later on 2026-10-12: it does, by far. C would save at most about 2% in the woods (below).

Is it expensive to keep several representations?

A view exists in up to five forms at once: the Html value (in the view memo); in the worker's and the page's tables of shared values; the virtual DOM tree the page last drew, kept for the next diff; the new virtual DOM tree, during a diff; and the DOM itself.

In memory, sharing makes most of these cheap. The Html value and the tables hold references to the same objects, not copies: the boundary returns the page's own objects for anything unchanged. The two virtual DOM trees exist together only during a diff. The DOM is the only full copy that cannot be avoided. What memory these take has not been measured. The tables are capped at 200,000 entries each.

Update, later on 2026-10-14: measured on #2597, in ^report(14): a tab keeps about 92 MB more after every slider edit on Full Body, but not in these tables, which stay within their cap; what stays is whole versions of the editor's state.

In time, the cost is rebuilding, not holding. Today, steps 7 and 8 rebuild a view's virtual DOM and walk all of it whenever the view is drawn. That is a cost per representation per change, and C is what removes it. On Kids' Choice it is small: converting the face's Html to the page took 8 ms of a 104 ms redraw (profiled, ^report(1)), and in an earlier profile, the browser's own layout and DOM work was about 30 ms of a 310 ms redraw. In the woods, with about 600 shapes, the conversion and the diff are part of the half of the page's time that is not evaluating the view (^report(4)). How large a part has not been measured.

Update, later on 2026-10-12: measured: about 2% (below).

Update, later on 2026-10-12: the woods' redraw, split

A Gecko profile of two figure-size changes in the woods, on #2635 at b18cef1f5a. The runs took 4.4 and 4.1 s under the profiler, which makes code with many small calls look slower than it is, so read the shares, not the times. Each sample of the page's main thread is counted once, under the innermost of these that it was in:

The page's main thread, during the two changesShare
Evaluating the view (eval_view, evaluate_open)50%
Garbage collection, most of it during that evaluation21%
The app's own update machinery: frame callbacks, recomputing its views25%
Converting Html to virtual DOM (HazelDOM)1.1%
The virtual DOM diff and patch0.9%
The browser's style, layout and paint1.4%

Counting the garbage collection it causes, evaluating the view is about 69% of the time. C can save only the two small rows, about 2%. It would also be harder than above assumed. The view's call memo misses on the woods' ~600 shapes, so each shape's Html is a new object on every change, even when nothing in it changed. A memo would need a content hash as its key, not identity. And each event handler holds the state it was drawn with, so a remembered node would need handlers that look the state up when the event fires.

So the order changes: first make the view cheaper to evaluate, per shape and in the evaluator's general overhead (done the same day, in three changes: the woods' redraw from a median of 2.20 s to 1.41 s, ^report(4)'s update). Then consider moving it off the main thread (B), weighing the round trip it would add to each interaction. C waits. On Kids' Choice the conversion was 8 of 104 ms (^report(1)), a larger share but a small time.

What to measure next

How this was put together