~ / insights / templates

Template · September 2026

Render Audit Checklist.

Twelve checks in three passes that count how many of a page's words Googlebot receives at first paint, and name the pattern hiding the rest.

Amit TiwariTemplate11 min read

The decision this protects

Twelve checks in three passes Pass A: raw HTML. curl, count words, is the body a shell? Pass B: rendered DOM. URL Inspection or headless Chrome, count Pass C: visible at first paint. Words at opacity 0, visibility Delta and the pattern responsible
Figure 1. Pass C is the one nobody runs and the one that found the problem the checklist is built around.

The decision is whether a page’s visibility problem belongs to the content team, the developer who owns rendering, or the developer who owns animation, because each fix costs about a month and the wrong one costs the month and keeps the problem. A writer asked for more copy on a page that already carries 1,400 words behind opacity:0 wastes the month. A developer asked to add server-side rendering when the raw HTML already contains every word wastes a quarter. The checklist forces three separate counts before anyone proposes a fix, and the verdict names the pattern responsible, so the ticket goes to the person who owns it.

Before you start

  • One URL that matters, ideally a service or category page with real copy below the fold. Audit one page before you audit fifty.
  • A terminal with curl, Python 3 and either headless Chrome or Playwright (pip install playwright then playwright install chromium).
  • Access to the Search Console property, so URL Inspection and its rendered HTML view are available to you.
  • A folder for the run. You will save raw.html, rendered.html, two text files and the verdict, and you will want them when the developer asks what you measured.

Stated limits

  • It measures words, not meaning. A page can pass every check and still say nothing a buyer needs.
  • It measures what Google could have received, not what Google chose to index. Canonicals, crawl budget and quality signals are outside it, as are images, video and structured data.
  • Headless Chrome on a laptop stands in for Google’s rendering service. Where the two disagree, the URL Inspection rendered HTML wins.

1. Pass A, raw HTML (checks 1 to 4)

Set the user agent and the URL once, then fetch the page as Googlebot smartphone receives it before any script runs.

UA='Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
URL='https://example.com/service-page'
curl -sL -A "$UA" -o raw.html "$URL"

Save the counter below as words.py. It strips script, style, noscript and template blocks, then tags, then counts words, so every pass is counted the same way.

import html, re, sys
s = open(sys.argv[1], encoding="utf-8", errors="replace").read()
s = re.sub(r"<(script|style|noscript|template)[^>]*>.*?</\1>", " ", s, flags=re.S | re.I)
s = html.unescape(re.sub(r"<[^>]+>", " ", s))
s = re.sub(r"\s+", " ", s).strip()
if "--text" in sys.argv:
    print(s)
else:
    print(len(re.findall(r"[A-Za-z][A-Za-z'\-]*", s)))
python3 words.py raw.html                                   # check 2
grep -oE '<div id="(root|app|__next|__nuxt)"[^>]*>\s*</div>' raw.html   # check 3
grep -c '<noscript' raw.html; grep -c '<template' raw.html; grep -oE '\{\{[^}]{1,40}\}\}' raw.html | sort | uniq -c | sort -rn | head   # check 4
Field What to record Why it matters
1. Fetch HTTP status, final URL after redirects, response size in bytes A redirect or a 2 KB body means the crawler is being served something different from the browser
2. Raw word count Output of words.py raw.html The baseline every later count is compared with
3. App shell Whether the body is an empty mount point such as <div id="root"></div>, yes or no An empty shell means every word depends on JavaScript running to completion
4. Noscript and template tags Count of noscript blocks, template tags and unrendered {{ }} placeholders, and how many words sit inside noscript Copy that lives only inside noscript is a fallback no browser with JavaScript shows, and an unfilled placeholder is copy that never existed for anyone

Judgment prompt: If check 2 returns under 100 words on a page that shows 1,500 in a browser, every one of those words depends on Pass B, so log the raw count and treat the rendered count as a number Google may or may not receive on a given crawl.

2. Pass B, rendered HTML (checks 5 to 8)

Take the rendered HTML from two places. In Search Console, run URL Inspection on the URL, choose Test live URL, then View tested page, and copy the HTML tab into rendered-gsc.html. Then render it yourself, so the run can be repeated whenever the developer ships a change.

google-chrome --headless=new --disable-gpu --user-agent="$UA" --window-size=412,915 \
  --virtual-time-budget=10000 --dump-dom "$URL" > rendered.html
python3 words.py rendered.html                              # check 6
python3 words.py raw.html --text > raw.txt
python3 words.py rendered.html --text > rendered.txt
diff <(tr ' ' '\n' < raw.txt) <(tr ' ' '\n' < rendered.txt) | grep -c '^>'   # check 7, words added by rendering

For check 8, URL Inspection lists page resources that could not be loaded and JavaScript console messages under More info. Record every script that was blocked, timed out or threw, then grep the raw HTML and the site’s bundles for the events that gate the loader.

grep -oE "addEventListener\(\s*['\"](mousemove|touchstart|touchmove|keydown|scroll|wheel|click)['\"]" raw.html *.js | sort | uniq -c | sort -rn
Field What to record Why it matters
5. Rendered source URL Inspection or headless Chrome, with the date and time The two can disagree, and the client needs to know which one your number came from
6. Rendered word count Output of words.py rendered.html, and the same for rendered-gsc.html Words that appear only after rendering depend on scripts that may not run for Google
7. Raw to rendered delta Words added and removed by rendering, from the diff A negative delta means a script is replacing content the raw HTML already carried
8. Scripts that did not run Script URL, reason (blocked, timed out, error, waiting for an event), and the event A loader that waits for mousemove or touchstart never fires for a crawler that produces no input

Judgment prompt: If the rendered count is high and the raw count is low, ask whether the client can afford a crawl on which Google skips the render, because rendering is queued rather than guaranteed.

3. Pass C, viewport-visible at first paint (checks 9 to 12)

This pass counts only the words a user with no hands would see, which is the crawler’s situation. Save the script below as firstpaint.py. It walks every text node, climbs its ancestors, and charges the node’s words to the first hiding rule it finds.

import json, sys
from playwright.sync_api import sync_playwright

UA = ("Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 "
      "(KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36 "
      "(compatible; Googlebot/2.1; +http://www.google.com/bot.html)")

JS = r"""
() => {
  const wc = s => (s.match(/[A-Za-z][A-Za-z'\-]*/g) || []).length;
  const skip = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
  const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
  let total = 0, hidden = 0; const why = {};
  for (let n; (n = walker.nextNode());) {
    const words = wc(n.nodeValue);
    if (!words || skip.has(n.parentElement.tagName)) continue;
    total += words;
    let reason = null;
    for (let e = n.parentElement; e && e !== document.body && !reason; e = e.parentElement) {
      const cs = getComputedStyle(e);
      const r = e.getBoundingClientRect();
      if (cs.display === 'none') reason = 'display none';
      else if (parseFloat(cs.opacity) === 0) reason = 'opacity zero';
      else if (cs.visibility === 'hidden') reason = 'visibility hidden';
      else if (r.width && (r.right <= 0 || r.left >= innerWidth || r.bottom < 0)) reason = 'transformed off-screen';
    }
    if (reason) { hidden += words; why[reason] = (why[reason] || 0) + words; }
  }
  return { total, visible: total - hidden, hidden, why };
}
"""

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page(user_agent=UA, viewport={"width": 412, "height": 915})
    page.goto(sys.argv[1], wait_until="networkidle")
    page.wait_for_timeout(3000)
    print(json.dumps(page.evaluate(JS), indent=2))
    browser.close()
python3 firstpaint.py "$URL"                                 # check 9
grep -oE 'data-aos|class="[^"]*\b(wow|animate__[a-z]+|is-visible|reveal|fade-up|fade-in)\b' rendered.html | sort | uniq -c | sort -rn   # check 10

The script never scrolls, never moves the mouse and never presses a key, so anything gated on those stays hidden, exactly as it would for Googlebot. Elements further down the page count as visible, because position in the document is not a hiding rule. Only elements pushed off the sides, above the top, or hidden by a style rule count as hidden.

Field What to record Why it matters
9. Hidden words at first paint The total, visible and hidden figures and the why breakdown from firstpaint.py The number the verdict is built on
10. Reveal-library signatures Count of data-aos, wow, animate__, is-visible and similar hooks, and the sections they wrap A library that sets opacity zero in CSS and removes it on scroll hides the page from anything that does not scroll
11. Interaction-gated loaders Each script from check 8 that waits for mousemove, touchstart, keydown or scroll, and the words that depend on it Usually added to improve a PageSpeed score, trading a lab metric for the page’s index copy
12. Scroll-loaded content Sections inserted by an IntersectionObserver or scroll listener with no server-rendered fallback, and their word count Google documents that its crawler does not scroll, so content that waits for a scroll event is never requested

Judgment prompt: If the why breakdown puts most hidden words under one reason, resist adding a second cause to the verdict, because the client needs one fix to approve and the second cause can have its own ticket later.

4. The counts and the delta

Reading the two possible drops Drop between B and C Raw high, rendered high, visible low CSS hid it: opacity reveal, delayed script Fix in the theme and the plugin exclusions Drop between A and B Raw low, rendered high, visible high Client-side rendering: the shell is empty Fix with server rendering or pre-rendering
Figure 2. The two drops have different causes and different owners.
Field What to record Why it matters
Raw words Check 2 Baseline
Rendered words Check 6 What Google gets if the render succeeds
Visible words at first paint Check 9, visible What Google gets with no input
Rendered minus raw Check 6 minus check 2 Dependency on JavaScript running
Rendered minus visible Check 6 minus check 9 Dependency on user input, which Google never gives
Share visible Visible divided by rendered, as a percentage The figure in the verdict

Judgment prompt: If share visible is above 90 percent and the page still does not rank, close this audit and open a content or links audit, because rendering is not the reason.

5. The pattern responsible

Field What to record Why it matters
Pattern Empty app shell, interaction-gated loader, opacity reveal, visibility reveal, off-screen transform, scroll-loaded section, noscript-only copy or unfilled template The verdict names one, and the pattern decides who owns the fix
Sections affected Count, and the headings of the first three Gives the developer a place to start and the client a sense of scale
Owner Person or team that shipped the pattern The ticket goes to them and nobody else
Fix One sentence in the developer’s terms, such as render visible and animate with transform only, or fire the loader on DOMContentLoaded The fix must keep the effect the designer wanted, or it will be reverted in the next redesign
Re-test date When Pass C will be run again A render fix without a re-test is a promise, and this template records measurements

Judgment prompt: If the owner argues that the page passes PageSpeed, agree, then show the visible figure from check 9, because the two measure different things and both are true.

The defensible output

Interactive: enter the three counts from passes A, B and C for the verdict line. Needs JavaScript.

The defensible output, filled in (example values) FIELD EXAMPLE Raw words 1,840 Rendered words 1,810 Visible at first paint 330 Share Google was told to read 18 percent Pattern opacity reveal on 9 sections, script held by delay plugin Verdict line Googlebot receives 18 percent of the page's words at first paint; fix: Example numbers to show the shape. Replace with the three counts from the passes.
Figure 3. One line a client can read, with the three counts behind it.

One line the client can read aloud in a meeting, followed by the six figures from section 4 as a small table. The line always has three parts, the share, the cause and the fix.

Googlebot receives 18 percent of the page's words at first paint; cause: opacity-based reveal on 9 sections; fix: render visible, animate with transform only.

Attach raw.html, rendered.html, the two text files, the firstpaint.py output and the date, so that when the fix ships the same commands produce a second line to sit beside the first.

Where I could be wrong

Google’s rendering service is a version of Chrome with its own timeouts, and a headless run on a laptop is a stand-in for it. Google documents that its crawler does not scroll or click, and every case I have measured agrees, but the service changes without announcement, which is why the URL Inspection rendered HTML is recorded alongside the local render and wins any disagreement. If Google’s renderer uses a viewport shorter than the page and never extends it, the share I report will be higher than what Google received.

The counter looks for four hiding rules. Sites also hide content with height zero and overflow hidden, with clip-path, with transparent text colour, or behind another element. Those pages score better on check 9 than they should, and the rendered screenshot in URL Inspection is the only check I have for them.

The word regex counts Latin words. On a page written in Hindi or another Indic script it returns close to zero at every pass. Swap the regex for one that matches the page’s script before you run it.

Sources

How to cite this template

Amit Tiwari (2026). Render Audit Checklist. Template, September 2026. amittiwari.net. https://amittiwari.net/templates/render-audit-checklist

Send me the template

A filled example alongside the blank one, so you can see how it is meant to be used.

So I can look before we talk.

Discuss in the community ↗