How to check if googlebot can render javascript
Load the URL twice in Chrome, once with scripts off and once with them on, and count the difference: node render.mjs URL --text --nojs against node render.mjs URL --text. On the client-rendered route below the served HTML held 0 anchors, the rendered DOM held 2. That measures your renderer, not Google's.
Why check this
Run this before a release that changes a framework, a bundler, a routing mode or a flag that moves content between server and client, and on any page whose traffic dropped after such a change. The failure it prevents is a route whose served HTML is an empty container: every link and heading exists only after a script runs, so anything that does not run scripts finds nothing to crawl.
Google does render JavaScript, on a delay and within its own resource limits, so the answer is rarely a flat yes or no. What this procedure produces is a number: how much content exists only after scripts run, and which of it needs something a crawler never supplies. A crawler does not click and does not wait for a long timer, so content behind either is not a rendering question at all.
Nothing here reports what Google did. It reports what one Chrome on one machine did. Only Search Console URL Inspection shows Google's own fetched HTML and rendered DOM, on a verified property.
Prerequisites
- The
render.mjsscript from How to view a page as googlebot, which takes--nojs,--wait,--clickand--count.--nojscallspage.setJavaScriptEnabled(false), so Chrome parses the served bytes and runs none of them. - The meta tag checker reads the served HTML the same way
--nojsdoes, for head tags rather than body text. - A local target with three known failures, printed here so the counts below can be reproduced:
import { createServer } from 'node:http';
const wrap = (title, main) => `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${title}</title></head>
<body>${main}</body></html>`;
const routes = {
'/spa': wrap('Docs', `<div id="root"></div>
<script>document.getElementById('root').innerHTML =
'<h1>Docs</h1><ul><li><a href="/install">Install</a></li><li><a href="/upgrade">Upgrade</a></li></ul>';</script>`),
'/gated': wrap('Trail Runner 3', `<h1>Trail Runner 3</h1><button id="more">Show materials</button><div id="panel"></div>
<script>document.getElementById('more').onclick = () => { document.getElementById('panel').innerHTML =
'<h2>Materials</h2><p>Recycled mesh upper. EVA midsole. Rubber outsole.</p>'; };</script>`),
'/timer': wrap('Trail Runner 3', `<h1>Trail Runner 3</h1><div id="late"></div>
<script>setTimeout(() => { document.getElementById('late').innerHTML =
'<h2>Shipping</h2><p>Free over 50. Returns within 30 days.</p>'; }, 4000);</script>`),
};
createServer((req, res) => {
const html = routes[req.url];
res.writeHead(html ? 200 : 404, { 'content-type': 'text/html; charset=utf-8' });
res.end(html ?? '<!doctype html><title>404</title>');
}).listen(8818, '127.0.0.1', () => console.log('js-server listening on 127.0.0.1:8818'));
Browser figures below are one capture on one machine, Chrome 152.0.7977.76.
Steps
- Step 1.
Fetch the client-rendered route and read the body the server sent.
curl -sS http://127.0.0.1:8818/spa<!doctype html> <html lang="en"><head><meta charset="utf-8"><title>Docs</title></head> <body><div id="root"></div> <script>document.getElementById('root').innerHTML = '<h1>Docs</h1><ul><li><a href="/install">Install</a></li><li><a href="/upgrade">Upgrade</a></li></ul>';</script></body></html><div id="root"></div>is the whole page. Every heading and every link arrives later. - Step 2.
Grep the served bytes for a word you expect on the page.
curl -sS http://127.0.0.1:8818/spa | grep -c 'Install'1One match, and the page is still empty. The word sits in a JavaScript string literal in the script above, not in any element. Text search over raw HTML answers a different question.
- Step 3.
Count elements in the parsed DOM instead, with scripts off and then on.
printf 'served %s anchors\nrendered %s anchors\n' "$(node render.mjs http://127.0.0.1:8818/spa --nojs --count=a)" "$(node render.mjs http://127.0.0.1:8818/spa --count=a)"served 0 anchors rendered 2 anchorsZero against two. Those links are the only route out of this page, and they exist only after the script runs.
- Step 4.
Measure the text gap across all three routes in one pass.
for r in spa gated timer; do node render.mjs http://127.0.0.1:8818/$r --text --nojs > served-$r.txt node render.mjs http://127.0.0.1:8818/$r --text > rendered-$r.txt printf '/%-6s served %2s rendered %2s gap %2s\n' "$r" "$(wc -w < served-$r.txt)" "$(wc -w < rendered-$r.txt)" "$(( $(wc -w < rendered-$r.txt) - $(wc -w < served-$r.txt) ))" done/spa served 0 rendered 3 gap 3 /gated served 5 rendered 5 gap 0 /timer served 3 rendered 3 gap 0Read this the wrong way and two of the three routes look healthy. A gap of zero means only that rendering added nothing, and on
/gatedand/timerit added nothing because the capture ended first. A zero gap is a question, not a pass. - Step 5.
Sweep the settle time on the timer route to find where the missing content appears.
for w in 0 1000 2000 3000 3500 4000; do printf -- '--wait=%-5s %s words\n' "$w" "$(node render.mjs http://127.0.0.1:8818/timer --text --wait=$w | wc -w)" done--wait=0 3 words --wait=1000 3 words --wait=2000 3 words --wait=3000 3 words --wait=3500 11 words --wait=4000 11 wordsEight words arrive between 3000 and 3500, for content the page injects at 4000 ms. The extra wait starts after
page.gotohas returned, and that settle costs around half a second of its own. Time the sweep from navigation, not from the moment the script resumes. - Step 6.
Check whether the last route hides its content behind an interaction.
diff <(node render.mjs http://127.0.0.1:8818/gated --text) <(node render.mjs http://127.0.0.1:8818/gated --text --click=#more)2a3,5 > Materials > > Recycled mesh upper. EVA midsole. Rubber outsole.Three lines appear on click, and no amount of waiting produces them. No render budget fixes this: a crawler has no pointer.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Served and rendered text match, both non-empty | Nothing important depends on scripts | Confirm the head tags separately, they can still be injected |
| Served text empty, rendered text full | The route is client-only | Server-render or prerender it, then re-measure |
| Gap zero and both counts low | Nothing rendered inside the capture window | Sweep the wait before concluding anything |
| Content appears only after --wait | A timer or a slow fetch gates it | Move it into the first paint or accept it may be missed |
| Content appears only after --click | An interaction gates it | Render it into the initial DOM, or give it a crawlable URL |
Common mistakes
Thresholds
What to check next
- How to check if server side rendering is working: the inverse measurement, and the fix for every empty root element found here.
- How to view a page as googlebot: whether the served bytes change when the request claims to be a crawler.
- How to check robots.txt: a blocked script bundle produces the same empty result as a script that fails.
- How to check if a page is indexable: a rendered page still needs directives that allow it.
FAQ
How to check page source against what the browser shows?
View source shows the served bytes. The Elements panel, and document.documentElement.outerHTML, show the DOM after scripts ran. Comparing the two is this whole procedure. Write both to files and diff them rather than reading two panels side by side.
How to check if an HTML element is rendered?
Query for it in both states with --count and a selector. With --nojs you get the count in the served HTML, without it the count after scripts ran. Step 3 shows the pattern: 0 anchors served, 2 anchors rendered, on one URL.
Are search engines going to see my dynamically created content?
Google renders, so often yes. Anything gated behind a click or a long timer will not be seen, as steps 5 and 6 show. Content that must be indexed belongs in the first response, or at least in the DOM without an interaction.
How do I make a single page application crawlable?
Give every indexable view its own URL that returns its content in the first response, and use real anchors between them. Step 3 is the test: 0 anchors in the served HTML means nothing can be followed out of that page.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2Chrome 152.0.7977.76
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate12 minpublished updated Maks Verny