Published July 29, 2026 · 3 min read
A ref in the dependency array and a route that commits before its content arrives. It passed every manual test and failed eight times out of eight under parallel load.
This one cost me an evening, and the reason it took an evening is that it passed every time I tested it by hand.
The site has a sticky navigation bar that inverts its colours when it sits over a section that is dark by design on an otherwise light page. A hook finds the marked sections, watches them with an IntersectionObserver, and flips a flag. On the home page it worked perfectly.
Then someone loaded a different route first, navigated home, and the bar rendered black links on a black background.
Bug one: the dependency array
The hook took a ref to the nav element as its only dependency.
useEffect(() => {
// find sections, observe them
}, [navRef]);A ref object is stable for the lifetime of the component, and the navbar lives in the root layout, so it never unmounts. Between those two facts, this effect ran exactly once per session. Land on a page with no marked sections, take the early return, and the subscription is never rebuilt — navigate to the home page afterwards and the bar has no idea the dark section exists.
The mirror image is worse. Load the home page, navigate away, and the observed elements are now detached from the document. A detached element returns a zero rect from getBoundingClientRect, and depending on how your geometry check is written, zero can read as "overlapping". The flag latches on and every other page renders an inverted bar.
The fix is a dependency that actually changes when the page does:
useEffect(() => {
// ...
}, [navRef, pathname]);A sibling hook in the same folder had been keyed on the pathname from the start. The two were written weeks apart and nobody noticed they disagreed.
Bug two: the route commits before its content arrives
The dependency fix was not enough, and this is the part worth the article.
The App Router commits the new pathname before the incoming page's payload has finished streaming. So the effect re-runs — correctly, on the new route — and queries a document that does not contain the new page yet. It finds nothing, takes the early return, and by the time the section appears there is no effect left to notice it.
Sequentially this passes, because the payload is already cached and arrives fast enough. Under parallel load it failed eight times out of eight. That is the shape of the bug: it is a race, and running your test suite one file at a time hides it.
The fix is to stop assuming the DOM is final when the effect runs:
const mutations = new MutationObserver(() => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
if (attach()) mutations.disconnect();
});
});
if (!attach()) {
mutations.observe(document.body, { childList: true, subtree: true });
}Three things make this cheap rather than reckless. It only starts if the first attempt found nothing, so a direct page load never uses it. It disconnects the moment a section turns up, so it lives for the gap between route commit and content arrival and no longer. And the callback is coalesced into one animation frame, so a burst of mutations costs one attribute query rather than hundreds.
What I took from it
Two things, and neither is about observers.
A ref in a dependency array is almost always a mistake. It looks like a dependency and it is typed like one, but it never changes, so what you have written is an empty array with extra steps. If the effect needs to re-run when the route changes, the route has to be in the array.
And a test that passes sequentially and fails in parallel is not flaky. It is a race condition that your sequential run happened to win. The regression test for this one has to navigate client-side to reproduce it at all — a direct load passes even when the code is broken, which is exactly why the bug shipped.
useEffect reference: https://react.dev/reference/react/useEffect MutationObserver: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver IntersectionObserver: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API Next.js App Router: https://nextjs.org/docs/app
If this is the kind of problem you are dealing with — type safety, forms, or a codebase that keeps surprising you — a short call is the fastest way to find out whether I can help.