Hydration Mismatches — Finding and Fixing What the Server and Browser Disagree On
Photo by Jan van der Wolf on Pexels
Part 11 solved one version of a problem by transferring the experiment assignment instead of recomputing it. This article is about the general case: values the server used, recomputed in the browser, giving you a page that was correct when it arrived and is wrong a moment later. This is the bug class that server-side rendering adds to a Vue application, and it’s well worth learning as a category rather than as a string of individual incidents.
The example is a labelled fiction, built out of the document application I’ve been using throughout the series.
Table of Contents
- A Page That Is Correct Until It Starts
- What the Hydration Contract Requires
- Debugging the Example
- Five Categories of Mismatch
- The Rule Underneath All Five
- Events Emitted During Server Rendering
- Ways of Hiding the Problem
- Writing It Down: The Cookbook
- The Example Resolved
- What’s Next
A Page That Is Correct Until It Starts
Our revision-comparison page renders two document revisions side by side. Next to the newer one, a badge reads “Current as of today” whenever that revision’s validity period includes the current date.
Server-rendered, it’s right. Then the page finishes loading, the badge disappears, the layout shifts slightly as the heading reflows, and the console has a hydration mismatch warning pointing at a <span>.
And nothing is broken in any way a test managed to catch. The server produced correct HTML, the browser produced correct HTML, and the two of them disagreed. Because the check was written as a comparison against the browser’s clock and then evaluated twice: once in a container running in UTC, and once in a browser in a timezone where “today” is already tomorrow.
That’s the shape of very nearly every hydration bug: a value that isn’t a function of the transferred state.
What the Hydration Contract Requires
When Vue hydrates, it walks the existing DOM and attaches its component tree to it rather than creating elements itself. The contract is that the client’s first render must produce the tree the server already sent. Hydration is an attachment step rather than a rendering step, and its entire benefit, so no second render, no flash and no re-layout, depends on that assumption holding.
When it doesn’t hold, Vue reports a mismatch and recovers as best it can, and that’s where the symptoms come from. Depending on where the difference sits, you can get a subtree re-rendered on the client (the visible flicker and layout shift), event listeners attached to elements the server produced for a different state, or state and DOM that quietly disagree without another word of complaint. That last one is the dangerous case: nothing looks wrong at all until a user interacts with a control whose handler believes something the screen isn’t showing.
Two properties of this bug class explain why it sails through normal testing. It can’t occur in a client-only application at all, so any component-level test that mounts in a browser will pass. And it’s frequently environment-dependent: the example above never reproduces for a developer whose machine happens to be in UTC.
Debugging the Example
A hydration warning tells you where the DOM diverged and almost never why. The node it names may sit several layers below the component whose state was actually wrong. Three techniques narrow that down, in increasing order of effort.
Compare the two outputs directly. Fetch the page without executing any JavaScript, so curl the URL or view source rather than opening the inspector, and compare that markup against what the DOM contains after hydration. The difference is your symptom stated precisely, and that’s usually enough to recognize the category. In our example, the server HTML has a <span> that the hydrated DOM doesn’t.
Read the payload. Everything the server transferred is sitting in the serialized payload. If the value driving the difference isn’t in there, then that’s your answer: the client had to recompute it, and this article’s rule says it shouldn’t have had to.
Bisect with an explicit boundary. Wrapping a suspect region in <ClientOnly> makes the warning go away if the cause is inside it, which localizes the problem nice and quickly. This is a diagnostic step and not a fix, because the section you used it on is no longer server-rendered, which is a genuine loss and the subject of a later section.
Applied to our badge, the first technique shows up the missing span, the second shows that isCurrent was never transferred, and the fix then follows from the category rather than from any cleverness on your part.
Five Categories of Mismatch
Almost every mismatch I’d expect to run into in a Nuxt application falls into one of five groups, and naming the group is most of the work done.
Non-deterministic values. Math.random(), crypto.randomUUID(), Date.now() and anything derived from them, used in setup() or in a template. Generated element ids are the classic case, because they look like an implementation detail and then end up in the markup. Nuxt’s useId() produces ids that are stable across server and client, and for other values you compute once on the server and transfer the result.
State mutated during child setup. If a child modifies parent state while it’s setting up, then the order in which that happens relative to the parent’s render can differ between server and client. So the parent can render before the mutation on one side and after it on the other. Initializing shared state with useState, rather than assembling it through emits during setup, gets rid of the ordering dependency entirely.
Teleported content. Vue’s <Teleport> lets a component declare its markup in one place in the component tree and then have it appear somewhere else entirely in the document, typically at the end of <body>. That’s how a dialog escapes a parent with overflow: hidden, or a stacking context that would clip it. The catch during SSR is that the two sides place it differently: the server emits the content where the component sits, and the browser moves it to the target on mount, so the DOM Vue expects to attach to isn’t the DOM it finds. Modals and overlay layers are the usual suspects, and an explicit client-only boundary is the right answer here, since a modal’s markup is worth nothing in the initial HTML anyway.
Client-only initialization that affects the first render. A panel whose isOpen is false on the server and gets set to true in mounted renders two different class lists. The fix is all about timing: the initial value has to match what the server rendered, and the change belongs after hydration rather than during it.
Async resolution order. Several useAsyncData calls feeding one computed value can resolve in a different order in the browser than they did on the server, so the computed value passes through intermediate states and the first client render can end up built from a partial picture. Keeping the data flow top-down from transferred state, and avoiding computed values derived from partially resolved async results, is what makes this deterministic.
The Rule Underneath All Five
Those five categories are really one rule with five symptoms:
Any value the server used to produce markup must be transferred to the client. Any value that genuinely cannot be known on the server belongs behind an explicit client-only boundary.
That’s the whole contract, and it turns hydration debugging into a question with a mechanical answer: for the value that differs, which of the two kinds is it? Our badge’s “is this revision current” is a server decision, so evaluate it once during SSR, put the result into transferred state, and let the browser read it. A dependency on the visitor’s viewport width is the other kind entirely: it can’t be known on the server, so a component depending on it either renders a server-safe default and adjusts after hydration, or isn’t server-rendered at all.
And notice that Part 11’s assignment rule and Part 10’s async-condition rule are both instances of exactly this. That’s why I stated them the same way.
Events Emitted During Server Rendering
There’s one timing problem that isn’t a rendering mismatch but belongs squarely in the same family. Part 6 permitted an event bus for the narrow case where a sender mustn’t know its receivers. During SSR the senders run and the receivers don’t exist yet: a module emits while the server renders, the browser subscribes after hydration, and the event happened somewhere in between.
Buffering SSR-emitted events in request-scoped state and replaying them after hydration closes that gap, so module authors don’t have to care which side an event came from. Three conditions make it safe. Handlers have to tolerate a replayed event arriving later than it was emitted, so anything time-sensitive should carry its own timestamp rather than assuming “now”. Ordering has to be preserved, or handlers that depend on sequence get told a different story than the server told. And the buffer is per request, for exactly the reason it’s been per request in every previous article.
And let me be clear that this mechanism only exists because the event bus exists. If the flow could have been a direct call, then replay is machinery you never needed.
Ways of Hiding the Problem
There are three responses that make the warning go away without fixing a thing, and all three of them are common.
Wrapping the region in <ClientOnly>. Legitimate for content that’s genuinely client-specific, and a real loss the rest of the time: that markup is no longer in the server response, so it’s absent for crawlers, absent from the first paint, and rendered late on slow devices. Used as a general remedy, it converts a server-rendered application back into a client-rendered one, one warning at a time.
A custom “skip hydration” directive. Let me state this plainly: Vue does not offer a directive that excludes an arbitrary subtree from hydration, and ordinary directive hooks don’t replace the hydration algorithm. A project that invents such a name owes it documentation, tests, and a clear statement of what happens to event handlers and accessibility behaviour inside the skipped region, because a component that never hydrates is inert markup. And where the goal is to postpone rather than to skip, use the framework’s supported lazy hydration, which is what Part 14 is about.
Silencing the warning. That warning is the only cheap signal this whole bug class produces. Suppress it and you’re left with the silent-divergence variant, which is the version that reaches production and costs you an afternoon to find.
Writing It Down: The Cookbook
Hydration bugs keep coming back with a small number of causes and a very large number of appearances, which makes them unusually well suited to keeping a written record. A useful entry runs to about four lines:
Symptom Hydration node mismatch on a <span>; badge disappears after load
Cause Validity compared against the current clock in setup(), on both sides
Category Non-deterministic value
Fix Evaluate during SSR, transfer the result in state, read it on the client
Prevention Lint rule: no Date.now()/Math.random() in setup or templates
The last line is the one that compounds over time. A few targeted lint rules, so no non-deterministic calls in setup() or templates and no mutation of shared state during child setup, head off the categories that are mechanically detectable. For everything else, a browser test that loads the application’s main routes and fails on console warnings turns hydration mismatches from something a developer happens to notice into something CI reports. That test is cheap to write and it’s the single highest-value check in this whole article, because it catches the silent variety too.
The Example Resolved
So the badge’s fix comes down to two lines and one decision. “Is this revision current” gets evaluated on the server during data fetching, the boolean becomes part of the transferred state, and the component renders from it. The browser no longer holds an opinion about what day it is, so there’s nothing left to disagree about.
What’s left over is a genuine product question the bug had been hiding all along. If the page gets served from a cache, then “current as of today” was decided when the page was rendered rather than when it was read, which is the same time-dependence Part 10 found in date-range conditions. So either the page’s cache lifetime has to be short enough for that to be acceptable, or the badge has to be explicitly client-rendered and labelled as reflecting the visitor’s local time. Both are perfectly defensible. The important part is that it’s now a decision rather than an accident of whichever clock happened to run last.
And here’s the diagnostic worth keeping. When the server and the browser disagree, don’t ask which of them computed the right answer. Ask which of the two was supposed to make that decision at all, and whether its answer was transferred to the other side instead of being worked out twice.
What’s Next
- Part 13: Kinds of Waiting — Where a server-rendered page’s time actually goes, and how to tell kinds of waiting apart.
- Part 14: Deferred Hydration — Postponing hydration deliberately, without making a control inert.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Category: Nuxt in Production