zgx

Diagnose a slow interaction

Find what runs on each frame of a stutter, which script line made it, and what to write instead.

A scroll, a keystroke or a streamed reply that stutters: find what runs on each of its frames and which script line made it, then fix that line.

  1. Run a release build with devtools: cargo run --release -p zgx-run -- --inspect app/ (a debug build’s times are several times a release build’s, so compare like with like). An app already running with devtools needs no restart.

  2. Do the interaction a few times, through devtools so it is the same each time: zgx inspect scroll '#feed' 400 (or click, type, key), or by hand.

  3. Read what ran: zgx inspect frames 5. Each frame gives its time, the states written, where the time went by phase, and its runs, the costliest first:

    frame 212 (19.80 ms): state.luau::rows, 3 unnamed → 41 runs, 9840 µs
      phases: update 10120 µs (52310K instr), layout 6400 µs (30120K instr), text 1900 µs, paint 820 µs, present 560 µs
      6120 µs @58 binding (column components/row.luau:14) ← state.luau::rows
      2210 µs app.luau:88 effect ← state.luau::rows
      …
  4. The costliest phase says where to look: update and script are the runs below it; layout, text, paint and encode are the interface’s size and drawing (see the table). Map each costly run to its source line (components/row.luau:14, app.luau:88), then look at what that line reads and does. zgx inspect node @58 shows the node, and zgx inspect lint lists what the linter already knows is costly.

  5. Change it, save (the app reloads), and repeat steps 2–3 until the frame fits: 8.3 ms at 120 Hz, 16.7 ms at 60.

What the runs tell you:

You seeIt usually isDo instead
many binding runs of a list’s rows on every writerows reading the whole list (list:get()[i]) in their functionsui.list { data = state, key, row = function(item, i, row) … }: a row reads its own item
a binding run costing milliseconds, on a node holding thousands of childrenui.each over a long list: every row built and laid outui.list, which builds the rows in view
the same effect line in every frame, with no write you madean effect writing state that it (or another) reads, or ui.every faster than it needswrite only when the value changes; a slower timer; ui.tween/ui.spring for motion
a binding run for a whole long text or list after a small changeitems:update(function(l) … end) or set of a rebuilt tablepush, set_key, append, patch: they write the path that changed
few or cheap runs, yet layout, text or paint is most of the framethe cost is the interface’s size or drawing, not the scripta box laid out from its content (no w/h), a long text re-measured, a blur or shadow redrawn; zgx inspect tree --depth 2 shows which boxes are large or deep enough to cost
a slow frame with no writes and no runsa scroll or an animation laying out or drawing a lotthe same as above: the phases say which
Edit on GitHub