docs/oss/building-features/performance-tracks-and-profiling.md
React on Rails apps have three broad performance questions that look similar but need different tool clusters:
React Performance Tracks are most useful when you can reproduce a slow interaction locally. They show React scheduler work, component render and effect durations, and, for React Server Components in development builds, server request and Server Component timing. They do not replace production telemetry, but they make the local trace much easier to read than a JavaScript stack alone.
| Symptom | Start with | Why |
|---|---|---|
| Slow click, navigation, or hydration in the browser | Chrome DevTools Performance recording with React Performance Tracks | Shows React scheduler priority, component render cost, effects, network, layout, and paint on one timeline. |
| A specific component renders too often | React Components track, React DevTools, and optional <Profiler> boundaries | Shows component render/effect duration and, in development builds, prop changes for selected entries. |
| Slow server rendering in the Pro Node Renderer | Profiling Server-Side Rendering Code | Attaches Chrome DevTools to the Node process and records CPU work such as bundle upload, VM setup, and render calls. |
| Renderer breakpoints or request-by-request debugging | Node Renderer Debugging | Uses --inspect, renderer logs, and focused restarts for breakpoint debugging. |
| Production SSR latency or error correlation | Error Reporting and Tracing and OpenTelemetry | Captures spans and errors from live requests without attaching an inspector to production traffic. |
| Memory growth in the renderer | Memory Leaks | Uses heap snapshots and worker restart strategy instead of CPU flamegraphs. |
Use this workflow for browser-side interaction, hydration, Suspense, and RSC timing investigations.
React enables Performance Tracks automatically in development builds. In profiling builds, the Scheduler track is enabled by default. The Components track is limited to subtrees wrapped in <Profiler> unless the React DevTools extension is enabled. React's Server Components and Server Requests tracks are development-build only.
The Scheduler track explains the priority and phase of React work:
startTransition.Within those lanes, look for update, render, commit, and remaining-effect spans. A render followed by repeated updates or long effects often points to avoidable state changes, expensive effects, or missing memoization boundaries.
The Components track shows component render and effect durations as flamegraphs. Use it to answer:
The Server tracks are relevant when you use React Server Components in development. The Server Requests track visualizes async work that flows into Server Components, and the Server Components tracks show the component work and awaited Promises. Treat these as React-level RSC timing: they do not include every Rails controller, Fastify, network, or renderer worker cost.
Profiles are only useful when component and function names survive the build.
displayName on components wrapped by helpers such as memo or forwardRef when the wrapper hides the useful name.react-dom/client to react-dom/profiling at build time as described in the React Performance Tracks docs, and preserve function/class names in that temporary build if minification erases useful labels. For Terser-style minifiers, that means profiling-only keep_fnames and keep_classnames settings or the equivalent setting in your bundler/minifier. Do not turn those knobs on blindly for production, since they can increase bundle size and reduce minification.For a local SSR/RSC investigation, collect one browser trace and one renderer trace for the same scenario:
If your server-rendered code needs its own named spans in a renderer CPU profile, add temporary User Timing marks around the code under investigation:
performance.mark('ror:ssr:ProductSummary:start');
try {
// Render or data preparation work under investigation.
} finally {
performance.mark('ror:ssr:ProductSummary:end');
performance.measure('ror:ssr:ProductSummary', 'ror:ssr:ProductSummary:start', 'ror:ssr:ProductSummary:end');
}
In the Pro Node Renderer VM, performance is available when supportModules: true; otherwise inject it with additionalContext. See Runtime Globals for SSR and RSC.
Do not attach --inspect to production traffic as your primary profiling strategy. It slows the renderer and changes the workload you are trying to measure.
Use production-safe instrumentation instead:
When you convert a page to React Server Components, the only honest way to know whether you regressed user-visible performance is a paired, throttled A/B comparison of the page before and after the conversion. The profiling workflows above tell you where time goes inside one build; this workflow tells you whether the conversion moved the numbers that users feel.
An unthrottled load on localhost will hide the regression almost entirely. Local hardware is fast and the network is effectively free, so the page is bound by main-thread and loopback timing that no real user shares. The regression only surfaces under Lighthouse mobile throttling, which models a phone on a slow connection:
Sampling matters as much as throttling. A single server measured sequentially — control runs, then experiment runs — is noise-dominated: background load, thermal state, and JIT warmup drift between the two runs and swamp the effect you are trying to measure. Sample the two variants simultaneously (paired) instead, so each control sample shares its environment with the experiment sample it is compared against.
Stand up two production-mode servers serving identical data and config, side by side:
| Variant | Build |
|---|---|
| Control | Pre-RSC baseline (your default branch before the RSC conversion) |
| Experiment | RSC branch |
Then:
We use ShakaPerf for this — it brings up the twin production-local servers and runs the paired comparison with shaka-perf compare --categories perf. The methodology is what matters, not the tool: any harness that runs two production builds side by side under identical mobile throttling with paired sampling and a significance test gives you the same signal.
Do not just stare at LCP. Decompose the metrics, because the fix depends on which ones moved:
| Signal | Likely cause | Where to look |
|---|---|---|
| FCP and TBT both high | JS-bundle / hydration bound — the 'use client' tail is shipping and executing too much | Reduce client boundaries. See Chunk Contamination. |
| LCP high while FCP is also high | LCP is gated on late FCP; the largest element may be healthy but cannot paint yet | Fix FCP first by reducing client JS boundaries; LCP usually follows. |
| LCP high while FCP is healthy | The LCP element or its asset delivery is slow | Inspect the hero/image resource, asset host, CDN cache headers, preload/fetch priority, and responsive image selection. |
| INP high in RUM or interaction traces | Long client tasks delay input responsiveness, often from the same JS tail that raises TBT | Follow the FCP/TBT path, then verify the affected interactions with RUM or browser traces. |
| CLS and Lighthouse score | Corroboration | Use as secondary confirmation, not as the primary signal. |
A high FCP that drags LCP behind it is the common RSC-conversion pattern: the largest element is healthy, it simply cannot paint until the late first render lets it. Fix FCP first and LCP usually follows.
Treat each fix as one controlled change:
Because the control never moves, every change has a defensible before/after instead of a number you have to argue about.
A real RSC conversion of the HiChee home and faq pages, measured with a paired ShakaPerf A/B under Slow 4G + 4x CPU (p ≈ 0.03), shows the initial regression state that triggered investigation. These are not final shipped metrics; add your own post-fix rows after each client-boundary or CSS-delivery change.
| Page | FCP | LCP | TBT | Lighthouse |
|---|---|---|---|---|
| Home | 2.0s → 9.3s | 2.1s → 21.1s | 0 → 928ms | 79 → 8.5 |
| FAQ | 2.0s → 15.9s | 2.2s → 20.5s | — (not captured) | — (not captured) |
The FAQ run did not capture TBT or Lighthouse score. The captured signals pointed straight at the 'use client' JS tail: FCP regressed on both pages, Home TBT blew up, and LCP was gated on late FCP because first render arrived far too late, not because the hero element itself was slow.
A CSS broadcast fix (react_on_rails_rsc #108 → #110 / #113, shipped in react-on-rails-rsc 19.2.0-rc.3) was correct, but it was a second-order effect. The dominant driver was the client JS tail, not CSS delivery. Chasing the CSS fix first would have spent effort without moving the metrics that mattered.
The lesson to take from this: measure paired and throttled, then decompose FCP/TBT versus LCP before choosing a fix. A related second-order CSS concern worth knowing about is the end-of-<head> rsc-css precedence trap described in CSS and styling for RSC — but confirm with the decomposition that CSS delivery is actually on your critical path before you spend time there.