"Google renders JavaScript, so it's fine" and "Google can't handle our framework" are both the wrong frame. Google does render, and rendering does fail in specific, predictable ways. The useful question is narrower: what differs between the HTML your server returns and the DOM after scripts run, and which of those differences change what gets discovered or indexed?
That difference is measurable. You can compute it across thousands of URLs, rank the failures by consequence, and stop guessing. What you can't do is treat your headless Chrome output as identical to what Google's renderer produced — and that gap is where most render audits quietly go wrong.
What Google has documented about how rendering works
A few mechanics are documented rather than inferred, and they constrain everything else:
- Googlebot uses an evergreen Chromium-based renderer. Since 2019 Google has said it keeps the rendering engine current with recent stable Chrome, so "Googlebot doesn't support ES6" arguments are obsolete.
- Crawling and rendering are separate stages. Pages enter a render queue after the HTML is fetched. Googlers have repeatedly said the typical wait is short — on the order of seconds to minutes for most pages — and have walked back the older "two waves of indexing" framing as misleading. The queue exists; it is usually not the bottleneck.
- The renderer uses its own resource cache and largely ignores your cache headers. Google's JavaScript SEO documentation says as much and recommends content-hashed filenames so a changed bundle gets a new URL.
- No persistence, no interaction. Google documents that Googlebot clears cookies and storage between page loads, and that it does not click, type, or scroll. Content behind interaction never exists in the DOM Google sees. Lazy loading has to work via native
loading="lazy"orIntersectionObserver, because Googlebot renders with a very long viewport rather than scrolling. noindexin the served HTML stops rendering. This is the single most consequential documented behavior: if the raw response carries a robotsnoindex, Google drops the page before executing JavaScript, so a script that removes the tag later never runs.
Which differences between raw HTML and rendered DOM actually matter
Not all diffs are equal. Rank them by what they break.
| Signal | Rendered-only is... | Why |
|---|---|---|
<a href> internal links | High risk | Links only in the DOM depend on a successful render for discovery. Router-based onclick navigation with no href yields nothing at all. |
Robots meta / X-Robots-Tag | Critical | A served noindex prevents rendering entirely. A JS-added noindex is honored once seen — and is a common accidental de-indexing bug. |
rel=canonical | Risky | Google's JS documentation discourages injecting canonicals with JavaScript because conflicting raw and rendered values are easy to create. |
| Title and description | Tolerable | Usually picked up from the rendered DOM, but easy to get overwritten by client-side code with generic defaults. |
| Main body content | Tolerable but slow | Works when rendering succeeds; it makes indexing dependent on a fragile step and delays it. |
| JSON-LD structured data | Generally fine | Google documents support for JS-generated structured data, including via tag managers. |
| hreflang annotations | Avoid | Support for JS-injected hreflang is not clearly documented. Keep annotations in the response HTML or in sitemaps. |
The practical priority order: discovery, then indexability directives, then content, then enhancements.
How to build the diff at scale
You need two fetches of the same URL, close together: one plain HTTP GET, one full render after network idle. Then compare a fixed set of extracted features rather than eyeballing screenshots.
import re
from urllib.parse import urljoin, urldefrag
LINK_RE = re.compile(r'<a\b[^>]*\bhref=["\']([^"\'#][^"\']*)', re.I)
def internal_links(html, base, host):
out = set()
for href in LINK_RE.findall(html):
u = urldefrag(urljoin(base, href))[0]
if host in u:
out.add(u)
return out
raw = fetch_plain(url) # requests / curl, no JS
dom = fetch_rendered(url) # headless Chrome, outerHTML after idle
r = internal_links(raw, url, host)
d = internal_links(dom, url, host)
jaccard = len(r & d) / len(r | d) if (r | d) else 1.0
print(f"{url}\traw={len(r)}\tdom={len(d)}\tdom_only={len(d - r)}\tJ={jaccard:.2f}")
Extend the same pattern to a small feature vector per URL: robots meta value, canonical href, <title>, count of JSON-LD blocks and their @type values, and a normalized visible-text token count. Store both versions and diff the vectors.
Two rules keep the data honest:
- Fetch both versions within seconds of each other. Personalization, A/B tests, and inventory changes will otherwise show up as render deltas.
- Sample by template, not by URL. Rendering is expensive. Twenty URLs per template class tell you almost everything; 200,000 renders tell you the same thing at 10,000x the cost. Reserve full-coverage rendered crawls for templates where the sample showed a real gap.
Why your headless Chrome isn't Google's renderer
This is where audits mislead. Your local render is a best case, and the differences run in both directions:
- Consent management. A CMP may block scripts until consent, and its behavior can differ for a browser with no cookies versus your automation setup. Googlebot arrives with cleared storage every time.
- Resource availability. Google's renderer may serve a stale cached bundle and may decline resource requests it judges unnecessary. Your Chrome fetches everything fresh.
- Time. Google has never published a fixed render budget, and Googlers have said the "five-second timeout" number is not how it works. Your crawler waiting for network idle indefinitely proves nothing about a slow endpoint's real risk. Record when main content appears in the DOM and treat long tails as risk, not verdicts.
- User agent and geography. If your stack varies output by UA or IP region, your crawl needs to match the case you're testing.
Ground-truth checks come from Search Console's URL Inspection tool, which shows the rendered HTML, screenshot, and — importantly — page resources Google could not load. Distinguish the indexed version from a live test: the indexed view reflects the crawl Google actually used, while the live test is a fresh fetch. When they disagree, the indexed view is the one explaining your rankings.
What to do with the results
- Run the raw-versus-rendered diff on one sampled URL set per template and sort by
dom_onlylink count. Templates where navigation or pagination exists only in the DOM are your first fix: emit realhrefattributes server-side. - Assert invariants in CI rather than re-auditing. For each template, fail the build if the served HTML lacks a self-referencing canonical, carries an unintended
noindex, or returns zero internal links. - Move robots directives, canonicals, and hreflang out of client-side code entirely. These are cheap to serve and expensive to get wrong.
- Where content must be client-rendered, add content-hashed asset filenames so Google's resource cache can't serve a stale bundle.
- Confirm every conclusion on at least one URL per template with URL Inspection's indexed rendered HTML, and check the blocked-resource list before blaming the framework.