Performance regressions often slip past code review, and unless users complain, teams may never learn that the app has slowed down. Relying on complaints is poor quality control, and users cannot be asked to open the React DevTools and profile the app interactively. What is needed is a way to capture render timings and ship them to a server for monitoring.

Framework-agnostic tooling such as Lighthouse CI already covers general performance measurement. React additionally ships an API dedicated to measuring component performance in production. It exposes less detail than the React DevTools, but enough to locate where problems originate.

Enabling React's profiler build is a prerequisite for any of this to work in production. It carries a small performance cost, which is why facebook.com serves the profiler build of its app only to a subset of users.

Basic usage

The React <Profiler /> component is used as follows:

<App>
	<Profiler id="Navigation" onRender={onRenderCallback}>
		<Navigation />
	</Profiler>
	<Main />
</App>

The onRenderCallback function receives:

function onRenderCallback(
	id, // the "id" prop of the Profiler tree that has just committed
	phase, // either "mount" (if the tree just mounted) or "update" (if it re-rendered)
	actualDuration, // time spent rendering the committed update
	baseDuration, // estimated time to render the entire subtree without memoization
	startTime, // when React began rendering this update
	commitTime, // when React committed this update
	interactions, // the Set of interactions belonging to this update
) {
	// Aggregate or log render timings...
}

Unless the app is built with react-dom/profiling and scheduler/tracing-profiling, the component does nothing at all.

Shipping data to a monitoring tool

The callback payload is meant to be forwarded to a monitoring tool such as Grafana. Since re-renders can occur very frequently, batching the data and sending it roughly every 5 seconds is a sensible approach:

let queue = []

// sendProfileQueue every 5 seconds
setInterval(sendProfileQueue, 5000)

function onRenderCallback(
	id,
	phase,
	actualDuration,
	baseDuration,
	startTime,
	commitTime,
	interactions,
) {
	queue.push({
		id,
		phase,
		actualDuration,
		baseDuration,
		startTime,
		commitTime,
		interactions,
	})
}

function sendProfileQueue() {
	if (!queue.length) {
		return Promise.resolve()
	}
	const queueToSend = [...queue]
	queue = []
	// here's where we'd actually make the server call to send the queueToSend
	// data to our backend...
	console.info('sending profile queue', queueToSend)
	return Promise.resolve()
}

Placement and nesting

In production React deliberately limits the information it collects so that measurement itself does not hurt performance. This makes placement a design decision: <Profiler /> components should be positioned strategically and given meaningful id props so that any reported slowdown can be attributed to a known part of the tree.

Profilers can also be nested:

<App>
	<Profiler id="Navigation" onRender={onRenderCallback}>
		<Navigation />
	</Profiler>
	<Profiler id="Main" onRender={onRenderCallback}>
		<Main>
			<LeftNav />
			<Profiler id="Content" onRender={onRenderCallback}>
				<Content />
			</Profiler>
			<RightNav />
		</Main>
	</Profiler>
</App>

Given that structure, a rerender of <Content /> triggers the Content and Main callbacks, but not Navigation. A rerender of <LeftNav /> triggers Main only, leaving Content and Navigation untouched.

Sample output

A captured payload looks like this:

{
  id: "Navigation",
  phase: "update",
  actualDuration: 0.09999994654208422,
  baseDuration: 0.3799999540206045,
  startTime: 104988.11499998556,
  commitTime: 104988.45000000438,
  interactions: [ // this is actually a Set, not an array
    {
      __count: 0
      id: 3,
      name: "menu click",
      timestamp: 104978.33499999251,
    }
  ],
}

Details on the experimental interactions field are available in a separate write-up.

Feeding this data into monitoring software surfaces trends and highlights performance regressions as spikes.