Measure UI Performance
Record a View in Studio or Unreal before deciding what to optimize.
Record your View
- Open Tests in Studio and choose Profiler.
- Select Studio Preview or Unreal. Use a connected Unreal target to measure the running game.
- Open the View and reproduce the interaction you want to measure.
- Click Record 5s.
- Compare another recording after your change, using the same target, resolution, and interaction.
A value shown as n/a was not supplied by that target. It is not a zero and
is not an estimated measurement. Studio Preview and Unreal measure different
renderers, so do not compare their numbers as if they were the same test.
The Profiler's Reload cost section and footer pill show how often an update falls back to a full document reload instead of patching in place, with the exact reason when it just happened. See Reload cost.
Measure the first cold opening separately from a repeated View switch. Studio reuses unchanged compiled component scripts, and Preview avoids reloading or laying out an unchanged document. A cold compiler result and an interaction-time layout result answer different performance questions. Repeated generated stylesheets reuse only their immutable parsed rule trees; selector matching, computed styles, and media-query state are still isolated per document.
For a critical first menu, load the package before opening the View. The Unreal runtime prewarms reusable compiled presentation state during the load; initial Store writes and action listeners can also complete before the entrance. Still measure the first visible frame in the packaged game because host assets, scene composition, and the target GPU remain outside that preparation.
For a real Unreal interaction, use a Development build and reproduce it through
the normal mouse, keyboard, or gamepad input path. The Unreal log records
the input queue, dispatch, layout, paint-build, submit, first-frame-submit, and
animation-settled timings for the input sequence. Correlate the reported
CalmUI.InputFrame.<sequence> event in Unreal Insights to include render-thread,
GPU, and presentation timing; the game-thread submit timestamp by itself is not
proof that the frame reached the display.
A View declared with KeepAlive may reuse its prepared presentation when the
same package and rendered HTML/CSS are still valid. A View declared with
Destroy is intentionally a cold open, so record and budget it separately.
Retained screen budget
A hidden KeepAlive screen keeps its component View, script context, laid-out
document and cached paint frame so that reopening it is instant. That is not
free: measured on a project where 25 of 34 screens are KeepAlive, each screen
a player had opened held roughly 12 MB for the rest of the session, about 307 MB
across 21 screens, including thousands of paint commands and layout elements
that are never drawn.
Project Settings -> CalmUI -> Screens -> Retained keep-alive screen budget
caps how many hidden screens stay resident. The default is 8. Past the cap,
the least recently shown screen is evicted: it is not broken, only cold, and
reopens exactly like a Destroy screen: it loses the script-local state
KeepAlive would have kept, so raise the budget if a screen further back in the
player's history must remember its selected tab or scroll position. 0 disables
eviction and restores unbounded retention.
Retention never costs frame time on screens the player is not looking at. A
store patch is applied only to the screens that actually bind one of the changed
fields, so a hidden screen that reads none of them does no work at all: the
per-frame cost of a store flush is a function of what changed, not of how many
screens have ever been opened. ScriptCounters::StorePatchesSkipped counts the
patches a context ignored for exactly this reason.
Script event counters and memory diagnostics
Native hosts use ICalmUiScriptContext::EventCounters() for frame scheduling and
per-frame event statistics. It reads the same timer, watch and job counters as
Counters() without walking the JavaScript heap. Its QuickJsMemoryBytes field
contains the last diagnostic sample, initially zero.
Use Counters() when an explicit memory diagnostic is needed. It may traverse
the entire shared runtime heap. Ordinary job processing does not collect memory
statistics. Custom script-context implementations must implement EventCounters();
rebuild native consumers against the matching SDK headers and libraries.
In Unreal, periodic heap logging is disabled by default. Set
CalmUi.Debug.ScriptHeap 1 to opt in to one measurement per minute, and set it
back to 0 when finished. The measurement walks the shared heap and may cause
a diagnostic hitch; it does not change memory limits, allocation or garbage
collection behavior.
Rebuild only what changed, once per frame
A View's template reads its script state through a render observer: every
reactive state() or computed() a binding touched is remembered per
component instance, exactly like a render effect. When one of them changes, that
instance's ICalmUiScriptContext::InstanceStateVersion() and the context's
RenderStateVersion() move; nothing else does. So:
ApplyStorePatch()returnstrueonly when the patch reached state a template renders, andComponentView::NeedsSettle()says whether the View needs a compose at all. A host marks only those screens dirty after a Store flush instead of recomposing every visible screen.- A handler, timer, watch or promise job that writes nothing a template reads (a hover-intent timer being armed, an action dispatched to the game) causes no rebuild at all.
- With
ComponentView::SetDeferredEventSettle(true), event handlers still run immediately and in order, and their emissions still reach parent handlers immediately, but the rebuild waits for the host's next settle point, so thepointerleave+pointerenterof one crossed tile, or any number of events in one frame, cost one rebuild. - A rebuild reads each script binding at most once, and not at all for an
instance whose state version did not move. A component instance that takes no
slot content from its parent and whose props and subtree state are unchanged
replays its previous markup and bookkeeping instead of executing its template
(
ComponentRuntimeCounters::ComponentOutputsReused). The package stylesheet is built once per package, andComponentViewResult::HtmlRevision/CssRevisionlet a host skip comparing unchanged markup.
NeedsSettle() is also true while a timer or watch emission waits to reach its
listeners. State that is not reactive (a plain object or array changed in place,
a module-level variable) is not observed; keep rendered data in
state()/computed(). On the Calmaria Trade screen (118
component instances, 71 KB HTML, 465 KB CSS; Release, CalmUiIntegrationTests --trade-profile) an unchanged recompose went from 8.3 ms to 1.5 ms, a hover
frame from 26 ms (two full rebuilds) to about 2 ms, and a published 32-row grid
from 70 ms to about 22 ms. Set CALMUI_COMPONENT_OUTPUT_CACHE=0 to execute
every template again for A/B measurement.
Animated layout
A width or height keyframe (or any script timer that drives one, such as a
drawer opening) is a layout-affecting animation, not a paint-only one: every
frame it changes, Core re-runs layout for the affected box and Unreal applies
the new size in the same render callback (SettleAnimatedLayout), so a
mounting drawer never paints once at its natural size before catching up to
the animation.
Most of that cost is contained rather than eliminated. When the only change since the last successful layout is a size override on an out-of-flow node (the common case for a drawer, popover, or toast), the layout backend re-renders and re-places just that node's subtree through a dedicated LiteHTML entry point instead of the whole document; measured on a HUD-scale reference scene (286 addressable elements, 2844 paint commands, 1920x1080) this brought the median animated frame from 8.14 ms to 1.68 ms. Any change outside that shape (a document reload, a viewport or safe-area change, a scroll, or an override with no out-of-flow ancestor) still takes the full render.
A box in normal flow whose own geometry cannot depend on its content is the
same kind of boundary: a block or flex box with a definite width (a length or a
percentage), a definite length height and overflow other than visible, that
is not baseline-aligned, and that is itself positioned when it contains
out-of-flow descendants (a scrolled item grid, a tray of staged items). A text,
attribute, restyle or mount change inside it re-lays out that box alone, in
place, from the containing block its last render used; if the box comes out of
that with different geometry the full render runs instead. Every step is held
to a fresh layout of the same markup in CalmUiIntegrationTests. Set
CALMUI_LAYOUT_CONTAINED=0 to disable this root for A/B measurement.
The first frame a text style is laid out also rasterizes that style's glyphs
for digits, signs and Polish letters (TextSystem::kDynamicGlyphCharacters,
contextual digit forms included), while the glyph atlas still has a free page,
so a counter showing a new digit later draws without rasterizing.
CALMUI_GLYPH_PREWARM=0 disables it. Set
CALMUI_LAYOUT_SUBTREE=0 to force the full-document path on every frame and
compare against the fast path; use CALMUI_LAYOUT_MEMO=0 to additionally
disable LiteHTML's own scalar-measurement memoization. Both are process
environment variables read once at startup, meant for A/B measurement, not for
shipping configuration.
Store updates that only change text
A store field bound into rendered text (a HUD counter, a wave timer) makes the
host reload the document with new HTML. When that HTML differs from the loaded
document only in text-node contents, the element sequence, every tag and every
attribute byte-identical, which is what the component compiler emits for a
value-only change, Core patches the loaded document in place instead of
re-parsing and restyling it. The changed nodes are then marked dirty the same way
an animated size override is: text inside an out-of-flow island re-renders and
re-places that island alone (DocumentRenderCount stays 0), text in normal
flow still takes one full document render but no parse and no style pass.
Anything else falls back to the ordinary full load: a structure or CSS change,
and also a text change that adds or removes a word, because that changes how
LiteHTML splits the run into text nodes.
LayoutResult::IncrementalPatchCount counts the patches a document has applied,
so a host can log whether its updates are taking the cheap path. Measured on a
HUD-scale reference scene (412 elements, 1226 paint commands, 1920x1080), a
text-only update went from 4.84 ms reload + 4.01 ms layout to 0.05 ms reload +
2.41 ms layout. Set CALMUI_DOC_PATCH=0 to force every reload back through the
full parse/style/render path for A/B measurement.
Store updates that change attribute values
The same store tick usually moves attribute values too: a progress bar
re-renders as style="width:57%" where it was style="width:42%", a meter
follows aria-valuenow, a state chip swaps a class. Core patches those in place
as well. Attributes are matched by name: a tag may change attribute values,
gain an attribute or lose one (the compiler omits a bound :disabled,
:aria-hidden or empty :class whose value is false, so a toggle looks like
an attribute appearing or vanishing) as long as the tag name and the identity
attributes are byte-identical; the new values are written into the loaded
elements, removed attributes are erased from them, and the
document stylesheets are re-applied to the changed element's subtree only. Its
parent's subtree is restyled only when a selector can observe the change on a
sibling: the changed class or attribute appears in a compound directly left of
+ or ~, or inside :nth-child(An+B of S); a class toggled on one grid slot
no longer restyles every other slot. CALMUI_RESTYLE_SIBLINGS=1 restores the
parent-subtree restyle for every selector-visible change (A/B). Values the HTML
parser rewrites (an invalid UTF-8 byte or a control character becomes U+FFFD in
the tree) are compared through the same rewrite, so one mis-encoded Store string
no longer disables patching for the whole document. Element metadata (class lists,
selector attributes, accessible and range values) is collected again, so
interactive styling and the Unreal element cache re-key off the new values.
These attributes are never patched and always take the full load: id,
data-cui-node, data-cui-scope, data-cui-virtual-index and data-testid
(node identity, style scope and the virtualization window key off them), src,
srcset and href (a resource load and an intrinsic size, not a restyle), and
any value containing -- (a custom property moves the retained animation
property set). A document whose markup carries its own <style>, <link>,
<script> or <base>, or whose CSS uses :has() or CSS counters, never takes
the attribute path at all. LayoutResult::AttributePatchCount counts the
patched elements additively next to IncrementalPatchCount, and
CALMUI_DOC_PATCH=0 disables both patches. Measured on a HUD-scale reference
scene (150 elements, 48 progress bars, 1920x1080), a progress-fill update went
from 2.06 ms reload + 0.59 ms layout to 0.27 ms reload + 0.44 ms layout.
Store updates that mount or unmount a subtree
A ui:if drawer opening changes the element sequence itself, which neither
patch above accepts. Core recognises the case where the only structural
difference is one contiguous range of complete elements inserted under, or
removed from, a single parent, and splices that range into the loaded document:
the new markup is parsed as a fragment through the same parser a full load uses,
the parent's subtree is restyled, and the parent's render subtree is rebuilt
through the same render-item construction a full load runs, for that one parent,
without a parse and without a whole-document cascade. The two markup versions
are aligned by element identity: the tag name plus the compiler's
data-cui-node and the other identity attributes above (for markup without
data-cui-node, the ordered attribute names too), so the same update may carry
the mount together with the text and attribute patches of everything around it,
including a bound :disabled or :aria-* appearing or disappearing elsewhere. Compiler output is
indented, so every element boundary is a whitespace text node and the text in
front of a mounted subtree is not the text in front of its parent's close tag;
that boundary text travels with the splice rather than refusing it. The change is then handed
to the dirty-subtree relayout, so a mount inside an out-of-flow island issues no
document render at all and a mount in normal flow issues exactly one, and neither
re-parses the document. One update may move more than one subtree: two drawers
whose close timers land in the same tick, or a toast mounting while a drawer
unmounts, even when the token counts happen to match: each range is spliced on
its own, over markup that applies exactly that range, so every step keeps the
same verification. Anything else takes the ordinary full load: a reordering (the
same identities arriving somewhere else, which must keep their elements rather
than be rebuilt from markup), a changed node identity, inserted markup carrying
its own <style>, <script> or a table, and a stylesheet using :empty. LayoutResult::StructuralPatchCount counts these updates additively
next to IncrementalPatchCount and AttributePatchCount, and CALMUI_DOC_PATCH=0
disables them with the other patches. Measured on a Calmaria-scale reference
scene (307 elements, 33 KB HTML, 249 KB CSS over ~4 300 rules, a 17-element
drawer with flex rows, progress fills and icons), mounting the drawer went from
22.5 ms to 5.4 ms and unmounting it from 20.7 ms to 3.2 ms. On the same scene in
compiler-shaped markup (a doctype, html/head/body, a scoped <main>,
newline-indented children and inline animation-delay styles: 241 elements,
22.6 KB HTML, 219 KB CSS), a mount went from 11.6 ms to 4.1 ms and an unmount
from 11.1 ms to 2.8 ms.
LayoutResult::LastPatchRefusal names the guard that refused the most recent
update, so a host can see why it took the full load instead of guessing:
structure: elements outside the range are not the same elements,
attribute: identity changed [data-cui-node on <div id=hud data-testid=hud-root>]
(attribute refusals name the element), text: token count differs, or
document: not a markup-only reload [css text changed], which names the
condition that disqualified the reload. It is empty while the last update was
patched in place, and is diagnostic only. Studio surfaces the same reason as
the Profiler's full-reload banner and footer pill; see Reload
cost for the
per-reason authoring fix.
The cost of a full load
A load that cannot be patched (the first load of a screen, a CSS reload, or any
update the rules above refuse) used to test every selector of every stylesheet
against every element, which is linear in rule count times element count and is
the dominant cost of a design-token stylesheet. Core indexes each loaded
stylesheet once by the rightmost compound of its selectors and offers every
element only the selectors whose rightmost compound that element carries: its
tag, its classes, its id, and the attribute names and exact attribute values it
holds, plus the universal and pseudo-only rules that are candidates everywhere.
The offered list is a strict superset of what can match and keeps the sheet's own
order, so the cascade, specificity and generated ::before/::after boxes are
identical. On the Calmaria-scale scene above a fresh load went from 29.1 ms to
20.6 ms; small documents and small stylesheets are unchanged. Set
CALMUI_STYLE_INDEX=0 to apply the stylesheets selector by selector again and
compare. The patch maps a later update needs are built by that same load, so the
first patched update after a screen opens costs what every later one costs
(0.43 ms against 0.37 ms on the same scene) instead of several milliseconds more.
To read what a given frame actually cost, enable Verbose logging for
LogCalmUI in Unreal and look for:
R4 layout prepared (animated): ... layout=<ms> prepare=<ms> litehtml=<ms> render=<ms> draw=<ms> collect=<ms> renders=<n>: one line per layout pass.layout/prepareare Unreal-side timings around the call;litehtmlis Core's ownLayoutMillisecondsfor that pass, broken down intorender(the litehtml box pass),draw(pre-draw preparation plus PaintFrame recording) andcollect(the element/interaction/ownership rebuild) when the stagedLayoutResultcarries those fields;rendersisDocumentRenderCount.R4 prepare phases: nodes=<n> commands=<n> resolves=<n> frame=<ms> styles=<ms> split=<ms> total=<ms>: the Unreal-side frame-copy, interactive style resolve, and paint-command split costs around that layout pass.resolvesis the count of interactive-style selector resolutions the per-node style-resolve cache performed; on an animated-size relayout that changes no pseudo-state this should read0.
Compare a recording with the kill switches on and off, and compare resolves
before and after a change that might have widened what the per-node style
cache invalidates.
Measured examples
The figures below are individual recordings from one project (Calmaria), kept here with the exact build, scene, viewport, date, and measurement method so they can be reproduced or superseded rather than taken on faith. They are not a benchmark suite and do not generalize to another project's scenes or hardware.
Studio save-to-preview with a warm script cache
4.3 s → 140 ms rebuild after a save, for a View with eight scripted
imports and nothing changed since the last build. Compiled component scripts
are cached under the system temporary directory, keyed by script text, source
path, configuration, the generated Store/Action contract and the pinned
JavaScript toolchain, and reused across builds instead of recompiled; an
isolated cold build of Calmaria JoinGame also fell from about 2.87 s to
1.57 s, with byte-identical cold and warm packages. Recorded 2026-09-12.
Static HUD idle cost
0.6 ms game thread. Calmaria GameplayHud, Development editor build,
Unreal Editor PIE, viewport 1280x722, recorded 2026-09-14. Measured with
calmui dev inspect --json against the idle HUD (no drawer open, no counter
changing).
Animated drawer relayout
1.15 ms median frame cost, 0 document renders. Same project, build,
and session as above (2026-09-14). Measured with calmui dev inspect --json
while the drawer's width/height keyframe animation ran; see Animated
layout for why a layout-affecting animation still avoids a
document render when it stays inside the dirty-subtree fast path.
Store-driven counter patches
0 document renders per HUD counter tick. Same project, build, and session
as above (2026-09-14). Measured against the R4AnimatedDrawer fixture and the
IncrementalPatchTests counters (LayoutResult::IncrementalPatchCount); see
Store updates that only change text.
Stylesheet warm-up
155 ms → 0.27 ms first-resolve warm-up on a 175 KB stylesheet. Calmaria,
recorded 2026-09-14. 155 ms is the cost of the first selector resolve against
that stylesheet before the per-stylesheet selector index is built; 0.27 ms is
the cost of the same resolve once the index exists. Measured with calmui dev inspect --json; see The cost of a full load.
Structural drawer mount
22.5 ms → 5.4 ms to mount a 17-element drawer (flex rows, progress fills
and icons) into a 307-element HUD (33 KB HTML, 249 KB CSS over ~4,300 rules);
unmounting the same drawer went from 20.7 ms to 3.2 ms. Native, measured with
the CalmUiLayoutTests "Structural drawer mount benchmark" on a Calmaria-scale
reference scene; see Store updates that mount or unmount a
subtree.
UI GPU cost with per-primitive scissoring
17.7 ms → 4.2 ms. Calmaria MainMenu, Development editor build, Unreal
Editor PIE (viewport not claimed as 1080p), recorded 2026-09-12. Measured with
Unreal's csvprofile around the UI GPU pass, comparing per-primitive
scissoring against no scissoring on the same scene.
Changes to investigate
- Prefer color, opacity, or a transform when changing a button's appearance.
- Keep repeated items keyed by their real IDs.
- Use fixed-height lists for large collections when their restrictions fit your design.
- Keep component scripts short; move gameplay work to Unreal.
- Inspect texture memory when testing large images or many icon variants.
- Bound icon-sized Unreal host images with
maximumDecodedExtentso the runtime can choose an appropriate cooked mip and retain a smaller RGBA resource. - Avoid stacking large full-screen backdrop blurs. Each blur needs additional render-target work on the real renderer.
Record the release candidate
Use the same resolution, package, Store data, input route, and camera scene for the before-and-after recording. Test the busiest overlay or modal, including its opening and closing animation. Then repeat the interaction in a packaged Development or Shipping game on the target machine.
A faster Studio recording does not establish Unreal performance. A faster local Unreal recording does not establish performance on another platform.