#!/usr/bin/env node
* Analyze a React DevTools Profiler export (the JSON you get from "Save
* profile" in the Profiler tab — NOT a Hermes .cpuprofile; that's a
* different format handled by analyze-cpuprofile.js).
*
* Usage:
* node perf/analyze-react-profile.js path/to/profile.json
*
* Output: top components by total self time, by total actual time, by avg
* self time per render; slowest single commits; what triggered each commit;
* focused breakdown for Message-row components. All numbers in ms.
*
* Implementation notes:
* - `snapshots` only contains the fiber tree at the START of the profile.
* Fibers that mount DURING the profile (the common case for scroll work)
* have their displayName encoded into the per-commit `operations` stream
* as length-prefixed UTF-8 entries. We decode that stream so message
* rows etc. get real names, not `<unknown-NNN>`.
* - Operation codes (React DevTools constants):
* 1 = TREE_OPERATION_ADD
* 2 = TREE_OPERATION_REMOVE
* 3 = TREE_OPERATION_REORDER_CHILDREN
* 4 = TREE_OPERATION_UPDATE_TREE_BASE_DURATION
* 5 = TREE_OPERATION_REMOVE_ROOT
* 6 = TREE_OPERATION_SET_SUBTREE_MODE
*/
const fs = require('fs');
const path = require('path');
const profilePath = process.argv[2];
if (!profilePath) {
console.error('usage: node perf/analyze-react-profile.js <profile.json>');
process.exit(1);
}
const data = JSON.parse(fs.readFileSync(profilePath, 'utf8'));
const root = data.dataForRoots[0];
const fiberInfo = new Map();
for (const [id, info] of root.snapshots) {
fiberInfo.set(id, info);
}
const OP_ADD = 1;
const OP_REMOVE = 2;
const OP_REORDER = 3;
const OP_UPDATE_BASE = 4;
const OP_REMOVE_ROOT = 5;
const OP_SET_SUBTREE_MODE = 6;
function decodeOperations(ops) {
const stringTableLen = ops[2];
let i = 3;
const strings = [null];
const tableEnd = i + stringTableLen;
while (i < tableEnd) {
const len = ops[i++];
const bytes = ops.slice(i, i + len);
i += len;
strings.push(Buffer.from(bytes).toString('utf8'));
}
while (i < ops.length) {
const code = ops[i++];
switch (code) {
case OP_ADD: {
const id = ops[i++];
const type = ops[i++];
if (type === 11) {
i += 5;
if (!fiberInfo.has(id)) fiberInfo.set(id, { id, type, displayName: 'Root' });
} else {
const parentID = ops[i++];
const ownerID = ops[i++];
const displayNameStringID = ops[i++];
const keyStringID = ops[i++];
const displayName = strings[displayNameStringID] || null;
const key = strings[keyStringID] || null;
if (!fiberInfo.has(id)) {
fiberInfo.set(id, { id, type, parentID, ownerID, displayName, key });
} else {
const existing = fiberInfo.get(id);
if (!existing.displayName && displayName) existing.displayName = displayName;
}
}
break;
}
case OP_REMOVE: {
const n = ops[i++];
i += n;
break;
}
case OP_REORDER: {
i++;
const n = ops[i++];
i += n;
break;
}
case OP_UPDATE_BASE: {
i += 2;
break;
}
case OP_REMOVE_ROOT: {
break;
}
case OP_SET_SUBTREE_MODE: {
i += 2;
break;
}
default: {
return;
}
}
}
}
for (const ops of root.operations) {
try {
decodeOperations(ops);
} catch (e) {
}
}
const nameOf = (id) => {
const info = fiberInfo.get(id);
if (!info) return `<unknown-${id}>`;
return info.displayName || `<anon-type-${info.type}-${id}>`;
};
const perName = new Map();
function bump(name, fiberId, selfMs, actualMs) {
let e = perName.get(name);
if (!e) {
e = { selfMs: 0, actualMs: 0, renderCount: 0, fiberIds: new Set(), maxSelf: 0, maxActual: 0 };
perName.set(name, e);
}
e.selfMs += selfMs;
e.actualMs += actualMs;
e.renderCount += 1;
e.fiberIds.add(fiberId);
if (selfMs > e.maxSelf) e.maxSelf = selfMs;
if (actualMs > e.maxActual) e.maxActual = actualMs;
}
const commits = [];
let totalCommitDuration = 0;
let earliestTs = Infinity;
let latestTs = -Infinity;
for (const commit of root.commitData) {
const selfMap = new Map(commit.fiberSelfDurations || []);
const actualMap = new Map(commit.fiberActualDurations || []);
const ids = new Set([...selfMap.keys(), ...actualMap.keys()]);
let commitSelf = 0;
const commitContributors = [];
for (const id of ids) {
const name = nameOf(id);
const selfMs = selfMap.get(id) ?? 0;
const actualMs = actualMap.get(id) ?? 0;
bump(name, id, selfMs, actualMs);
commitSelf += selfMs;
commitContributors.push({ name, id, selfMs, actualMs });
}
commits.push({
duration: commit.duration,
effectDuration: commit.effectDuration,
passiveEffectDuration: commit.passiveEffectDuration,
priorityLevel: commit.priorityLevel,
timestamp: commit.timestamp,
commitSelf,
contributors: commitContributors,
updaters: commit.updaters || [],
});
totalCommitDuration += commit.duration || 0;
if (commit.timestamp < earliestTs) earliestTs = commit.timestamp;
if (commit.timestamp > latestTs) latestTs = commit.timestamp;
}
const pad = (s, n) => String(s).padEnd(n);
const padR = (s, n) => String(s).padStart(n);
const fmt = (ms) => `${ms.toFixed(2)}ms`;
console.log(`\n=== Profile summary ===`);
console.log(`File: ${path.resolve(profilePath)}`);
console.log(`Root: ${root.displayName} (rootID=${root.rootID})`);
console.log(`Commits: ${commits.length}`);
console.log(`Wall-time span: ${(latestTs - earliestTs).toFixed(0)} ms`);
console.log(`Sum of commit durations: ${totalCommitDuration.toFixed(1)} ms`);
console.log(`Avg commit duration: ${(totalCommitDuration / commits.length).toFixed(2)} ms`);
console.log(
`Resolved fiber names: ${[...fiberInfo.values()].filter((f) => f.displayName).length} / ${fiberInfo.size}`,
);
const allEntries = [...perName.entries()].map(([name, e]) => ({ name, ...e }));
console.log(`\n=== Top 40 components by total SELF time ===`);
console.log(
`${pad('Component', 50)} ${padR('SelfMs', 10)} ${padR('Renders', 8)} ${padR('Avg', 8)} ${padR('Max', 8)}`,
);
allEntries
.sort((a, b) => b.selfMs - a.selfMs)
.slice(0, 40)
.forEach((e) => {
console.log(
`${pad(e.name.slice(0, 50), 50)} ${padR(e.selfMs.toFixed(2), 10)} ${padR(e.renderCount, 8)} ${padR((e.selfMs / e.renderCount).toFixed(3), 8)} ${padR(e.maxSelf.toFixed(2), 8)}`,
);
});
console.log(`\n=== Top 25 components by total ACTUAL time (incl. children) ===`);
console.log(
`${pad('Component', 50)} ${padR('ActualMs', 10)} ${padR('Renders', 8)} ${padR('Avg', 8)} ${padR('Max', 8)}`,
);
allEntries
.sort((a, b) => b.actualMs - a.actualMs)
.slice(0, 25)
.forEach((e) => {
console.log(
`${pad(e.name.slice(0, 50), 50)} ${padR(e.actualMs.toFixed(2), 10)} ${padR(e.renderCount, 8)} ${padR((e.actualMs / e.renderCount).toFixed(3), 8)} ${padR(e.maxActual.toFixed(2), 8)}`,
);
});
console.log(`\n=== Top 25 by AVERAGE self time per render (min 3 renders) ===`);
console.log(`${pad('Component', 50)} ${padR('Avg', 10)} ${padR('Max', 10)} ${padR('Renders', 8)}`);
allEntries
.filter((e) => e.renderCount >= 3)
.sort((a, b) => b.selfMs / b.renderCount - a.selfMs / a.renderCount)
.slice(0, 25)
.forEach((e) => {
console.log(
`${pad(e.name.slice(0, 50), 50)} ${padR((e.selfMs / e.renderCount).toFixed(3), 10)} ${padR(e.maxSelf.toFixed(2), 10)} ${padR(e.renderCount, 8)}`,
);
});
console.log(`\n=== Top 25 single-render hits across all commits ===`);
console.log(`${pad('Component', 50)} ${padR('SelfMs', 10)} ${padR('ActualMs', 10)} (commit ts)`);
const allHits = [];
for (const c of commits) {
for (const x of c.contributors) {
allHits.push({ ...x, ts: c.timestamp });
}
}
allHits
.sort((a, b) => b.selfMs - a.selfMs)
.slice(0, 25)
.forEach((h) => {
console.log(
`${pad(h.name.slice(0, 50), 50)} ${padR(h.selfMs.toFixed(2), 10)} ${padR(h.actualMs.toFixed(2), 10)} (t=${h.ts.toFixed(0)})`,
);
});
console.log(`\n=== Top 12 SLOWEST commits ===`);
commits
.slice()
.sort((a, b) => (b.duration || 0) - (a.duration || 0))
.slice(0, 12)
.forEach((c, i) => {
const top = c.contributors
.slice()
.sort((a, b) => b.selfMs - a.selfMs)
.slice(0, 6)
.map((x) => `${x.name}@${x.selfMs.toFixed(1)}`)
.join(', ');
const updaters = (c.updaters || [])
.map((u) => u.displayName || `<${u.id}>`)
.slice(0, 4)
.join(', ');
console.log(
`${i + 1}. duration=${fmt(c.duration || 0)} (self=${fmt(c.commitSelf)}, ${c.contributors.length} fibers, prio=${c.priorityLevel}, t=${c.timestamp.toFixed(0)})`,
);
console.log(` updaters: ${updaters || '(none recorded)'}`);
console.log(` top self: ${top}`);
});
console.log(`\n=== Top 20 UPDATERS by # of commits triggered ===`);
const updaterCount = new Map();
for (const c of commits) {
for (const u of c.updaters || []) {
const k = u.displayName || `<anon-${u.id}>`;
updaterCount.set(k, (updaterCount.get(k) || 0) + 1);
}
}
[...updaterCount.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 20)
.forEach(([name, n]) => {
console.log(`${pad(name.slice(0, 60), 60)} ${padR(n, 6)}`);
});
const messageNamesRe =
/^(Message|MessageWithContext|MemoizedMessage|MessageItemView|MessageItemViewWithContext|MemoizedMessageItemView|MessageContent|MessageContentWithContext|MemoizedMessageContent|MessageFooter|MessageTextContainer|MessageBubble|SwipableMessageWrapper|MessageWrapper|MessageAvatar|MessageAuthor|MessageStatus|MessageTimestamp|MessageReplies|MessageHeader|MessageList|MessageListWithContext|MessageFlashList|MessageFlashListWithContext|MessageSimple|MessageInput|MessageInputWithContext|TypingIndicator|ReactionList)$/;
console.log(`\n=== Message-subtree focus (all matching components, by self time) ===`);
console.log(
`${pad('Component', 50)} ${padR('SelfMs', 10)} ${padR('ActualMs', 10)} ${padR('Renders', 8)} ${padR('Avg', 8)} ${padR('Max', 8)}`,
);
allEntries
.filter((e) => messageNamesRe.test(e.name))
.sort((a, b) => b.selfMs - a.selfMs)
.forEach((e) => {
console.log(
`${pad(e.name.slice(0, 50), 50)} ${padR(e.selfMs.toFixed(2), 10)} ${padR(e.actualMs.toFixed(2), 10)} ${padR(e.renderCount, 8)} ${padR((e.selfMs / e.renderCount).toFixed(3), 8)} ${padR(e.maxSelf.toFixed(2), 8)}`,
);
});
const heuristics = [
{ label: 'Markdown', re: /Markdown|markdown|MDX|renderText|MDRender/i },
{ label: 'Reanimated', re: /Animated|Reanimated|SharedValue/i },
{ label: 'Gesture/RNGH', re: /Gesture|GestureDetector|GestureHandler/i },
{ label: 'FlatList/FlashList', re: /FlatList|FlashList|VirtualizedList|CellRenderer/i },
{ label: 'Image', re: /Image|FastImage|Gallery/i },
{ label: 'Pressable/Touchable', re: /^Pressable$|TouchableOpacity|TouchableHighlight/ },
{ label: 'Context.Provider', re: /Provider$/ },
{ label: 'Memo/ForwardRef wrappers', re: /^Memo|^ForwardRef/ },
{ label: 'Stream Channel*/Chat*/Thread*', re: /^(Channel|Chat|Thread)/ },
{ label: 'Stream Message*', re: /^Message/ },
{ label: 'Reactions', re: /Reaction/ },
{ label: 'Attachments', re: /Attachment|Audio|Video|File|Card/i },
{ label: 'Poll', re: /Poll/ },
{ label: 'Avatar / Author / Status', re: /Avatar|Author|Status/ },
];
console.log(`\n=== Heuristic buckets — total self time across bucket ===`);
console.log(`${pad('Bucket', 42)} ${padR('SelfMs', 8)} ${padR('Renders', 8)} top members`);
for (const h of heuristics) {
const hits = allEntries.filter((e) => h.re.test(e.name));
const sumSelf = hits.reduce((s, x) => s + x.selfMs, 0);
const sumRenders = hits.reduce((s, x) => s + x.renderCount, 0);
const topNames = hits
.slice()
.sort((a, b) => b.selfMs - a.selfMs)
.slice(0, 6)
.map((x) => `${x.name}(${x.selfMs.toFixed(0)}ms,${x.renderCount}r)`)
.join(', ');
console.log(
`${pad(h.label, 42)} ${padR(sumSelf.toFixed(1), 8)} ${padR(sumRenders, 8)} ${topNames}`,
);
}
console.log(`\n=== Components rendered with the most distinct fiber instances ===`);
console.log(
`${pad('Component', 50)} ${padR('Fibers', 8)} ${padR('Renders', 8)} ${padR('SelfMs', 10)}`,
);
allEntries
.sort((a, b) => b.fiberIds.size - a.fiberIds.size)
.slice(0, 25)
.forEach((e) => {
console.log(
`${pad(e.name.slice(0, 50), 50)} ${padR(e.fiberIds.size, 8)} ${padR(e.renderCount, 8)} ${padR(e.selfMs.toFixed(2), 10)}`,
);
});
console.log(`\n=== Done ===\n`);