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

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

  1. 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.

  2. 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'
    
    1

    One 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.

  3. 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 anchors

    Zero against two. Those links are the only route out of this page, and they exist only after the script runs.

  4. 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  0

    Read this the wrong way and two of the three routes look healthy. A gap of zero means only that rendering added nothing, and on /gated and /timer it added nothing because the capture ended first. A zero gap is a question, not a pass.

  5. 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 words

    Eight words arrive between 3000 and 3500, for content the page injects at 4000 ms. The extra wait starts after page.goto has returned, and that settle costs around half a second of its own. Time the sweep from navigation, not from the moment the script resumes.

  6. 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

Sign: `curl … | grep 'Install'` matches, so the content is recorded as present in the HTML.Cause: Step 2 returned 1 on a page whose body is an empty div. The match came from a string literal inside a script tag. A grep over raw HTML cannot tell an element from source code that would create one. Count nodes in a parsed DOM instead, as step 3 does: 0 against 2 on the same URL.
Sign: The rendered word count equals the served word count, and the route is passed as needing no JavaScript.Cause: Both `/gated` and `/timer` scored a gap of 0 in step 4, and both are broken. The renderer had not produced the content yet, or never would without a click. A zero gap becomes a pass only once the content you expected by name is present in the served text.
Sign: The sweep in step 5 is read as a render budget, so a 4 second timer is assumed safe at a 4 second wait.Cause: The waits are measured from after navigation settled, not from navigation start, so `--wait=3500` already sits past 4000 ms of page time. Anchor a timing claim to a clock inside the page, and treat every number here as one capture on one machine.

Thresholds

The default settle used above, puppeteer's networkidle2, maps to Chrome's networkAlmostIdle lifecycle event: at most 2 open connections for 500 ms. That is the whole budget a default capture gives a page, which is why the content injected at 4000 ms in step 5 is absent until an explicit wait is added. Source: https://pptr.dev/api/puppeteer.puppeteerlifecycleevent

What to check next

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.

intermediate12 minpublished updated Maks Verny