I regularly help people with performance of their React apps. Here’s a common, but surprisingly obscure issue that I keep seeing over and over:
A single hydration mismatch on a page can tank Largest Contentful Paint from green all the way to red
To avoid it, it’s enough to avoid hydration mismatches. But to understand why it’s an issue, you need to learn three separate facts that, together, form a puzzle.
Refresher: what’s a hydration mismatch? Hydration is when React makes server-rendered DOM interactive, going over it, attaching all event listeners (
onClicketc) to the right DOM nodes, and executinguseEffects. Hydration mismatch happens when client-rendered DOM is different from server-rendered DOM – e.g.:function MyButton() { if (typeof window === 'undefined') return <div>Please wait for the app to load...</div>; return <button onClick={() => console.log('Clicked')}>Click me</button>; }In this situation, as React walks DOM, it expects to find a
buttonand attach anonClickto it – but finds adivinstead. This is a mismatch.
Fact 1: Hydration Mismatches Force a New DOM
Hydration mismatches force React to recreate the DOM from scratch. If the server rendered <span>Current time: 10:09</span> but the client rendered <span>Current time: 10:10</span>, React would not simply patch the time, no. It would find the nearest Suspense boundary around this <span> and remount its whole DOM from scratch. If the app has no Suspense boundary, the entire page would remount.
Sidenote: React used to just patch hydration mismatches. But this was killed in React 18 for correctness reasons.
Fact 2: Text Resizes When Fonts Load
Here’s another, unrelated piece of the puzzle.
If you use web fonts (and font-display: swap), it’s very common to see a behavior like this:
Different fonts usually have different physical sizes even with the same font-size. So when a web font loads, all text elements on the page usually change their size.
Fact 3: LCP Measures Only New DOM Nodes
Here’s the third and final piece of this puzzle.
Largest Contentful Paint (LCP) is one of Google’s Core Web Vitals. It’s critical for SEO, and it measures how long it takes for the largest element on the page to render. Here’s how it does that:
- the first time the page becomes visible, the browser finds the largest text element or image on the page and records its size. This element becomes the LCP candidate, and the time when it appeared becomes the LCP time
- whenever a new element gets added into DOM, the browser measures its size. If that element is larger than the previous LCP candidate, it becomes the new LCP candidate, and LCP time increases
- this also happens whenever an image loads
- as soon as the user interacts with the page, the browser stops new measurements
Critically for this puzzle, step 2 only runs when new elements are added. If an existing element becomes larger, the browser ignores that:

Resized Text + DOM Remount = 💥
Now let’s combine all pieces of the puzzle.
What happens when we have a) a text block that becomes larger when fonts load, and b) a hydration mismatch that remounts that text block? Here’s what:

Ouch!
If it wasn’t for a hydration mismatch, LCP would occur when the text first becomes visible. On most web pages and an average 4G connection, this happens around the 1-2 second mark (safely in the green).
Unfortunately, in this case, hydration replaces all elements on the page. To the user, nothing changes: they still see the same text! But to the browser, React just deleted all DOM nodes and added a bunch of completely new nodes. Hence, the browser measures them, finds a text node that’s larger than previously recorded, and updates the LCP value.
Now, due to the mismatch, LCP is registered only when hydration completes. With an average 4G connection and a typical app, this would happen at the 5+ second mark (very much in the red).
How to Deal With This
Avoid hydration mismatches. Fix them if you run into them.
If you can’t: wrap the element that causes a mismatch with <Suspense>. Then React would remount only that <Suspense> boundary (instead of the full DOM). Unless that element is also the LCP element, that would solve the issue.
With thanks to Andy Davies who taught me to inspect LCP candidates in a Chrome trace and helped me discover this.

