Minimalist photo of a modern blue building facade with geometric design. Hero image credit: Photo by Jan van der Wolf on Pexels

Part 12 of the Nuxt and .NET series.

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, producing a page that was correct when it arrived and is wrong a moment later. It is the bug class that server-side rendering adds to a Vue application, and it is worth learning as a category rather than as a series of incidents.

The example is a labelled fiction, built from the document application used throughout the series.

Table of Contents

A Page That Is Correct Until It Starts

The revision-comparison page renders two document revisions side by side. Next to the newer one, a badge reads “Current as of today” when that revision’s validity period includes the current date.

Server-rendered, it is right. Then the page finishes loading and the badge disappears, the layout shifts slightly as the heading reflows, and the console has a hydration mismatch warning pointing at a <span>.

Nothing is broken in any way a test caught. The server produced correct HTML, the browser produced correct HTML, and the two disagreed — because the check was written as a comparison against the browser’s clock, evaluated twice: once in a container running in UTC, once in a browser in a timezone where “today” is already tomorrow.

sequenceDiagram participant S as Nuxt server participant B as Browser participant V as Vue (hydration) S->>S: isCurrent = validFrom <= now() <= validTo → true S->>B: HTML including the badge B->>B: Badge is visible and correct B->>V: Bundles execute, hydration begins V->>V: Same component, now() differs → isCurrent = false V->>B: Mismatch warning, subtree re-rendered without the badge B->>B: Badge vanishes and the layout shifts

That is the shape of nearly every hydration bug: a value that is not 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. The contract is that the client’s first render must produce the tree the server already sent. Hydration is an attachment step, not a rendering step, and its entire benefit — no second render, no flash, no re-layout — depends on that assumption holding.

When it does not hold, Vue reports a mismatch and recovers as best it can, which is where the symptoms come from. Depending on where the difference is, 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 with no further complaint. That last case is the dangerous one: nothing looks wrong until a user interacts with a control whose handler believes something the screen does not show.

Two properties of this bug class explain why it survives normal testing. It cannot occur in a client-only application, so any component-level test that mounts in a browser will pass. And it is often environment-dependent — the example above never reproduces for a developer whose machine is in UTC.

Debugging the Example

A hydration warning tells you where the DOM diverged, and almost never why. The node it names may be several layers below the component whose state was wrong. Three techniques narrow that down, in increasing order of effort.

Compare the two outputs directly. Fetch the page without executing JavaScript — curl the URL, or view source rather than the inspector — and compare that markup with what the DOM contains after hydration. The difference is the symptom stated precisely, which is usually enough to recognize the category. In the example, the server HTML has a <span> the hydrated DOM does not.

Read the payload. Everything the server transferred is in the serialized payload. If the value driving the difference is not in there, that is the answer: the client had to recompute it, and this article’s rule says it should not have had to.

Bisect with an explicit boundary. Wrapping a suspect region in <ClientOnly> makes the warning disappear if the cause is inside it, which localizes the problem quickly. This is a diagnostic step, not a fix — the section it is used on is no longer server-rendered, which is a real loss and the subject of a later section.

Applied to the badge, the first technique shows the missing span, the second shows that isCurrent was never transferred, and the fix follows from the category rather than from cleverness.

Five Categories of Mismatch

Almost every mismatch I would expect to meet in a Nuxt application falls into one of five groups, and naming the group is most of the work.

Non-deterministic values. Math.random(), crypto.randomUUID(), Date.now() and anything derived from them, used in setup() or a template. Generated element ids are the classic case, because they look like an implementation detail and end up in the markup. Nuxt’s useId() produces ids that are stable across server and client; for other values, compute once on the server and transfer.

State mutated during child setup. If a child modifies parent state while setting up, 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 removes the ordering dependency.

Teleported content. Content teleported to <body> is rendered in place in the server’s output and relocated in the browser, so the structures differ by construction. Modals and overlay layers are the usual suspects, and an explicit client-only boundary is the appropriate answer here, since a modal’s markup has no value in the initial HTML anyway.

Client-only initialization that affects the first render. A panel whose isOpen is false on the server and set to true in mounted renders two different class lists. The fix is timing: the initial value must match what the server rendered, and the change belongs after hydration, not during it.

Async resolution order. Several useAsyncData calls that feed 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 be built from a partial picture. Keeping 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

The five categories are 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 is the whole contract, and it turns hydration debugging into a question with a mechanical answer: for the value that differs, which of the two is it? The badge’s “is this revision current” is a server decision — evaluate it once, during SSR, put the result in transferred state, and let the browser read it. A dependency on the visitor’s viewport width is the other kind: it cannot be known on the server, so a component that depends on it must either render a server-safe default and adjust after hydration, or not be server-rendered at all.

Notice that Part 11’s assignment rule and Part 10’s async-condition rule are both instances of this. That is why they were stated the same way.

Events Emitted During Server Rendering

There is one timing problem that is not a rendering mismatch but belongs to the same family. Part 6 permitted an event bus for the narrow case where a sender must not know its receivers. During SSR, senders run and receivers do not exist yet: a module emits while the server renders, the browser subscribes after hydration, and the event happened in between.

sequenceDiagram participant M as Module (server) participant ST as Request-scoped buffer participant P as Payload participant L as Listener (browser) M->>ST: emit 'documents:loaded' ST->>P: serialized with the rest of the state Note over L: hydration completes L->>P: read buffered events P-->>L: 'documents:loaded' L->>L: handler runs

Buffering SSR-emitted events in request-scoped state and replaying them after hydration closes the gap, so module authors do not have to care which side an event came from. Three conditions make it safe. Handlers must tolerate a replayed event arriving later than it was emitted — anything time-sensitive should carry its own timestamp rather than assuming “now.” Ordering has to be preserved, or handlers that depend on sequence get a different story than the server told. And the buffer is per request, for the reason it has been per request in every previous article.

It is worth being clear that this mechanism exists because the event bus exists. If the flow could have been a direct call, replay is machinery you did not need.

Ways of Hiding the Problem

Three responses make the warning go away without fixing anything, and all three are common.

Wrapping the region in <ClientOnly>. This is legitimate for content that is genuinely client-specific, and it is a loss the rest of the time: that markup is no longer in the server response, so it is 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. It is worth stating plainly that Vue does not offer a directive which excludes an arbitrary subtree from hydration, and ordinary directive hooks do not 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 is never hydrated is inert markup. Where the goal is to postpone rather than to skip, use the framework’s supported lazy hydration, which Part 14 is about.

Silencing the warning. The warning is the only cheap signal this bug class produces. Suppressing it leaves the silent-divergence variant, which is the version that reaches production and costs an afternoon to find.

Writing It Down: The Cookbook

Hydration bugs recur with a small number of causes and a large number of appearances, which makes them unusually well suited to a written record. A useful entry is 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. A few targeted lint rules — no non-deterministic calls in setup() or templates, no mutation of shared state during child setup — prevent the categories that are mechanically detectable. For the rest, a browser test that loads the application’s main routes and fails on console warnings turns hydration mismatches from something a developer notices into something CI reports. That test is cheap to write and is the single highest-value check in this article, because it catches the silent variety too.

The Example Resolved

The badge’s fix is two lines and one decision. “Is this revision current” is evaluated on the server during data fetching, the boolean is part of the transferred state, and the component renders from it. The browser no longer has an opinion about what day it is, so there is nothing to disagree about.

What remains is a genuine product question the bug was hiding: if the page is served from a cache, “current as of today” was decided when the page was rendered, not when it was read — the same time-dependence Part 10 found in date-range conditions. 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 defensible; the important part is that it is now a decision rather than an accident of which clock ran last.

The diagnostic worth keeping: when the server and the browser disagree, do not ask which one is right. Ask which one was allowed to decide, and whether the answer travelled.

What’s Next


Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.