Optimizing Performance
Optimize rendering performance, eliminate layout bottlenecks, profile JavaScript execution, and tune memory settings.
On this page
- Avoid Style and Layout Recalculation
- Profile and Replace Hot JavaScript
- Update the Page Directly from Native Code
- Use DDS Images on the GPU Renderer
- Draw Frequent Updates to a Canvas
- Defer Garbage Collection to Quiet Moments
- Reduce Painting on Secondary Views
- Tune Memory Usage
- Choose the Render Path
You can keep UI responsive across desktop apps, games, and embedded devices by eliminating unnecessary engine work.
The techniques below appear in order of impact, starting with the biggest frame-rate wins and finishing with smaller configuration levers.
Avoid Style and Layout Recalculation
Modifying the DOM or updating element styles forces the renderer to recalculate styles, update layout, and repaint the affected area. In an active UI, these recalculations are often the largest per-frame expense, and the cost increases on larger pages.
The remote Web Inspector's timeline records each recalculation and layout pass so you can track them during profiling.
Animate Transform and Opacity with CSS
Animate elements using CSS animations or transitions on transform and opacity instead of updating styles from script.
.sliding-panel {
animation: slide-in 0.3s ease-out;
}
@keyframes slide-in {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
CSS animations and transitions of transform and opacity avoid per-frame engine work— the renderer advances them without recalculating styles, updating layout, or repainting.
Updating element styles from script forces a style recalculation and a repaint on each frame. Setting transform or layout properties from script also forces a layout pass (modifying opacity skips layout).
You should avoid animating layout properties like width, height, left, or top, since animating geometry in CSS still forces a style recalculation, a layout pass, and a repaint on every frame.
Batch DOM Writes Before Reads
Perform all DOM and style modifications before reading back computed styles or geometry.
// Write everything first...
bar.style.width = dom::StyleValue::Pct(health * 100);
label.textContent = std::to_string(static_cast<int>(health * 100));
// ...then read (one style and layout pass).
dom::DOMRect box = panel.getBoundingClientRect();
Reading element geometry or a computed style immediately after modifying the DOM forces the renderer to process pending style and layout calculations on the spot. Batching modifications together lets the engine resolve layout in a single pass.
When applying large DOM tree modifications, spread the changes across multiple frames. For more on inspecting and setting element properties, see Reading and Writing Styles and Element Geometry and Scrolling.
🚧 Avoid Interleaving Reads and Writes
Alternating between DOM writes and geometry reads forces a synchronous layout pass on every read. This pattern stalls the frame loop and degrades responsiveness.
Profile and Replace Hot JavaScript
When page scripts cause frame drops, profile the page to identify the most expensive functions, then port them to C++ using the DOM API.
Profile the Page
Profile the page before modifying code to locate slow JavaScript and rendering work.
Use the Remote Web Inspector
Start the remote inspector server to profile views from another window or machine.
// Accept inspector connections from this machine only.
renderer->StartRemoteInspectorServer("127.0.0.1", 9222);
To connect, load inspector://127.0.0.1:9222 in another View— the inspecting application implements ViewListener::OnCreateInspectorView() to host the inspector window. You can also run MiniBrowser from the SDK tools/ folder and enter the address in its address bar.
The remote inspector timeline records JavaScript execution with sampled call stacks, style recalculations, layouts, and repaints. JavaScript breakpoints also require the remote inspector.
Remote inspection requires the Plus edition or higher.
Use the Local Web Inspector
You can call View::CreateLocalInspectorView() to inspect DOM elements and styles directly inside the application— see Handling View Events.
The local inspector does not support timelines or JavaScript breakpoints yet. Use the remote Web Inspector instead when profiling performance.
Profile in Chrome DevTools
When a page also runs in Google Chrome, you can profile script execution in the Chrome DevTools Performance panel for a quick look at expensive functions.
Timing characteristics in Chrome differ from Ultralight— confirm any bottlenecks in the remote Web Inspector before changing code.
Profile with Native Tools
To measure entire frames alongside engine layout, painting, and JavaScript execution, profile native code with a system profiler or generate a trace file.
This workflow requires the Pro edition or higher— see Profiling and Tracing.
Port Hot Functions to the DOM API
The DOM API syntax closely mirrors JavaScript, making porting mostly a line-by-line translation. Native C++ executes faster and avoids JavaScript heap allocations.
Here is a page script that resizes health bar elements every frame.
function updateHealthBars(units) {
const bars = document.querySelectorAll(".health");
for (let i = 0; i < bars.length; i++)
bars[i].style.width = units[i].health + "%";
}
You can port the function to C++ and call it directly from the native frame loop.
void UpdateHealthBars(View* view, const std::vector<Unit>& units) {
dom::Document document(view);
auto bars = document.querySelectorAll(".health");
for (size_t i = 0; i < bars.size(); i++)
bars[i].style.width = dom::StyleValue::Pct(units[i].health);
}
For more on working with DOM elements in C++, see About the DOM API.
Update the Page Directly from Native Code
When building new UI, update the page directly from native code instead of passing data through JavaScript.
Use Data Bindings
Bind page state directly to native data when presenting values in the UI. Modify data on the Context's home thread and call Sync() once per frame. The library synchronizes only the values that changed— see About Data Bindings.
Use the DOM API
Use the DOM API for one-off modifications, measuring elements, and responding to events in native code. For details on manipulating nodes directly from C++, see About the DOM API.
Disable JavaScript
Disable JavaScript when creating a View if the page does not use scripts.
ViewConfig config;
config.enable_javascript = false;
Both data bindings and the DOM API continue to function with JavaScript disabled. For more advice on optimizing resource-constrained platforms, see Embedded and Device UI.
Use DDS Images on the GPU Renderer
Reference .dds images directly in HTML image tags or CSS URLs.
<img src="minimap-icons.dds">
Block-compressed .dds images upload directly to the GPU in compressed form and decode only when sampled. They use four to eight times less video memory and load faster than PNG images.
This optimization requires the Pro edition or higher and applies only to the GPU renderer. The CPU renderer decodes DDS images into uncompressed memory, providing no memory savings— see Compressed Textures.
If you already hold GPU textures, you can display them directly without copying via Displaying Custom Textures.
Draw Frequent Updates to a Canvas
Draw content that updates every frame (such as minimaps, radars, or animated menus) into an HTML5 <canvas> instead of moving DOM elements.
const ctx = document.querySelector("#minimap").getContext("2d");
function drawMinimap() {
ctx.clearRect(0, 0, 256, 256);
for (const unit of units)
ctx.fillRect(unit.x, unit.y, 4, 4);
requestAnimationFrame(drawMinimap);
}
requestAnimationFrame(drawMinimap);
Drawing to a canvas updates pixels directly— the renderer repaints the canvas without recalculating styles or updating layout.
Render Canvases on the GPU or CPU
By default, a canvas uses the render path of its View.
When a View uses the GPU renderer, its canvases draw on the GPU. When a View uses the CPU renderer, its canvases draw in software— both paths skip style recalculation and layout.
Read Pixels Back from a Canvas
When the page reads pixels back using getImageData() or toDataURL(), request a software canvas with willReadFrequently.
const ctx = canvas.getContext("2d", { willReadFrequently: true });
Setting willReadFrequently switches that individual canvas to software rendering— drawing for it no longer runs on the GPU.
🚧 Pixel Readbacks on GPU Canvases
On a GPU canvas without
willReadFrequently,getImageData()throws andtoDataURL()returnsdata:,. In addition,putImageData()does nothing. For details on supported canvas features, see Supported Web Features.
Defer Garbage Collection to Quiet Moments
The renderer collects JavaScript garbage continuously on background threads. Occasionally, the engine requires a larger collection that runs on the thread calling Renderer::Update(), pausing it briefly while memory reclaims.
When views remain idle without user input, the engine requests a foreground collection through GCListener::OnRequestGC(). Without a listener installed, the collection runs immediately. You know best when a pause will go unnoticed, such as during a loading screen, a pause menu, or an idle period. Installing a GCListener lets you record the request and trigger it later with Renderer::CollectNow(). This interface requires the Pro edition or higher— see Editions and Feature Macros.
Hold the Collection Request
Override OnRequestGC() to remember collection requests instead of running them immediately.
class QuietGC : public GCListener {
public:
void OnRequestGC(Renderer* caller, const GCInfo& info,
CodeDropMode mode) override {
// Don't collect now. Remember the request for a quiet moment.
pending_ = true;
mode_ = mode;
}
// Call from your loop during a loading screen or a pause menu.
void CollectIfPending(Renderer* renderer) {
if (pending_) {
renderer->CollectNow(mode_);
pending_ = false;
}
}
private:
bool pending_ = false;
CodeDropMode mode_ = CodeDropMode::Nothing;
};
Always call Renderer::CollectNow() on the same thread that calls Renderer::Update().
Attach the Listener
Register the listener with the renderer before starting the frame loop.
// The Renderer doesn't own the listener; keep it alive longer.
renderer->set_gc_listener(&quiet_gc);
For more listener callbacks and heap monitoring options, see Managing Memory.
📘 Held Requests Wait for Explicit Collection
A deferred collection never runs until you explicitly call
Renderer::CollectNow(). Background collections and collections triggered by memory pressure continue to run in the meantime.
Reduce Painting on Secondary Views
A View repaints only when its content changes— a static page costs nothing to render.
Cap Secondary View Frame Rates
Cap the frame rate of secondary views that remain visible but do not need full refresh rates.
ViewConfig minimap_config;
minimap_config.max_render_fps = 30;
minimap = renderer->CreateView(256, 256, minimap_config, nullptr);
You can also adjust the limit at runtime with View::set_max_render_fps(), passing 0 to remove the cap.
The cap limits CSS animations, smooth scrolling, requestAnimationFrame callbacks, and repaints. Script changes, data bindings, and DOM API calls update on the next capped frame. User input, view resizing, and showing a hidden view force an immediate repaint.
Hide Inactive Views
Hide views that are offscreen, obscured, or closed instead of destroying and recreating them.
menu->set_visible(false); // skipped by Render(), animations paused
// Later:
menu->set_visible(true); // resumes, repaints on the next Render()
Hiding a View stops Renderer::Render() from painting it, pauses CSS animations and requestAnimationFrame callbacks, and fires visibilitychange on the page.
JavaScript timers continue running while hidden unless ViewConfig::enable_hidden_timer_throttling was enabled when creating the View, which throttles repeating timers to about once per second— see Updating and Rendering.
Tune Memory Usage
You can control memory usage by selecting an allocation profile and reclaiming cached resources at quiet moments.
Set a Memory Profile
Set Config::memory_profile before creating the renderer to tune heap allocation.
Config config;
config.memory_profile = MemoryProfile::LowMemory;
Platform::instance().set_config(config); // before Renderer::Create()
Use MemoryProfile::LowMemory on memory-constrained devices to keep heap usage low. When RAM is plentiful, choose MemoryProfile::Performance to favor execution speed.
Reclaim Caches at Transitions
Call Renderer::Recycle() during a loading screen or level transition to purge cached data.
void OnLoadingScreen() {
renderer->Recycle(RecycleMode::Full); // thorough, and heavier
}
Internal caches recycle automatically on a timer. Passing RecycleMode::Full forces a thorough cleanup— save this for natural pauses since the call is too heavy to run every frame.
For more on memory management and cache reclamation, see Managing Memory.
Choose the Render Path
If animated or complex UI runs slowly on the CPU renderer, switch to the GPU renderer. The GPU renderer composites layers directly on graphics hardware— see GPU Renderer Overview.
When using the CPU renderer, you can implement a custom Surface to paint directly into native application memory, removing one pixel copy per frame— see Render Surfaces.