Search Console told me ten pages on a site I run were duplicates of each other. That report is usually noise — it fires on trailing slashes, on query strings, on pagination. I opened it expecting to close it.
The site is a React single-page app that prerenders static HTML at build time, so crawlers get real content instead of an empty <div>. I fetched the ten flagged URLs to see what the crawler was seeing.
/section/a/first-page 200 6025B
/section/a/second-page 200 6025B
/section/b/third-page 200 6025B
/section/c/fourth-page 200 6025B
Ten different URLs. Ten identical byte counts.
Not similar. Identical — down to the byte, because they were the same file. Every one of them was the homepage, served under a different URL, carrying a <link rel="canonical"> pointing at the homepage. Each of those pages was telling Google, in the crawler's first pass, I am not a page. I am the homepage.
Nothing had failed
Here is what makes this hard to catch, and it is the whole point of this post.
The routes returned 200. The app has a catch-all rewrite, so any path serves the shell and the router sorts it out in the browser. A human visiting one of those URLs saw the correct page, fully rendered, with the correct title in the tab — because after hydration the client-side code sets all of that properly.
The build exited 0. It printed a success line and a route count, and the route count was a number nobody had a reason to question.
The prerender step had a try/catch around the part that loads the page data. When that load failed, the catch logged a warning and returned an empty array. Every downstream route was generated by looping over that array, so an empty array produced zero pages — quietly, with a success exit code.
And the pages that were missing didn't 404. They fell through to the catch-all and served the shell. The failure covered its own tracks.
So: no exception, no red build, no broken link, nothing wrong on screen. Four independent layers, each doing something individually reasonable, and the composite result was a few hundred pages that existed for humans and did not exist for crawlers.
A fallback converts a failure into a plausible lie
This is the reframe I keep coming back to.
A fallback exists to stop a failure from being loud. That is its entire job, and most of the time it is the right job. But the output of a fallback is not an error message — it is a value. A plausible one. Something shaped exactly like a correct answer.
Which means you cannot find this class of bug by looking for failures, because no failure occurred. Your logs are clean by construction. Your monitors are green by construction. The system did not break; it substituted.
So the question stops being what went wrong and becomes something much more mechanical:
What should be different here, and is it?
Uniformity is the signal. Ten pages that should have ten different sizes had one size. That is not a subtle statistical hint — it is a screaming anomaly, and it is invisible unless you go looking for sameness specifically.
Once I started asking that question instead of hunting for errors, the same afternoon turned up three more.
Four bugs, one technique
Identical outputs where inputs differed. The byte counts above. Ten inputs, one output. That found the missing pages.
One half worked and the other didn't, in the same file. Two sections of the same build enriched their pages from different sources — one fetched over HTTP, one needed a bundler to read a local data file. The HTTP one produced 156 links on the live page. The bundler one produced zero. Same file, same build, same run, one worked and one didn't. That is a controlled experiment I got for free, and it isolated the cause to the bundler dependency in about a minute. Without the working half I would have been guessing at the whole pipeline.
A set difference nobody had ever computed. One page linked out to every item in a database table. The router resolved those links against a separate static file. Nobody had ever asked whether those two lists agreed. I subtracted one from the other: ten links pointed at pages that could not exist. Not because of a typo — because the two sources disagreed about spelling in six cases and about categorisation in four. Every one of those links had been manufacturing a fresh duplicate page, from an indexed page, for months.
Arithmetic against a stale premise. A migration script sitting in the repo predicted the table would end up with a particular distribution of values: eleven of one kind, a hundred and seven of another. The live table had twenty-one and ninety-seven. That ten-row gap was the entire finding — a later audit had deliberately moved ten rows, and running the older script would have reverted it. I didn't need to understand either script's reasoning to know something was wrong. The numbers didn't match, and mismatched numbers are a question.
None of those four is clever. All of them are comparisons. That is the only skill involved.
The one that actually mattered
The worst thing I found that day had nothing to do with prerendering, and it was hiding behind the same mechanism.
A classification field in the database had five possible values. It had gained its fifth some months earlier. One function mapped that field to a human-readable label with a switch, and that switch had cases for four of the five. The fifth hit the default branch and came out as the most severe of the other four.
The pages affected were three of the highest-traffic pages on the site. Each of them was displaying a confident, specific, wrong classification. Not a blank. Not a placeholder. The opposite of the truth, stated plainly, in the page title.
Two things kept it invisible. First, the fallback again — the output was a real label, so nothing looked empty or broken. Second, and worse: a different function, the one that picks the colour for the status badge, did have a case for the fifth value. So the page rendered the correct colour next to the incorrect word. Anyone glancing at it saw a styled, complete, confident page.
The tell was in the type signature. The function declared a return type listing all five labels. It could only ever return four. A function whose return type names a value it cannot produce is telling you, in writing, that a case is missing — and TypeScript will not flag it, because returning a subset of a union is perfectly legal.
That is a cheap thing to grep for and I had never thought to.
What I'd actually do differently
Two changes, and one of them is not a technique.
Delete the fallback where the fallback is a lie. The prerender's try/catch now throws, and the build refuses to run if it loads fewer pages than expected. A build that silently drops a few hundred pages and exits 0 is worse than a build that fails, because the failing one costs you ten minutes and the silent one costs you months. That is the change I'd make first in any system: find the places where degraded output is indistinguishable from correct output, and make them loud.
Compare things that should match. Not as a debugging step — as a routine check. Do the links on this page point at pages that exist? Does the sitemap match what the build emitted? Does the static file agree with the database table? Every one of those is a set difference you can compute in a few lines, and every one of them found something real for me. They're boring, they're fast, and they don't require knowing what you're looking for in advance, which is the entire point when the bug produces plausible output.
The non-technique: stop trusting the fact that nothing looks wrong. For this whole class of problem, "nothing looks wrong" is not evidence of health. It's the expected symptom.
The short version
You cannot detect a failure that didn't happen. Every fallback in your system is a place where a failure was quietly converted into a plausible value, and plausible values do not show up in logs, monitors, or a glance at the page.
What they do show up in is comparison. Two things that should differ and don't. Two lists that should match and don't. A number that should be one number and is another.
Ten files with the same byte count is not a subtle clue. It's a bug report — as long as somebody thinks to measure the thing that was never supposed to be uniform.
Top comments (0)