DOM Size Management & Hiding Elements
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.
| Technique | Resting Overhead | Toggle Cost |
|---|---|---|
opacity: 0 / visibility: hidden | High (layout + data-binding) | Lowest (no reflow) |
display: none | Medium (no layout, no draw) | Medium (layout reflow on show) |
remove() / data-bind-if | None | Highest (DOM mutation + layout reflow) |
CSS Hiding: opacity and visibility
Section titled “CSS Hiding: opacity and visibility”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:
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> );};<div class="objective-tracker"> <div class="objective-tracker__label">Reach the extraction zone</div> <div class="objective-tracker__marker"></div></div>/* ✅ 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
Section titled “Display None”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:
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> );};<div class="pause-menu"> <div class="pause-menu__header">Paused</div> <div class="pause-menu__options"> <!-- dozens of option rows --> </div></div>/* ❌ 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:
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> );};<!-- Inventory screen: only mounted when model.isInventoryOpen is true --><div class="inventory-screen" data-bind-if="{{model.isInventoryOpen}}"> <div class="inventory-screen__header">Inventory</div> <div class="inventory-screen__grid"> <!-- hundreds of item slots --> </div></div>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.
Deferred & Partial Rendering
Section titled “Deferred & Partial Rendering”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.
Lazy Component Loading
Section titled “Lazy Component Loading”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.
import { lazy, Suspense, createSignal } from 'solid-js';import Block from '@components/Layout/Block/Block';
// Lazy-loaded screensconst 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> );};// ❌ Both modules load at startup - all DOM nodes created immediately, before neededimport SettingsPanel from './settings-panel.js';import SkillTree from './skill-tree.js';
// ✅ Each module and its DOM nodes are created only when the player opens that screenasync function openSettings() { const { default: SettingsPanel } = await import('./settings-panel.js'); const panel = new SettingsPanel(); panel.mount(document.getElementById('overlay-root'));}
async function openSkillTree() { const { default: SkillTree } = await import('./skill-tree.js'); const tree = new SkillTree(); tree.mount(document.getElementById('overlay-root'));}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
Section titled “The template Element”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.
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)<div id="hud-root"> <div class="hud__health-bar"></div> <div class="hud__ammo-counter"></div></div>
<!-- Parsed but not in the layout tree until cloned --><template id="tooltip-template"> <div class="tooltip"> <div class="tooltip__title"></div> <div class="tooltip__body"></div> </div></template>function showTooltip(title, body) { const template = document.getElementById('tooltip-template'); const clone = template.content.cloneNode(true);
clone.querySelector('.tooltip__title').textContent = title; clone.querySelector('.tooltip__body').textContent = body;
document.getElementById('hud-root').appendChild(clone);}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.
Handling Massive Data
Section titled “Handling Massive Data”DOM Node Pooling
Section titled “DOM Node Pooling”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:
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>);const POOL_SIZE = 20;const pool = [];let poolIndex = 0;
for (let i = 0; i < POOL_SIZE; i++) { const node = document.createElement('div'); node.className = 'damage-number'; node.style.opacity = '0'; document.getElementById('hud-root').appendChild(node); pool.push(node);}
function showDamageNumber(value, x, y) { const node = pool[poolIndex % POOL_SIZE]; poolIndex++;
node.textContent = `-${value}`; node.style.left = `${x}px`; node.style.top = `${y}px`; node.style.opacity = '1'; node.classList.add('is-animating');}
function releaseNode(node) { node.classList.remove('is-animating'); node.style.opacity = '0';}.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.
Virtual Lists
Section titled “Virtual Lists”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.
Rate-Limiting DOM Mutations
Section titled “Rate-Limiting DOM Mutations”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:
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>;const UPDATE_INTERVAL_MS = 100; // 10 updates per secondlet lastDpsUpdate = 0;
function onDpsUpdate(currentDps) { const now = Date.now(); if (now - lastDpsUpdate < UPDATE_INTERVAL_MS) return;
lastDpsUpdate = now; document.getElementById('dps-label').textContent = Math.round(currentDps);}A 100ms interval produces 10 DOM updates per second and reduces mutations by 83% compared to per-frame updates at 60 FPS.
© 2026 Coherent Labs. All rights reserved.