Configurable embedded test case
I set up a test case with some parameters to measure the average render time, both for the memo and non-memo version of otherwise the same component. You can try it by running the code snippet below. It doesn't serve as a definitive answer, rather it helps demonstrate how different circumstances affect performance.
If you only change the "with memo" checkbox, it preserves the average times so you can compare both. Changing any other setting will reset the counters.
Note there's a small constant amount of time added by the input elements, but it's quite stable and doesn't prevent you from observing the relative impact of parameters. Though feel free to adjust this answer if you can make it more reliable/accurate.
For the memo version it also keeps track of the average time for hits and misses separately. A hit means the memo was able to skip a render.
You can set how frequently the memo component needs to run (default every 10 renders).
Finally, assuming the change detection algorithm would stop as soon as any difference is detected, I added a parameter to reduce the amount of props being changed. You can turn it up to 100% to make it change only a single prop, or 0% (default) to change all props. The last prop is always changed. So far this didn't seem to result in any measurable change. You can probably just ignore this setting.
Limitations
The props are simple equally sized strings, which probably makes the comparison operation easier than it is in real world cases.
Some timing info is written in an effect as to not "disturb" React too much. As a result some stale time info stays displayed until overwritten.
The very first render is considerably slower, you'll have to run some renders afterwards to cancel it out.
Accuracy
This is not meant as an accurate measurement, rather a way to compare the behavior of the 2 variants under otherwise similar circumstances.
While the App component has some expensive logic, the time is only measured after that. I used an effect for stopping the timer, as it's pretty much the first thing React will do after it's done rendering, should be close enough for this purpose.
My observations
The results of my own testing confirm the current top voted answer. The impact of the additional check is miniscule, even for absurdly large amounts of props, and/or props that are large strings.
If you use memo on a component that changes literally every time (change interval of 1), it can only be slower by a certain amount. So there's a turnover point where you start to gain from it.
However, I found that even if there's a 1 in 2 chance the component will need to render, memo came out favorably.
In fact the impact of using memo is so small that it's hard to observe even with many / big props.
The time avoided by skipping rendering, on the other hand, is measurably increased even for simple components.
You can try it for yourself with different parameters, the check is always much cheaper than the work it avoids. I did not find any configuration where not using memo was faster on average... Or did I?
memo
is slower on extremely large strings*
*If your component doesn't use the large string.
Just as I was about to submit my answer, I tried again increasing the strings to 1 million characters. For the first time memo
was struggling, whereas the component was not.

Even with only 1 in 10 "misses" it's clearly slower on average.
But if you're passing strings of that size as props you probably have more than 1 performance problem, and this likely won't be the biggest.
Also, in the rare case you do need to pass it, it would surely be used by the component. That would likely make it many times slower. Currently the test code doesn't do anything with these large values.
let renderTimes = 0;
let compRenderTimes = 0;
function randomString(length) {
var result = '';
var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
var charactersLength = characters.length;
for ( var i = 0; i < length; i++ ) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}
const {useState, useEffect, memo} = React;
let lastCompRenderStart;
function Comp (props) {
compRenderTimes++;
lastCompRenderStart = performance.now();
// Expensive task.
//console.log(props);
/*Object.entries(props).forEach(([k,v]) => {
if (`${k}${v}` === '4abc') {
alert('What a coincidence!');
}
});*/
useEffect(()=>{
const duration = performance.now() - lastCompRenderStart;
document.getElementById('lastCompRenderTime').innerHTML = duration.toFixed(2);
document.getElementById('nCompRenders').innerHTML = compRenderTimes;
});
return <p className="test">Testing {Object.keys(props).length} props, last rendered {performance.now()}</p>;
};
const MemoComp = memo(Comp);
let lastProps = {};
let lastRenderStart;
let lastWasHit = false;
let currentTotal = 0;
let memoRenders = 0;
let memoHitTotal = 0;
let memoHitRenders = 0;
let memoMissTotal = 0;
let memoMissRenders = 0;
let nomemoRenders = 0;
let nomemoTotal = 0;
function App() {
renderTimes++;
const [,refresh] = useState();
const [propAmount, setPropAmount] = useState(10);
const [propSize, setPropSize] = useState(10);
const [changeInterval, setChangeInterval] = useState(10);
const [changedPropOffset, setChangedPropOffset] = useState(0);
const [withMemo, setWithMemo] = useState(true);
useEffect(()=>{
renderTimes = 1;
compRenderTimes = 1;
currentTotal = 0;
memoRenders = 0;
memoHitTotal = 0;
memoHitRenders = 0;
memoMissTotal = 0;
memoMissRenders = 0;
nomemoRenders = 0;
nomemoTotal = 0;
}, [propAmount, propSize, changeInterval, changedPropOffset]);
let props = {};
lastWasHit = renderTimes !== 1 && renderTimes % changeInterval !== 0;
if (lastWasHit) {
// Reuse last props;
props = lastProps;
} else {
// Generate random new values after offset.
for (let i = 0; i < propAmount; i++) {
if (!!lastProps[i] && (i * 100 / propAmount < changedPropOffset) && i < propAmount - 1) {
props[i] = lastProps[i];
} else {
props[i] = randomString(propSize);
}
}
lastProps = props;
}
useEffect(()=>{
const duration = performance.now() - lastRenderStart;
document.getElementById('lastRenderTime').innerHTML = duration.toFixed(2);
if (!withMemo) {
nomemoRenders++;
nomemoTotal += duration;
document.getElementById('no-memo-renders').innerHTML = nomemoRenders;
document.getElementById('average-no-memo').innerHTML = (nomemoTotal / nomemoRenders).toFixed(2);
} else {
memoRenders++;
currentTotal += duration;
document.getElementById('memo-renders').innerHTML = memoRenders;
document.getElementById('average').innerHTML = (currentTotal / memoRenders).toFixed(2);
if (lastWasHit) {
memoHitRenders++;
memoHitTotal += duration;
document.getElementById('average-memo-hit').innerHTML = (memoHitTotal / memoHitRenders).toFixed(2);
} else {
memoMissRenders++;
document.getElementById('memo-renders-miss').innerHTML = memoMissRenders;
memoMissTotal += duration;
document.getElementById('average-memo-miss').innerHTML = (memoMissTotal / memoMissRenders).toFixed(2);
}
}
});
const ActualComp = withMemo ? MemoComp : Comp;
// This should give us the time needed for rendering the rest.
// I assume the settings inputs have has a more or less constant small impact on performance, at least while they're not being changed.
lastRenderStart = performance.now();
return <div>
<button onClick={() => refresh(renderTimes)} title='Trigger a render of App component'>render</button>
<input type='checkbox' onChange={event=>setWithMemo(!withMemo)} checked={withMemo}/>
<label onClick={event=>setWithMemo(!withMemo)}>
with memo -
</label>
- Prop amount: <input type='number' title='How many props are passed to memoed component' value={propAmount} onChange={event=>setPropAmount(event.target.value)}/>
Prop size: <input type='number' title='How many random characters in prop value string?' value={propSize} onChange={event=>setPropSize(event.target.value)}/><br/>
Change interval: <input type='number' title='Change memoized component props every X renders.' value={changeInterval} onChange={event=>setChangeInterval(event.target.value)}/>
Changed prop offset <input type='number' min={0} max={100} step={10} title='Leave the first X percent of the props unchanged. To test if props comparison algorithm is affected by how fast it can find a difference. The last prop is always changed.' value={changedPropOffset} onChange={event=>setChangedPropOffset(event.target.value)}/>
<ActualComp {...props} />
</div>;
};
ReactDOM.render(<App/>, document.getElementById('root'));
#lastRenderTime {
background: yellow;
}
#lastCompRenderTime {
background: lightblue;
}
.test {
background: lightgrey;
border-radius: 4px;
}
td {
border: 1px solid lightgrey;
padding: 4px;
}
input[type=number] {
max-width: 72px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js"></script>
<div id="root"></div>
<table>
<th>
<td>N renders</td>
<td>Average duration</td>
<td>Memo (hit) duration</td>
<td>Memo (miss) duration</td>
</th>
<tr>
<tr>
<td>No memo</td>
<td><span id="no-memo-renders"></span></td>
<td><span id="average-no-memo"></span></td>
</tr>
<tr>
<td>memo</td>
<td><span id="memo-renders"></span>, <span id="memo-renders-miss"></span> miss</td>
<td><span id="average"></span></td>
<td><span id="average-memo-hit"></span></td>
<td><span id="average-memo-miss"></span></td>
</tr>
</table>
=====
<table>
<tr>
<td>Last rendered App</td>
<td><span id="lastRenderTime"></span></td>
</tr>
<tr>
<td>Component renders</td>
<td><span id="nCompRenders"></span></td>
</tr>
<tr>
<td>Last rendered Component</td>
<td><span id="lastCompRenderTime"></span></td>
</tr>
</table>