Skip to content
SiteEmail

A large DOM tree taxes every part of Gameface’s rendering pipeline: layout calculation, style matching, data-binding synchronization, and draw call preparation. Four strategies address this at different levels. Hiding techniques trade resting overhead for toggle cost - picking the wrong one for a given element compounds that cost across every frame the element is invisible. Deferred rendering keeps heavy DOM branches out of the layout tree until the player actually needs them. Node pooling eliminates garbage collection spikes from high-frequency element creation. Update throttling keeps the layout tree quiet by batching DOM mutations to intervals the player cannot perceive.

Controlling Visibility: Hiding vs. Removing

Section titled “Controlling Visibility: Hiding vs. Removing”

Every technique for hiding a UI element carries two costs: the overhead it imposes while the element is invisible, and the cost of making it visible again. These two costs trade against each other. Picking the right technique is a function of how often the element toggles and how expensive its resting overhead is.

TechniqueResting OverheadToggle Cost
opacity: 0 / visibility: hiddenHigh (layout + data-binding)Lowest (no reflow)
display: noneMedium (no layout, no draw)Medium (layout reflow on show)
remove() / data-bind-ifNoneHighest (DOM mutation + layout reflow)

opacity: 0 makes an element invisible but leaves it fully active in the layout tree. The engine still runs layout calculations for it on every frame, still synchronizes its data bindings, and still processes it in the style-matching pass. Only paint commands are skipped. visibility: hidden behaves identically from a layout and binding perspective: the element occupies its box, participates in flex distribution, and keeps its data bindings alive.

The advantage is toggle cost. Switching opacity from 0 to 1 triggers no layout reflow. The element was already in the tree; only its visibility state changes. The following shows a HUD widget that toggles frequently during gameplay, where the zero-reflow toggle cost justifies the resting overhead:

ObjectiveTracker.tsx
import Block from '@components/Layout/Block/Block';
import TextBlock from '@components/Basic/TextBlock/TextBlock';
const ObjectiveTracker = () => {
return (
<Block class="objective-tracker">
<TextBlock class="objective-tracker__label">Reach the extraction zone</TextBlock>
<Block class="objective-tracker__marker" />
</Block>
);
};
hud.css
/* ✅ Fast toggle - element stays in the layout tree, no reflow on show */
.objective-tracker {
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease-out;
}
.objective-tracker.is-visible {
opacity: 1;
pointer-events: auto;
}

The resting overhead is the trade-off. An opacity: 0 element with active data bindings still synchronizes all its bound properties on every frame. A complex panel kept permanently invisible but fully data-bound contributes layout cost and binding overhead for no visible output.

display: none detaches the element from the layout tree entirely. Yoga skips it during layout calculation. No draw commands are generated. The element still exists in the DOM and its data bindings stay registered, but the layout solve cost drops to zero.

The toggle cost is the trade-off. When the engine transitions an element from display: none to any visible display value, it reintegrates the element into the layout tree and runs a full layout solve over the affected subtree. The following compares the two approaches on a pause menu that is hidden for most of a gameplay session:

PauseMenu.tsx
import { createSignal } from 'solid-js';
import { Show } from 'solid-js';
import Block from '@components/Layout/Block/Block';
import TextBlock from '@components/Basic/TextBlock/TextBlock';
const PauseMenu = () => {
const [isOpen, setIsOpen] = createSignal(false);
return (
<Show when={isOpen()}>
<Block class="pause-menu">
<TextBlock class="pause-menu__header">Paused</TextBlock>
<Block class="pause-menu__options">
{/* option rows */}
</Block>
</Block>
</Show>
);
};
hud.css
/* ❌ Pause menu hidden with opacity: 0 - layout computed for hundreds of nodes every frame */
.pause-menu {
opacity: 0;
pointer-events: none;
}
/* ✅ Pause menu hidden with display: none - Yoga skips the entire subtree */
.pause-menu {
display: none;
}
.pause-menu.is-open {
display: flex; /* Full layout reflow on open - paid once, not every frame */
}

display: none suits elements that are hidden for large portions of a session: pause menus, inventory screens, character customization panels. The one-time reflow on open is acceptable when weighed against the frame-by-frame layout savings over the session.

Structural Removal: remove() and data-bind-if

Section titled “Structural Removal: remove() and data-bind-if”

Fully removing a node from the DOM eliminates every form of resting overhead. The element is absent from the layout tree, the style-matching pass, and the data-binding system. Nothing computes for it while it is detached.

The toggle cost is the highest of any option. Adding or removing a node from the DOM triggers a main-thread operation that must synchronize with the layout thread. When the node is re-added, the engine runs a full layout solve for the reattached subtree.

Gameface’s data-bind-if attribute applies this mechanism through the data-binding system. An element with data-bind-if is physically mounted or unmounted from the DOM as the bound value changes. The following shows a full inventory screen that should only exist in the layout tree when the player has it open:

InventoryScreen.tsx
import Block from '@components/Layout/Block/Block';
import TextBlock from '@components/Basic/TextBlock/TextBlock';
const InventoryScreen = () => {
return (
<Block class="inventory-screen" attr:data-bind-if="{{model.isInventoryOpen}}">
<TextBlock class="inventory-screen__header">Inventory</TextBlock>
<Block class="inventory-screen__grid">
{/* item slots */}
</Block>
</Block>
);
};

data-bind-if is the right choice for full-screen overlays, game-state-specific views (character select, post-match results), and any screen the player visits infrequently. The mount cost is paid once on open. Zero resting overhead is maintained for the entire time the view is closed.


A large DOM tree at startup has two costs: the time to parse and attach every node, and the first-frame layout solve over all of them. Both costs can be eliminated for content the player does not see at startup.

A skill tree, a full character loadout panel, or a crafting screen each contain hundreds or thousands of nodes. The engine creates their DOM nodes at startup, meaning the player pays the full creation and layout cost before ever opening those screens. JavaScript dynamic imports defer both costs: the component module and its DOM nodes are not created until the first time the player actually navigates to that screen.

App.tsx
import { lazy, Suspense, createSignal } from 'solid-js';
import Block from '@components/Layout/Block/Block';
// Lazy-loaded screens
const SettingsPanel = lazy(() => import('./SettingsPanel'));
const SkillTree = lazy(() => import('./SkillTree'));
const App = () => {
const [activeScreen, setActiveScreen] = createSignal<'settings' | 'skill-tree' | null>(null);
return (
<Block id="overlay-root">
<Suspense fallback={null}>
{activeScreen() === 'settings' && <SettingsPanel />}
{activeScreen() === 'skill-tree' && <SkillTree />}
</Suspense>
<button onClick={() => setActiveScreen('settings')}>Open Settings</button>
<button onClick={() => setActiveScreen('skill-tree')}>Open Skill Tree</button>
</Block>
);
};

Structure components so they can be mounted and unmounted cleanly without rebuilding their internal state from scratch on every open. Caching the component instance after its first creation eliminates the module-load cost on subsequent opens.

The <template> element holds HTML content that the engine parses without rendering into the active layout. Template content is never part of the layout tree, never receives style calculations, and never runs data bindings. Its content is available to clone into the document on demand.

Tooltip.tsx
import { createSignal } from 'solid-js';
import { Show } from 'solid-js';
import Block from '@components/Layout/Block/Block';
import TextBlock from '@components/Basic/TextBlock/TextBlock';
type TooltipData = { title: string; body: string } | null;
const [tooltip, setTooltip] = createSignal<TooltipData>(null);
const Tooltip = () => (
<Show when={tooltip()}>
{(data) => (
<Block class="tooltip">
<TextBlock class="tooltip__title">{data().title}</TextBlock>
<TextBlock class="tooltip__body">{data().body}</TextBlock>
</Block>
)}
</Show>
);
// To show: setTooltip({ title: 'Fire Sword', body: '+25 damage' })
// To hide: setTooltip(null)

The benefit is at startup: the engine skips layout for all template content on the first frame. The trade-off is that each insertion of a cloned node triggers a layout reflow for that subtree, paid at the moment of insertion rather than at startup.


Damage numbers, kill feed entries, status effect icons, and waypoint markers share a pattern: they appear, animate briefly, and disappear. Implementing that cycle with document.createElement() and element.remove() on every occurrence forces the JavaScript garbage collector to reclaim the discarded nodes. A GC pause during an intense combat exchange is exactly when the player least tolerates a frame stall.

A node pool eliminates this by allocating a fixed set of nodes at startup and reusing them:

DamageNumbers.tsx
import { createSignal, For } from 'solid-js';
import Absolute from '@components/Layout/Absolute/Absolute';
import TextBlock from '@components/Basic/TextBlock/TextBlock';
const POOL_SIZE = 20;
type DamageEntry = { id: number; value: number; x: number; y: number; active: boolean };
const [pool, setPool] = createSignal<DamageEntry[]>(
Array.from({ length: POOL_SIZE }, (_, i) => ({ id: i, value: 0, x: 0, y: 0, active: false }))
);
let poolIndex = 0;
function showDamageNumber(value: number, x: number, y: number) {
const idx = poolIndex % POOL_SIZE;
poolIndex++;
setPool((prev) =>
prev.map((entry, i) =>
i === idx ? { ...entry, value, x, y, active: true } : entry
)
);
setTimeout(() => {
setPool((prev) =>
prev.map((entry, i) => (i === idx ? { ...entry, active: false } : entry))
);
}, 800);
}
const DamageNumbers = () => (
<For each={pool()}>
{(entry) => (
<Absolute
left={`${entry.x}px`}
top={`${entry.y}px`}
class={`damage-number${entry.active ? ' is-animating' : ''}`}
style={{ opacity: entry.active ? '1' : '0' }}
>
<TextBlock>-{entry.value}</TextBlock>
</Absolute>
)}
</For>
);
hud.css
.damage-number {
position: absolute;
pointer-events: none;
}
@keyframes float-up {
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(-4rem); opacity: 0; }
}
.damage-number.is-animating {
animation: float-up 0.8s ease-out forwards;
}

Set the pool size to the maximum number of simultaneous instances your peak scenario produces.

A 10,000-item inventory, a full server browser, or a complete achievement list are collections where rendering every item as a DOM node is not viable. Ten thousand <div> elements in the layout tree produce a layout solve measured in hundreds of milliseconds.

The rule is unconditional: never create a DOM node for each item in a list of more than 50 to 100 entries. Use Gameface’s engine.createVirtualList() API, which is covered in detail in Observable Models & Virtual Lists.


A DPS meter, a tachometer, a kill counter, and a match timer all receive values from game state at 60 FPS. Updating their DOM text nodes 60 times per second produces 60 mutations per element per second. The player cannot perceive a difference between a counter that updates 60 times per second and one that updates 10 times per second.

A timestamp throttle limits DOM updates to a fixed interval:

DpsLabel.tsx
import { createSignal } from 'solid-js';
import TextBlock from '@components/Basic/TextBlock/TextBlock';
const [dps, setDps] = createSignal(0);
const UPDATE_INTERVAL_MS = 100;
let lastDpsUpdate = 0;
function onDpsUpdate(currentDps: number) {
const now = Date.now();
if (now - lastDpsUpdate < UPDATE_INTERVAL_MS) return;
lastDpsUpdate = now;
setDps(Math.round(currentDps));
}
const DpsLabel = () => <TextBlock class="dps-label">{dps()}</TextBlock>;

A 100ms interval produces 10 DOM updates per second and reduces mutations by 83% compared to per-frame updates at 60 FPS.