You can record and read Roku performance traces to diagnose animation jank, dropped frames, slow key-event handlers, blocking transactions, and render-thread stalls. This document is for app developers and SDK maintainers who investigate rendering performance.

You record a trace with the rk perfetto command while you interact with your app, and then open the trace in the Perfetto UI or in VS Code with the Perfetto plugin.

This document covers the following topics:

Recording a trace

The following commands add a device, enable tracing, deploy the app, and record a trace:

# 1. Add your device (one-time)
rk device add <device-ip>

# 2. Enable tracing before launching the app
rk perfetto enable

# 3. Deploy the app
rk dev

# 4. Record while interacting with the app
rk perfetto start --open

# 5. Press Ctrl+C; the trace opens in VS Code with the Perfetto plugin

You must run rk perfetto enable before the app launches. Enabling tracing after launch misses early initialization traces.

By default, the trace file is named trace.<esn>.<timestamp>.perfetto-trace. The CLI writes it to its current directory in local mode (--local), or to the server's project root in server mode. Relative --output paths use the same base directory. In both modes, output paths (relative or absolute) must stay within that base directory. You can open the trace in the Perfetto UI (or in VS Code with the Perfetto plugin) to read it. See rk perfetto for more information about the command options.

Identifying the important threads

Three threads matter for rendering work. You can find them in the Perfetto UI track list first; the rest of this document describes slices relative to these threads. The following table lists the threads:

ThreadRole
ts_event_devWhere TypeScript code runs (your components, grid engines, navigation)
RenderThr:MainThe main SceneGraph render thread
AuxRenderThreadAuxiliary render thread that runs when the main render thread is busy

The AuxRenderThread thread running in parallel with the RenderThr:Main thread is normal. What matters is what AuxRenderThread runs during an animation. See Identifying performance issues for more information.

TypeScript-level Perfetto events appear under the rokuos.typescript track.

Understanding the slices that the firmware provides

These slices appear in every trace automatically, without code changes or tracepoints. The following table lists the slices:

SliceThreadMeaning
keyEventRenderThr:MainFirmware instant event that is the first trace point to fire when a key press happens. You can use it as the start of an animation sequence. The slice carries the debug.key and debug.isKeyPress arguments.
renderRenderThr:MainSceneGraph processing a draw instruction (drawTime)
swapBuffersRenderThr:MainSceneGraph flushing draw instructions to the GPU (text, images, rectangles), reported as drawFlushTime. Its duration is synchronized to the 16.67 ms frame; if render takes 1 ms, swapBuffers waits the remaining ~15.67 ms.
observer.callbackRenderThr:MainA field-change observer firing (key events, signal updates)
consumeAllTasksRenderThr:Main / AuxRenderThreadRuns all observer events and BrightScript callbacks
animationnode / animationstartRenderThr:MainSceneGraph animation lifecycle
component.initvariousComponent initialization
ExecBrightScriptvariousBrightScript task execution

Because swapBuffers pads out the frame, a long swapBuffers on its own is not a problem; it usually means the frame had spare time. You must read render + swapBuffers together. See Identifying performance issues for more information.

Understanding Program (TypeScript) and render transactions

Work crosses between TypeScript and the render thread through transactions. Each transaction has four trace points. The toTS segment in the name distinguishes the direction.

A transaction from the render thread to Program (TypeScript) has the following trace points:

  • transaction.toTS.start
  • transaction.toTS.commit
  • transaction.toTS.update
  • transaction.toTS.end

A transaction from Program (TypeScript) to the render thread has the following trace points:

  • transaction.start
  • transaction.commit
  • transaction.update
  • transaction.end

For the Program (TypeScript) to Render direction, two intervals are blocking:

  • The interval from transaction.start to transaction.commit is blocking.
  • The interval from transaction.update to transaction.end is blocking.

The following diagram shows the blocking intervals in each direction:

Program (TS) -> Render
    transaction.start        ─┐
                              │ blocking
    transaction.commit       ─┘
    transaction.update       ─┐
                              │ blocking
    transaction.end          ─┘

Render -> Program (TS)
    transaction.toTS.start        (not blocking on the render thread)
    transaction.toTS.commit
    transaction.toTS.update
    transaction.toTS.end

The toTS trace points are useful for seeing when the render thread hands work to TypeScript. The interval from transaction.toTS.start to transaction.toTS.commit does not block the render thread; therefore, you must not read a wide toTS span as a render-thread stall.

For the Program (TypeScript) to Render direction, you can measure the two blocking intervals rather than the whole transaction span. The remaining portions do not stall the initiating thread.

Analyzing animation frames

An animation that a key press drives produces the following pattern on RenderThr:Main:

[keyEvent instant] -> [observer.callback] -> [render][swapBuffers] -> [render][swapBuffers] ...

The frame budget for 60 frames per second (FPS) is 16.67 ms: render + swapBuffers ≤ 16.67 ms.

To measure FPS, do the following:

  1. Find the first swapBuffers after the triggering keyEvent.
  2. Find the last swapBuffers in the animation sequence.
  3. Count the swapBuffers slices (N).
  4. Calculate FPS = (N / duration_ms) × 1000.

Common causes of low FPS include the following:

  • Long observer.callback slices pushing the first render past the 16.67 ms budget
  • A single very long render or swapBuffers (GPU stall or heavy scene)
  • SceneGraph node creation during the animation

See Seeing inside transactions with auto-instrumentation and Optimizing grids and cells for more information.

To find the cause of low FPS, do the following:

  1. On RenderThr:Main, look for observer.callback slices longer than 1 ms.
  2. Check the keyEvent instant above each slice to confirm which key and which press or release triggered it.
  3. Instrument the TypeScript side to see which function or signal write ran inside the slow window. The firmware observer.callback slice tells you that an observer fired, but not which one. See Seeing inside transactions with auto-instrumentation for more information.

Identifying performance issues

The following three rules identify rendering jank.

  • Dropped frames during animation. If swapBuffers + render exceeds 16.67 ms during an animation, the frame drops. You can look for a single long render (heavy scene or too many nodes) or a long observer.callback pushing the first render past the budget.

  • Blocking transactions during animation. If the time between transaction.start and transaction.commit exceeds 300 ms while an animation is running, the animation janks during smooth scrolling. The same applies to the transaction.update to transaction.end interval. This rule applies to the Program (TypeScript) to Render direction only because the toTS trace points do not block the render thread. See Understanding Program (TypeScript) and Render transactions for more information.

  • Render time taken by consumeAllTasks. If consumeAllTasks runs on AuxRenderThread or RenderThr:Main during an animation, it can block and take time away from swapBuffers and render. Observer callbacks and BrightScript work scheduled during a scroll are the usual cause.

Deciding what to do next

After you find an offending frame or interval, do the following:

  1. Note the timestamp of the offending frame or interval and find every slice that overlaps it on all three threads.
  2. Identify the source of the work: smooth-scroll steps (flex-grid:smooth-scroll-stepY, flex-grid:smooth-scroll-stepX), observers (consumeAllTasks and observer.callback), signal writes (writeSignal), or node creation (createElement). See Seeing inside transactions with auto-instrumentation for more information about writeSignal and createElement.
  3. Reduce the work, or move it off the render-critical path by prefetching earlier, warming the pool up front, or deferring the work until the animation completes.

Interpreting intermittent long slices

If the same trace event occasionally takes a long time and the next run is fast, you must not treat the single spike as a steady CPU cost. A spike can have the following causes:

  • A TypeScript tracepoint (on ts_event_dev) that spikes may have had garbage collection run inside it.
  • A tracepoint on RenderThr:Main or AuxRenderThread that spikes may reflect context switching (the CPU was scheduled away) rather than extra SceneGraph work.

You should trust patterns that repeat across many frames more than one-off outliers.

Following the optimization workflow

Every rendering performance investigation follows the same loop: record, read, classify, fix, and re-verify.

  1. Record. Enable tracing before the app launches, then record while reproducing the jank (for a grid, usually a press-and-hold vertical scroll):

    rk perfetto enable
    rk dev
    rk perfetto start
    # interact, then Ctrl+C
  2. Read. Open the recorded trace in the Perfetto UI and locate the animation sequence: the run of render and swapBuffers frames that follows the keyEvent that you care about. See Analyzing animation frames for more information.

  3. Classify. Identify the three threads, the firmware slices, and the transaction boundaries, then apply the rules in Identifying performance issues to decide whether you are looking at a frame-budget overrun, a blocking transaction, or contention. See Identifying the important threads, Understanding the slices that the firmware provides, and Understanding Program (TypeScript) and Render transactions for more information.

  4. Fix. Reduce or relocate the work. Typical fixes fall into three categories:

  5. Re-verify. Re-record the same interaction and confirm that the frame budget and transaction blocking intervals improved. A single trace is not evidence. See Interpreting intermittent long slices for more information.

Reviewing classes of change that produced measurable wins

The following classes of change produced measurable wins:

  • Caching reads in frequently run code. You can move signal getters and Map lookups out of per-row and per-card loops (for example, cache an active boolean instead of re-reading an isActive() signal in a very frequent setter, and move a cumulative-offset lookup out of the inner column loop).
  • Naming signals and memos. You can give every createSignal and createMemo a name so that Solid development hooks and traces attribute reactive work to a source. Named reactive slices appear only in builds that resolve solid through its development build. To get a development build, set solidDevMode: true at the top level of roku-config.ts and build with rk dev --debug, or pass --solid-dev-mode; the default is false. The solidDevMode field applies only to rk dev --debug; the --solid-dev-mode flag works on any JavaScript build. Builds that precompile to Hermes bytecode skip the development build; as a result, reactive work stays anonymous.
  • Running a single layout per scroll step (prefetch). A vertical smooth-scroll step normally runs one layout pass. A second pass appears when the data for the entering row arrives asynchronously and increments the store version. If you keep the prefetch window ahead of the scroll (so that rows are in the store before focus lands), those steps return to a single layout.
  • Pooling and warmup. You should recycle nodes through the pool rather than creating them on every frame. The poolBuffer warmup in FlexGrid is additive; therefore, warming the working set up front removes node creation from the animation path.

Seeing inside transactions with auto-instrumentation

Build-time Perfetto auto-instrumentation wraps eligible named functions in perfetto.beginEvent and endEvent during compilation; therefore, TypeScript execution appears as named slices without editing sources. You can use auto-instrumentation to see what happens inside the transaction windows. After you find a long interval from transaction.start to transaction.commit, instrument Solid; the blocking window then shows which Solid work ran inside it. See Understanding Program (TypeScript) and Render transactions for more information.

The following table lists the --perfetto-paths flag values for each goal:

GoalFlag
Tracepoints for Roku Solid (@roku-sdk/solid)--perfetto-paths "packages/solid/src"
Tracepoints for Roku Solid and open-source solid-js--perfetto-paths "packages/solid/src,solid-js"

The following commands instrument the Roku Solid package, and then also instrument the open-source solid-js internals:

rk dev --perfetto-paths "packages/solid/src"

# deeper: also instrument open-source solid-js internals
rk dev --perfetto-paths "packages/solid/src,solid-js"

You should start with the narrower path. Adding solid-js produces more slices, and each tracepoint costs roughly from 10 to 30 microseconds per invocation, which can distort the timings that you are measuring. See App configuration for more information about the full configuration (app.perfetto.instrumentPaths, --perfetto-exclude, and --perfetto-filter).

Device support: The RSG 2.0 SDK runtime provides the perfetto global, which is absent on Liberty devices. On those devices, tracepoints compile to no-ops (instructions that do nothing) rather than failing. This applies to both auto-instrumentation and hand-written perfetto.beginEvent calls.

Understanding the writeSignal and createElement slices

The writeSignal and createElement slices show signal writes and SceneGraph node creation.

The writeSignal slice appears when solid-js is in --perfetto-paths. Each time app code calls a signal setter (setSignal()), a writeSignal slice appears. You can use it to attribute work inside a long transaction to specific signal writes.

The createElement slice appears with packages/solid/src instrumentation. Every createElement slice means that your app is creating a SceneGraph node. You should not create many elements during an animation because doing so causes jank. When you see these slices during a scroll, you can revisit the pooling and buffer warmup strategy so that the app recycles nodes and warms them ahead of the scroll instead of allocating them mid-animation.

Discovering --perfetto-paths from the compiled bundle

The build matches instrumentPaths and --perfetto-paths entries as substrings of the source file path that it sees at compile time. To find the right substring for a function that you want to trace, do the following:

  1. Build a readable dev bundle from the app under test:

    rk build --debug

    The --debug preset disables minification (--no-minify) and keeps __DEV__ true; therefore, function names survive in the bundle. You can use --no-minify directly if you want only that without the other debug-preset defaults.

  2. Open dist-build/bundle/source/compiled/index.js and search for the function name that you want to trace (for example, createElement).

    That file is the esbuild JavaScript bundle. You must not look in dist-build/pkg-dir/; that directory is the assembled Roku package, and the build deliberately excludes the raw JavaScript from it. With rk dev, the default output directory is dist-dev instead of dist-build.

  3. From the match, scroll up to the nearest source-path comment. The bundler emits one comment for each module:

    // ../../../packages/solid/src/solid.ts
  4. Use any substring of that path as the --perfetto-paths value. The following table shows example values for the comment above:

    Path substringScope
    solid/src/solid.tsThat single file
    packages/solid/srcThe whole Roku Solid package
    solid-jsOpen-source solid-js modules

Once you have the source-path comment, you have a valid --perfetto-paths value. You should pick the narrowest substring that still covers the functions that you want to trace; short entries like src match any path that contains that substring and over-instrument the build.

Optimizing grids and cells

The techniques in this section come from optimizing @roku-sdk/flex-grid and apply to any pooled, virtualized list or grid.

Avoiding <Show> for showing and hiding cell content

FlexGrid cell components are pooled templates. The <Show when={...}> component mounts and unmounts its children, which creates new SceneGraph nodes (createElement) every time the condition changes; this is expensive during animation. You can use one of two alternatives:

  • You can toggle visible so that the app creates the node once and only its visibility changes. The following example toggles visible:

    <rectangle visible={props.focused()} width={props.width} height={props.height} color="0x0066FFFF" />
  • You can create a new template type with a different number of SceneGraph nodes when the structure differs, and map it through getCellTemplateId and getCellComponent so that each variant gets its own pool stack with a fixed node count. The following example maps a template ID to a cell component:

    getCellTemplateId: (cellData) => (cellData?.hero ? "hero" : "portrait"),
    getCellComponent: (templateId) => TEMPLATES[templateId],

See Rendering cells for more information about the full cell rendering contract. See FlexGrid for more information about the rest of the grid documentation.

Related documentation

  • rk perfetto: Describes the command options of the rk perfetto CLI.
  • App configuration options (roku-config.ts): Describes the app.perfetto options, including app.perfetto.instrumentPaths, --perfetto-exclude, and --perfetto-filter.
  • App profiling: Describes how to collect CPU and heap profiling data in VS Code.
  • FlexGrid: Includes the cell rendering contract and the rest of the grid documentation.

Did this page help you?