> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# The Hydration Mismatch That Only Happened With an Ad Blocker
- URL: https://debugly.dev/react-hydration-ad-blocker-mismatch/
- Published: 2026-10-09T12:16:00.000Z
- Updated: 2026-10-10T13:36:08.000Z
- Description: Support got screenshots of a broken header that no one could reproduce, until someone enabled an ad blocker. The client side DOM was being edited after…
- Author: Rohit Bhadani
- Tags: Error Autopsy, React, JavaScript

The bug report came with a screenshot of a header rendered twice, with a hole where the navigation should be, and the reproduction steps that finally worked were "install an ad blocker". Without one, the page was perfect in every browser we owned. With one, React logged a hydration mismatch and patched the tree into the broken shape the user photographed. The unsettling part was that the ad blocker never touched our code. It removed a node, and that removal was enough to desynchronise two renders that were supposed to agree.

This is the autopsy of a hydration mismatch whose cause lived outside the application, and of the assumption that broke: that the DOM the server sends is the DOM the client hydrates.

This was a Next.js 15.4 app with React 19.1, observed in Chrome 133 with a common blocker extension, and the mechanism generalises to any client side DOM mutation between render and hydration.

## The short version

Hydration requires the client's first render to produce a tree that matches the server's HTML, node for node, because React reuses the server DOM instead of rebuilding it. An ad blocker runs before or during hydration and deletes elements it recognises, typically wrappers whose class or attribute matches a filter. The client then renders expecting the node to exist, finds a different tree, and React's recovery, which is to discard and re render the mismatched subtree, produces the visible breakage. The mismatch error is the symptom. The cause is a third party editing the DOM in the window where React assumes sole authorship.

Our specific case was a sponsored block whose container class matched a cosmetic filter, so the blocker removed it on some lists, and the header's layout logic measured the missing node and rendered a fallback, doubling the bar.

## Why it only happened for some users

The failure needed three things to align: a blocker installed, a filter list that matched our markup, and the removal happening before hydration settled. That intersection is a minority of users, which is why the internal reproduction rate was zero and the support volume was real. This is the sampling blindness from [serving a 2400px image to a 390px phone](https://debugly.dev/shopify-image-srcset-oversized-mobile/), where the team's devices are not the population, applied to browser extensions.

It is also why the error was intermittent even for affected users: filter lists update, and a class rename on our side could move us in or out of a list, flipping the bug on and off with no deploy of our own.

## Why React's error points at the wrong layer

The console said hydration failed and showed a component stack, which sends the engineer into the component, hunting for a server client divergence in props or date formatting, the usual suspects from [react hydration failed and the mismatch causes](https://debugly.dev/react-hydration-failed-mismatch-causes/). But the props were identical. The divergence was in the DOM, not the tree, and no amount of component inspection shows a node that an extension deleted, because by the time you inspect, the page has re rendered and the evidence is gone.

The diagnosis required observing the DOM at hydration time with the blocker active, comparing the server HTML to the live DOM before React finished, and seeing the missing node. The mismatch is a diff between two artefacts, and you have to capture both.

## The causes, ranked

### 1\. Client side DOM mutation before hydration

The primary cause. Any script that edits the DOM in the hydration window, a blocker, a translator, a privacy tool, a greedy tag manager, creates the same desynchronisation. The application assumed an untouched DOM, which is an assumption about the user's browser, not about your code.

### 2\. Markup that matches cosmetic filters

The container used a class name common to ad scaffolding, so it was a target. The markup choice decided whether the third party intervened, which means class naming is, slightly absurdly, a reliability decision.

### 3\. Layout logic that measured the DOM at mount

The header measured the sponsored node during mount and rendered a fallback when absent, amplifying a removed node into a duplicated bar. Measuring external DOM during the fragile window turns a deletion into a layout bug.

## The fix

The immediate fix renamed the container out of the filter lists and stopped measuring the node during mount, deferring layout decisions to after hydration with a resize observer, which is sturdy to the node appearing or disappearing at any time.

The durable fixes were three. First, mark the hydration sensitive regions and keep them free of third party mutable markup, so the tree React reuses is stable. Second, treat hydration mismatches as an environment signal: log the user agent and extension heuristics with the error, so the support team can ask the right question first. Third, add a test that mutates the DOM before hydration, deleting a node, and asserts the app recovers gracefully rather than doubling the header, because the hostile browser is a supported environment whether you test it or not.

## What I would do differently

I would have reproduced with the population, not the team, by running the smoke suite under a couple of common blocker profiles, because the failure was deterministic given the extension, and the extension was common. The gap was not in the code's logic but in the reproduction matrix, which excluded the very condition that defined the bug.

I would also have named the assumption in the code: the header's measurement depended on a stable DOM, and assumptions that depend on the user's browser belong in a comment and a test, not in silence.

## The rule I keep

Hydration assumes the client renders into the DOM the server sent, and any client side editor, blocker, translator, tag manager, breaks that assumption before your code runs. Keep hydration sensitive regions free of mutable third party markup, defer DOM measurement past the fragile window, and test with the hostile browser, because the mismatch error names your component while the cause is an extension you will never install.

The DOM is not yours during hydration. It is a shared document, and the shared document has other authors, which is the same lesson as [the app embed block that moved to the top of every page](https://debugly.dev/shopify-theme-app-embed-block-order/), where an outside hand reordered what you thought you owned.