Fetch each page without running JavaScript, count the words, then load it in a real browser and count the words again. If most of the rendered text is missing from the raw HTML, anything that does not execute scripts sees an empty page. Put the content that matters, and the headline, in the HTML the server sends.

Key takeaways

  • Rendering support differs between fetchers. Google documents its rendering; a crawler that does not document one is safest assumed not to run your scripts.
  • Compare words, not bytes. The ratio of raw-HTML words to rendered words is easy to read: 100% means everything is there, 0% means the page is built in the browser.
  • Check the headline and structured data too. A page can have body text in the HTML and still inject its H1 or its JSON-LD with script.
  • Two deliberately broken test pages scored 0% and 2%. A fully client-rendered page and one with only a short intro in the HTML.
  • 64 of 67 pages here scored 100%. The three below 90% are interactive checklists whose question and hint text lives in a script.

Why raw HTML matters

Google does render JavaScript, with a delay. But a page also gets fetched by link-preview bots, feed readers, validators, archive tools and AI systems that retrieve pages for answers. The documentation for the AI crawlers I used in is AI crawling my site? covers user agents and IP ranges, not rendering, so the cautious reading is to assume nothing and test the raw HTML. What a system does with your page once it has fetched it is the subject of how pages get cited by AI answer engines.

The script

It needs Node and Playwright. For each URL it downloads the HTML as a plain fetch, strips scripts and styles, counts words, then loads the URL in headless Chromium and counts the words in the visible text:

// usage: node nojs_check.js url [url...]
// Compares the text in the raw HTML response (what a fetcher that does not run JavaScript sees)
// with the text in the rendered page (after JavaScript runs).
const { chromium } = require('playwright');
const words = t => (t.match(/[\p{L}\p{N}'’-]+/gu) || []).length;
const stripHtml = h => h.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ').replace(/<!--[\s\S]*?-->/g, ' ').replace(/<[^>]+>/g, ' ');
(async () => {
  const b = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium' });
  for (const url of process.argv.slice(2)) {
    const raw = await (await fetch(url, { headers: { 'User-Agent': 'nojs-check/1.0' } })).text();
    const rawWords = words(stripHtml(raw).replace(/&[a-z#0-9]+;/gi, ' '));
    const p = await b.newPage(); await p.goto(url, { waitUntil: 'networkidle' });
    const renderedWords = words(await p.evaluate(() => document.body.innerText));
    const h1Raw = /<h1[\s>]/i.test(raw.replace(/<script[\s\S]*?<\/script>/gi, '')), pct = Math.round(100 * Math.min(rawWords, renderedWords) / Math.max(renderedWords, 1));
    console.log((pct >= 90 ? 'ok   ' : 'FIX  ') + url.replace(/^https?:\/\/[^/]+/, '').padEnd(52) + ' raw ' + String(rawWords).padStart(5) + ' words | rendered ' + String(renderedWords).padStart(5) + ' | ' + String(pct).padStart(3) + '% present without JS | h1 in raw HTML: ' + h1Raw);
    await p.close();
  }
  await b.close();
})();

Two broken pages

Two test pages that build their content with script, and one real page for comparison.
PageRaw wordsRendered wordsPresent without JS
Client-rendered (empty div, script fills it)13220%
Short intro in HTML, article body added by script94882%
A real guide from this site1,5371,377100%

The first run of the script reported that the client-rendered page had an H1 in its HTML. The heading was inside the script string, which my pattern matched. The script now removes scripts before looking for the heading, and reports false for that page. A check like this is easy to fool in the other direction too.

This site, page by page

I ran it on all 67 indexable pages of this site (served locally; the 404 page excluded). Every page has its H1 in the raw HTML. 64 pages had 100% of their rendered text in the raw HTML. The raw count can exceed the rendered one, because the HTML includes text that is hidden until interaction, so the score is the smaller count over the larger. Three pages scored lower:

The three pages below 90% present without JavaScript.
PagePresent without JS
Pre-launch website checklist81%
SEO audit checklist87%
UI/UX design checklist88%

I looked at the SEO audit checklist to see why. The text missing from the raw HTML is the plain-English hint under each question (for example the explanation of what "indexed" means), which the interactive scoring tool builds from data in main.js. The article text around the tool is in the HTML. I infer, without having checked every item, that the other two checklists work the same way. I have not changed it: the hints belong to the tool, and a reader without JavaScript gets the article but not the tool. If those hints mattered for search, the fix would be to render them in the HTML and let the script enhance them.

What to fix

  • Server-render or pre-render the main content, and the H1, so it is in the first response.
  • Put JSON-LD in the HTML rather than injecting it. The same check works: look for the script block in the raw response.
  • Treat script as enhancement. A page that reads fine without it is easier to crawl, preview, archive and use on a poor connection.
  • Re-run the check after changes to templates or frameworks; a build setting can quietly turn pre-rendering off.

What this does not cover

This counts words, not meaning. A page can score 100% and still lose content that matters, such as images, links or data loaded later, and the script does not compare links or structured data. It fetches with Node and runs Chromium once per page, on a local server, so it shows nothing about a CDN or bot-blocking rules that serve different HTML to different clients. The conclusion about the other two checklist pages is an inference. I make no claim about how any particular AI system fetches pages.

Badri Dutta

Software engineer · 8 years technical SEO

Written after running the check on every page of this site and finding that the three exceptions were the interactive checklists, not articles.

Full background →