The situation
Say you are a React Native engineer on a mobile team that has just upgraded the UI stack from the legacy renderer to the new Fabric renderer (React Native 0.71). Your goal is to reduce frame‑drop on a scrolling list that currently hits 45 ms per frame on a mid‑range Android device. After switching to Fabric you notice the UI feels smoother during a quick manual test, but the performance monitor still reports occasional spikes to 70 ms. The team’s sprint deadline is two weeks away and the product manager is demanding a quantifiable improvement before the release can be approved.
The cost of not having a solid measurement is threefold:
- Time: you may spend another week chasing “feel‑good” changes that don’t actually move the needle.
- Risk: a regression could slip into production because the baseline wasn’t captured.
- Stakeholder confidence: without hard data the product manager will push back, potentially delaying the launch.
The core problem is how to instrument a Fabric‑based app so you can decide, with numbers, whether the rewrite is worth the effort.
Why it happens
Fabric changes the way the JavaScript thread talks to the UI thread:
| Legacy (Bridge) | Fabric |
|---|---|
| JSON‑encoded messages over a single queue | Direct, typed C++ bindings; batched UI commands |
| All layout work on the UI thread | Layout can be off‑loaded to the JS thread via LayoutAnimation APIs, but still ends up on UI thread for final paint |
Because the data path is different, the existing instrumentation that ships with React Native (e.g., Systrace or the “Performance Monitor” in the developer menu) often does not surface the new cost centres:
- The “JS → Native bridge” graph still shows a single “Bridge” node, even though Fabric bypasses it.
- The UI thread’s “Layout” and “Mount” phases are now split into separate C++ calls that are invisible to the default timeline.
Consequently, developers see the same “JS time” and “UI time” numbers, but the underlying work has moved. Without specialised metrics you cannot tell whether the new path reduced overall work or simply shifted latency to a place you’re not watching.
The fix, step by step
Below is a reproducible workflow that adds low‑overhead instrumentation to a Fabric app, validates the data, and uses it to decide if the rewrite is worthwhile.
1. Enable the Fabric‑specific PerfMarkers
Fabric ships a set of PerfMarker strings that are emitted via ReactPerfLogger. They are only active when the RCT_ENABLE_FABRIC build flag is true and when RCT_DEV is true.
What to do
# In ios/Podfile or android/app/build.gradle set:
RCT_ENABLE_FABRIC=1
RCT_DEV=1
Then rebuild the app (npx react-native run-android or run-ios).
Why
These markers are emitted as RCTLog events that systrace can capture. They give you timestamps for:
-
FabricMountStart/FabricMountStop -
FabricLayoutStart/FabricLayoutStop -
FabricCommitStart/FabricCommitStop
How to confirm
Run adb shell setprop debug.reactperf 1 (Android) or set the RCT_DEBUG environment variable (iOS). Then launch the app and open adb logcat | grep PerfMarker. You should see lines like:
[PerfMarker] FabricMountStart: 1234567890
[PerfMarker] FabricMountStop: 1234567950
If you see matching start/stop pairs, the markers are active.
2. Capture the markers with Systrace (or Perfetto)
Systrace aggregates PerfMarker events into a timeline you can view in Chrome.
What to do
# Android
adb shell atrace --async_start -c -b 4096 -t 10 -z -o /data/local/tmp/trace.html \
-a com.yourapp -p com.facebook.react.perfmarker
adb pull /data/local/tmp/trace.html .
Open trace.html in Chrome’s chrome://tracing UI. Look for the Fabric sections; they will appear as distinct rows labelled “FabricMount”, “FabricLayout”, etc.
Why
The visual timeline lets you compare the duration of each phase before and after your optimisation.
How to confirm
Zoom into a frame where you expect a change (e.g., a scroll event). Note the duration of FabricMount in milliseconds. Record the value (e.g., 3.2 ms). Do the same for the baseline (legacy) build; you should see a separate set of markers called BridgeMount with a larger duration (e.g., 7.8 ms).
If the numbers differ, you have measurable data.
3. Add a custom RCTPerformanceLogger counter
The built‑in RCTPerformanceLogger stores timings in a circular buffer. You can add a custom counter for “total frame time” that includes both JS and UI work.
What to do
Create a native module (Android example):
// FabricPerfModule.java
package com.yourapp;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.facebook.react.uimanager.ReactPerformanceLogger;
public class FabricPerfModule extends ReactContextBaseJavaModule {
private static final int CUSTOM_TOTAL_FRAME = 1000; // arbitrary id > 500
public FabricPerfModule(ReactApplicationContext reactContext) {
super(reactContext);
ReactPerformanceLogger logger = ReactPerformanceLogger.getInstance();
logger.addCustomMetric(CUSTOM_TOTAL_FRAME);
}
@Override
public String getName() {
return "FabricPerf";
}
// Expose a method to JS to read the metric
@ReactMethod
public void getLastFrameTime(Promise promise) {
long duration = ReactPerformanceLogger.getInstance()
.getDuration(CUSTOM_TOTAL_FRAME);
promise.resolve(duration);
}
}
Register the module in MainApplication.java and expose a JS wrapper:
import {NativeModules} from 'react-native';
export const getLastFrameTime = () => NativeModules.FabricPerf.getLastFrameTime();
Why
A custom metric lets you aggregate the time spent in FabricMount + FabricLayout + JS execution for each frame, giving a single “frame budget” number you can compare against the 16 ms target for 60 fps.
How to confirm
Add a useEffect hook that logs the value after each render:
useEffect(() => {
const id = setInterval(async () => {
const ms = await getLastFrameTime();
console.log('Last frame time (ms):', ms);
}, 1000);
return () => clearInterval(id);
}, []);
Run the app and watch the console. You should see a stable series of numbers (e.g., 12.4, 13.1). If the numbers stay below 16 ms consistently, you have quantitative proof of success.
4. Automate regression detection in CI
Instrumented measurements are only useful if they are repeatable. Add a script that runs the app on a headless emulator, captures the PerfMarker trace, extracts the average FabricMount duration, and fails the build if it exceeds a threshold.
What to do
Create a Node script measure-fabric.js:
const {execSync} = require('child_process');
const fs = require('fs');
function runTrace() {
execSync(`adb shell setprop debug.reactperf 1`);
execSync(`adb shell am start -n com.yourapp/.MainActivity`);
execSync(`adb shell sleep 5`); // let the app settle
execSync(`adb shell atrace --async_start -c -b 4096 -t 5 -z -o /data/local/tmp/trace.html -a com.yourapp -p com.facebook.react.perfmarker`);
execSync(`adb pull /data/local/tmp/trace.html .`);
}
function parseTrace() {
const html = fs.readFileSync('trace.html', 'utf8');
const mountTimes = [...html.matchAll(/FabricMountStart.*?(\d+).*?FabricMountStop.*?(\d+)/g)]
.map(m => Number(m[2]) - Number(m[1]));
const avg = mountTimes.reduce((a,b) => a+b,0) / mountTimes.length;
return avg;
}
runTrace();
const avgMount = parseTrace();
console.log(`Average FabricMount: ${avgMount.toFixed(2)} ms`);
if (avgMount > 4.0) {
console.error('❌ FabricMount exceeds budget (4 ms)');
process.exit(1);
}
Add this step to your CI pipeline (e.g., GitHub Actions).
Why
CI gating prevents accidental regressions caused by future library upgrades or code changes.
How to confirm
Push a change that deliberately adds a heavy view (e.g., a FlatList with 10 000 items). The CI run should fail, printing the “exceeds budget” message. Reverting the change restores the green status, proving the test works.
5. Correlate with real‑world metrics (FPS, UI‑thread CPU)
Instrumentation inside the app tells you about internal timings, but the ultimate metric is frames per second as observed by the user. Use Android’s adb shell dumpsys gfxinfo or iOS’s measureFPS tool to capture the FPS while the same interactions are performed.
What to do (Android)
adb shell dumpsys gfxinfo com.yourapp > gfx.txt
# Parse the “Total” column for each frame; compute average FPS
What to do (iOS)
Open Xcode Instruments → “Core Animation” → record a scroll session. Export the CSV and calculate average FPS.
Why
If your Fabric metrics show a 3 ms reduction in mount time but FPS stays at 55 fps, the bottleneck lies elsewhere (e.g., image decoding).
How to confirm
Run the same scripted scroll (e.g., using detox or appium) on both the legacy and Fabric builds. Compare the average FPS numbers:
| Build | Avg Mount (ms) | Avg FPS |
|---|---|---|
| Legacy | 7.8 | 48 |
| Fabric | 3.2 | 57 |
A clear uplift in both columns validates that the rewrite is worth shipping.
What it costs, and when not to do it
| Cost | Detail |
|---|---|
| Runtime overhead | Each PerfMarker incurs a nanosecond‑scale write to a shared buffer. In production builds the overhead is typically < 0.5 % CPU on modern devices, but on low‑end Android (e.g., Snapdragon 450) you may see a 1–2 % increase in UI‑thread utilisation. |
| Bundle size | Adding the native module (FabricPerfModule) adds ~30 KB of compiled code (mostly logging glue). This is negligible compared with the overall binary (≈ 30 MB). |
| Build complexity | Enabling RCT_ENABLE_FABRIC and RCT_DEV together forces a debug build; you cannot collect these markers from a release‑signed APK. Therefore you must maintain a separate “performance‑profile” build variant. |
| Data noise | In CI, emulator timing can be unstable. Use a physical device (e.g., Pixel 5) with a locked CPU frequency to obtain reproducible numbers. |
| When not to do it | • If the feature under test is a one‑off UI change that will not be shipped to many users. • When you are already at the 60 fps ceiling and additional optimisation yields diminishing returns. • In a hot‑fix window where adding native modules would delay the release more than the performance gain. |
In practice, the benefit‑to‑cost ratio is favourable when you are:
- Targeting a high‑traffic screen (e.g., home feed) where a 2 ms reduction translates to a measurable increase in retained users.
- Working on a platform‑wide migration (all screens will run on Fabric), making the instrumentation a reusable asset.
At your level
| Role | Take‑away | One concrete next step |
|---|---|---|
| Student / first‑year | Instrumentation is the only way to turn “it feels faster” into a defensible claim. | Add the FabricPerfModule to a sample app, run the CI script locally, and verify the average mount time printed. |
| Working engineer | Use the built‑in PerfMarkers plus a custom counter to create a regression test that gates future changes. |
Integrate measure-fabric.js into your existing CI pipeline and set a realistic budget (e.g., 4 ms for mount). |
| Senior / staff | Correlate internal timings with user‑facing metrics (FPS, ANR rate) and expose the data in a dashboard for product owners. | Build a small internal Grafana panel that ingests the CI JSON output and shows trends across releases. |
| Lead / director | Allocate budget for a dedicated performance‑profiling build variant and for the hardware (mid‑range test phones) needed to keep measurements trustworthy. | Approve purchase of two representative Android devices and schedule a weekly “Performance Health” meeting that reviews the Fabric metrics. |
In the interview
Typical interview prompt
“We migrated a large screen from the legacy renderer to Fabric. How would you prove that the migration actually improved performance, and how would you decide whether the effort was justified?”
Weak answer
“I would look at the FPS counter in the developer menu and compare before/after. If the FPS goes up, the migration is good.”
Why it’s weak: It mentions only a surface‑level metric, ignores internal work redistribution, provides no method for repeatable measurement, and does not discuss trade‑offs or failure modes.
Strong answer
- Identify the constraint – the 16 ms per‑frame budget for 60 fps on target devices.
-
Instrument the new path – enable Fabric
PerfMarkers, add a customRCTPerformanceLoggercounter for total frame time, and capture the data with Systrace/Perfetto. -
Validate the data – confirm start/stop pairs appear, extract average
FabricMountandFabricLayoutdurations, and compare them to the legacyBridgeMountnumbers. -
Correlate with user‑facing metrics – run
adb shell dumpsys gfxinfo(or iOS Instruments) on the same scripted interaction to ensure FPS improves in proportion to the internal timing reduction. - **Quantify the cost
Amit Chakraborty is a founding engineer and senior architect — React Native, AI/RAG systems and production architecture. Portfolio: www.amitchakraborty.dev · LinkedIn · GitHub. Open to senior and founding engineering roles, remote worldwide.
Top comments (0)