Skip to content
  1. Home
  2. Fishbone Talk

field notes · · 9 min read

your prerender says it worked. here's how to tell if it actually did

How to check if a page was really prerendered: why a 200 and a byte count both lie, and a free check you can run on any site, with or without a terminal.

By Big Bad Agency

Our build was green. The prerender walked every route, wrote every file, and threw nothing. The sitemap came out complete.

Eleven of the URLs in it were empty.

Not 404. Not a 500. Not a warning buried in the log. Eleven pages on findbest.pro — /blog, /discover, /categories, /lists, /brands, /locations, /comparisons, /trends, /tier-lists, /ranked, /templates — each answering HTTP 200, each carrying the homepage’s <title>, and each containing exactly 1,074 characters of navigation and footer. That was the whole document. Header, nav, footer, closing tag. No headings, no posts, no lists, no error.

The eight-hundred-odd detail pages sitting underneath those hubs rendered perfectly.

If you check status codes — and most people only ever check status codes — that site looked flawless.

a byte count is not a content check

We had a guard for this. A thin-content check: render the page, strip the tags, count the characters, fail if the result came back under MIN_TEXT_CHARS = 500.

That threshold wasn’t plucked out of the air. It was calibrated, and the calibration was written down in a comment right next to it: a real page on that site measured at least 1,036 characters of text, and an empty data-driven page — a profile whose fetch never resolved — measured 75. Between 75 and 1,036 there’s a lot of daylight. 500 sits comfortably in the middle of it. Against the pages it was calibrated on, the guard was correct.

The hub routes are not data-driven pages. They’re chrome-heavy static routes: big nav, big footer, category rails, a fat link block at the bottom. When one of those renders nothing, you don’t get 75 characters. You get 1,074 — all furniture, no room.

1,074 is above the 500-character floor. It is also above the 1,036-character number the comment recorded as the minimum for a healthy page. Read that twice, because it’s the entire bug: on those routes, a completely empty render was longer than the guard’s own definition of a good one. There was no threshold available. Length did not separate the two populations at all. Raising it to 1,200 would have started failing legitimate pages while still passing blank ones.

So, first lesson, and it generalises well past our stack: a length threshold cannot detect an empty render on a chrome-heavy route. Your header and footer are content as far as wc -c is concerned. The more design you put in your shell, the less signal length carries. Assert on something only a rendered page can produce — its own <title>, its own <h1>, an element that route is uniquely responsible for creating. Not its size.

the flag that turned the check off

There was a second guard, and it would have caught this in about four milliseconds: assert that the rendered page’s <title> is the page’s own title and not the shell’s.

It was switched off on exactly the routes that broke. One flag:

requireTitle: !isStatic

Static routes — the hubs — skipped the title assertion. Detail routes got the assertion and a retry ladder that re-rendered on failure. Which is precisely why eight hundred detail pages were fine and eleven hubs were not. The routes where the check ran had no bugs. The routes where it was disabled had eleven.

I don’t think whoever wrote that line was careless. There’s always a reason: static routes are “simple”, they don’t fetch anything, they render instantly, the assertion went flaky during some earlier refactor. Every disabled check has a plausible story behind it at the moment it’s disabled. The story ages badly and the flag doesn’t.

So: be suspicious of any flag that turns a safety check off for a subset of routes. Not because the reasoning was wrong, but because you’ve just created a region of the codebase with less coverage than the rest, and defects find low ground the way water does. Grep your build config for skip, unless, except, only, !is — that’s a map of where your next incident happens.

The fix was to stop exempting anything and assert on each page’s own title everywhere. All eleven recovered on the next build, and we confirmed them live after deploy rather than trusting dist/. /blog was one of the eleven; it’s the page you land on now at findbest.pro/blog.

http 200 proves nothing

The reason this survived is that every casual check reported success.

An SPA with a catch-all route answers 200 for every URL you ask it for, including URLs that don’t exist. Your host does the same thing — a static host with an SPA fallback serves index.html for anything it can’t match. A status code tells you a file came back. It tells you nothing whatsoever about whether that file contains your page.

If your monitoring is a green uptime check, your prerender can be completely broken and your dashboard will stay green indefinitely. Read the title. That’s the check.

hubs and details fail independently

Last one, and it’s the one that actually costs you.

Roughly 836 working pages and 11 broken ones sounds like a 98.7% pass rate and a rounding error. It isn’t, because the two sets do different jobs.

Detail pages are destinations. Hub pages are the crawl paths that lead to them — the category index, the location list, the all-brands page. When hubs render empty, every internal link on them vanishes. The detail pages still exist and still work, but the structure that told a crawler how they relate, which ones matter, and where to go next is gone. Your internal link graph goes invisible.

And here’s the cruel part: the sitemap keeps working. Every URL is still listed, still returns 200, still gets crawled on that basis. So the index doesn’t collapse, coverage reports stay clean, and nothing flashes red. The sitemap holds the pages up while the link graph underneath them has quietly gone flat. That’s a slow leak no alert will ever fire for.

Check hubs separately from details. They fail separately.

a prerender check you can run on any site in thirty seconds

You don’t need a paid prerender checker for this, and it works on any public site, yours or not. Take five or six hub URLs and ask two questions per page: what’s the title, and how much text is there.

for u in / /blog /categories /brands /locations /lists; do
  html=$(curl -s "https://yoursite.com$u")
  title=$(printf '%s' "$html" | tr -d '\n' | grep -o '<title>[^<]*' | head -1)
  chars=$(printf '%s' "$html" | tr -d '\n' \
    | sed -e 's|<script[^<]*</script>| |g' -e 's|<[^>]*>| |g' \
    | tr -s ' ' | wc -c)
  printf '%-14s %7s  %s\n' "$u" "$chars" "${title#<title>}"
done

Two failure signatures, both obvious the moment the table is in front of you:

  • Every row shows the same title. That’s the shell. Either the prerender didn’t run for those routes, or it snapshotted the page before the router swapped the real view in.
  • The character counts collapse into two clusters. One tight cluster is your chrome. Anything sitting in it is empty, whatever the absolute number happens to be. This is why relative comparison beats an absolute threshold — you don’t need to know that 1,074 is bad, you only need to notice that eleven pages agree on it exactly. Real pages never agree exactly.

Run it against production, not against dist/. What your build wrote and what your CDN serves are two separate claims, and only one of them is what Google sees.

no terminal? check it in the browser

The question is always the same: does the HTML the server sends already contain the page, before any JavaScript runs? Two ways to see that without a command line:

  • View source. In Chrome, Ctrl+U (Cmd+Option+U on a Mac) opens the HTML as the server sent it. Look at the <title>, then search (Ctrl+F) for a sentence you can see on the page: the <h1>, a product name, the first line of a post. If it’s there, the page was prerendered or server-rendered. If you find an almost empty <body>, usually a single <div id="root"></div> or <div id="app"></div> and a script tag, the content is being built in the browser.
  • Turn JavaScript off and reload. In Chrome DevTools, open the Command Menu (Ctrl+Shift+P), run “Disable JavaScript”, and reload. What’s left on screen is roughly what a crawler gets before it renders anything. A prerendered page still reads. A client-side page goes blank or shows a spinner forever. Run “Enable JavaScript” when you’re done; the setting only lasts while DevTools is open.

Don’t use “Inspect element” for this. The Elements panel shows the page after JavaScript has built it, so a completely client-rendered page looks perfect there.

google’s own tools answer a different question

Search Console’s URL Inspection (“View crawled page”) and the Rich Results Test (“View tested page”) both show you HTML. It’s tempting to treat them as a prerender checker. They aren’t one.

Both show the page after Google has rendered it, JavaScript included. A site with no prerendering at all can look complete in them, because Google ran the scripts and filled the page in. That makes them the right tool for “can Google render this page”, and the wrong tool for “was this page prerendered”. Google does render JavaScript, but rendering can happen later than the crawl, and other crawlers, including many AI ones, may not run your JavaScript at all. Raw HTML is the only answer that holds for all of them.

So use both, for different things. View source or curl tells you what every crawler gets on the first request. URL Inspection tells you what Google ended up seeing.

if a prerender service sits in front of the site

Some sites don’t prerender at build time. They use a service that detects crawlers by user agent and serves them a stored snapshot, while people get the normal JavaScript app. On those sites, your browser will never show you the prerendered version, so check it the way a crawler asks:

curl -s -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
  https://yoursite.com/pricing | grep -o '<title>[^<]*'

Compare that title, and the amount of text, with a normal request. If the crawler request comes back with the real page and the normal one doesn’t, the service is doing its job. If both come back as the shell, it isn’t.

Two cautions. Some setups check that a “Googlebot” request really comes from Google’s addresses, so a spoofed user agent can get the normal shell even when the service is working. In that case check with the service’s own dashboard or URL Inspection. And because the snapshot is a separate copy, it can go stale on its own. Check that the snapshot has your current prices and copy, not just that it exists.

the honest version

We prerender everything meant to be found — it’s a rule, not a preference, and it’s baked into how we build every browser app in the webapps portfolio. CreateQRCodes runs the same discipline on its own pipeline; its blog is prerendered for the same reason.

This still got past us. In a pipeline that already had a guard written specifically to prevent it. Because the guard measured the wrong property, and the check that measured the right one had been politely excused from the routes that needed it most.

Assert on the title. Distrust the flag that skips the check. And never let a 200 convince you the page is there.

Disagree? Good.

Write to a person: hello@bigbad.agency. We answer within a business day.

Start a project