Two weeks ago I shipped a fix to my travel site with a commit message that named the wrong cause.
The headline change was real work: 220 country pages had never been prerendered, so every crawler that asked for one got the raw single-page-app shell — identical bytes for all 220, canonical pointing at the homepage. Google had filed them as duplicates of each other and of the front page, which is exactly what they looked like. Fixing it meant pulling a 2,800-line data structure out of a React component (it carried icon references, so no build script could import it), and the component went from 4,343 lines to 1,417. Call it 4,600 lines changed across nine files.
While wiring that up I found a second bug, unrelated except by proximity, and fixed it in the same commit almost as an afterthought. It was a lookup: one function building a URL from the wrong source. Maybe fifteen lines.
I have two weeks of search data now. The afterthought did most of the work.
What the numbers actually said
The obvious way to check whether a fix worked is to look at the thing you fixed and see if it went up. It went up. I nearly stopped there.
The problem is that everything went up that week — the whole site was 35% ahead, and I hadn’t shipped anything to most of it. So “it went up” was worth nothing on its own. I needed a control: some part of the site with no structural changes in the same window, to measure the tide against the boat.
The blog section was the control — untouched in those weeks. It ran 19% ahead. That’s the tide.
Against that baseline, comparing the eleven days before the deploy to the nine days after:
- The 220 newly prerendered country pages: +41%
- The hub page that links to them: +171%
The hub also moved from an average position in the high teens into the low teens. And here’s the detail that killed my original story: 142 of those 220 country pages were already earning impressions before the fix. Only eight pages showed up for the first time afterward.
So the pages I’d spent two days rescuing from duplicate hell were, mostly, not in duplicate hell. They were doing fine. The page that improved dramatically was the one I’d changed almost by accident.
The fifteen lines
Here’s what the afterthought fixed.
The hub page renders lists of countries, each one a link. It built those links from the database: take the row’s region, take the row’s slug, join them into a path. Straightforward.
But the router doesn’t resolve URLs against the database. It resolves them against a static data file. And ten countries disagreed between the two.
Some disagreed on spelling. The database said united-kingdom, the router knew uk. The database said saint-lucia, the router knew st-lucia.
Some disagreed on something more interesting: which continent a country is in. The database filed Georgia, Armenia and Azerbaijan under Asia. The static file has them in Europe. Both are defensible — that’s a genuinely contested boundary, and the two sources had been populated by different people at different times, each making a reasonable call.
So the hub was emitting links to ten URLs that did not exist.
A dead link that returns 200
If those links had 404’d, I’d have found them in a week. Every link checker I run would have flagged them, and a 404 in a crawl report is unambiguous.
They didn’t 404. The route pattern /world/[continent]/[country] matched fine — asia is a plausible continent string and georgia is a plausible country string. The page rendered. It just had no country to render, so it fell through to the generic world-map copy. Status 200. Real HTML. A <title>, a canonical tag pointing at itself.
That is not a broken link. That is a duplicate factory: a URL that manufactures a near-empty page, declares itself canonical, and gets vouched for by an inbound link from an indexed hub page. Ten of them, all generating the same generic content, all pointing at themselves, all endorsed by the one page in that section with the most authority.
And nothing in my toolchain could see it. The link checker asks “does this return an error?” — no. The typechecker asks “is this a valid string?” — yes. The build asks “did anything throw?” — no. The sitemap didn’t list these URLs, so a sitemap audit wouldn’t surface them either. The only signal was in the crawler’s opinion of the section as a whole, which is not a signal you can grep for.
I’ve written a lot recently about facts stored in multiple places drifting apart. This is the same failure with a different payload: the identifiers drifted, not the facts. And identifier drift is sneakier, because a wrong fact eventually reads wrong to a human, while a wrong slug produces a page that looks perfectly fine — just not the page anyone meant.
When two systems disagree about what a thing is called, you don’t get an error. You get a phantom.
The fix, and what makes it durable
The lookup now resolves the same way the app itself does — same normalization, same alias table — and if a database row has no corresponding page, it renders as plain text instead of a link. That last part matters more than the matching: the failure mode is now “no link,” not “link to a URL we hope exists.”
I checked the ten offenders live before writing this. /world/asia/georgia now serves the real Georgia page with a canonical pointing to /world/europe/georgia. The united-kingdom and saint-lucia spellings resolve and canonicalise to uk and st-lucia. The phantom path is closed.
The build also now refuses to run if the data module loads zero entries, with an error message that says why: every country page would deploy as an unprerendered duplicate of the homepage. That’s the pattern I keep landing on — the check that matters isn’t the one that verifies success, it’s the one that makes the specific silent failure loud.
What I won’t claim
Two things I can’t support, and I’d rather say so than let a tidy story stand.
About two days of the improvement predates the deploy. The hub started climbing on the 6th; the commit landed on the 8th. The steepest single-day jump is the day after the deploy, and the position improvement lines up, but a chunk of that curve started before I touched anything and I have no explanation for it.
There was no confirmed algorithm update to attribute the rest to. Third-party trackers spiked at the start of the month and the forums called it an update, but Google logged no ranking incident in that window and has confirmed nothing. So the site-wide lift is real, measurable, and unexplained.
Also worth saying: this is visibility, not traffic. The hub took 1,891 impressions last week and 10 clicks. It’s a section where the answer gets consumed on the results page, and making more of it eligible to be seen hasn’t changed that. I could also only see about 62 of those 1,891 impressions at the query level — the rest are anonymized — so I know that it rose, not what it rose on.
What I’d carry to any site
- Name the commit after the mechanism, not the effort. I named mine after the part that took two days. The part that took fifteen minutes moved the number. Effort is not evidence.
- A link that returns 200 can still be broken. Any route with a wildcard segment will happily serve a page for a parameter that means nothing. Those aren’t dead ends, they’re duplicate generators, and no link checker will tell you.
- Build links from the same source the router resolves. If your URLs are constructed from data and interpreted by code, those two need one shared answer — or they will disagree, quietly, in the ten cases nobody thought about.
- Prefer “no link” to “probably a link.” Failing to render a link is a visible, harmless absence. Rendering a link to a URL that resolves to nothing is an invisible, compounding cost.
- Always measure against a control. Something you didn’t touch, in the same window. Without it you cannot tell your fix from the weather, and the weather that week was worth 19%.
- Publish the part you can’t explain. Two days of my improvement have no cause I can name. That’s not a hole in the analysis; it’s the honest shape of it.