How to check heading structure of a page

Read the headings Chrome computed, not the h1 to h6 elements. Call Accessibility.getFullAXTree over the DevTools protocol and print every node whose role is heading, with its level. On the page below the tag list and the computed outline each hold four entries, and two of those four entries differ.

Why check this

Run this on any page with more than one section, before staging sign-off, and again after a CMS upgrade changes how editors mark up subheadings. The heading list is how a screen reader user moves through a long page, so the list is the page as far as that user is concerned.

The failure it catches is a section with a heading on screen and none in the outline. An editor styles a paragraph to look like a subheading, or a component ships a div with a large bold font. Sighted readers see six sections, the heading list offers four, and the two missing ones can be reached only by reading forward.

Prerequisites

// ax.mjs  -  one Chrome, one page, and the tree it computed. Imported by the scripts below.
import { launch } from 'puppeteer-core';

export async function open(url) {
  const browser = await launch({ channel: 'chrome', headless: true });
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 800 });
  const cdp = await page.createCDPSession();
  const goto = (u) => page.goto(u, { waitUntil: 'networkidle2', timeout: 30000 });
  if (url) await goto(url);
  return {
    browser, page, cdp, goto,
    async ax() {
      const { nodes } = await cdp.send('Accessibility.getFullAXTree');
      return nodes
        .filter((n) => !n.ignored && n.role?.value !== 'none')
        .map((n) => ({
          role: n.role?.value ?? '',
          name: n.name?.value ?? '',
          props: Object.fromEntries((n.properties ?? []).map((p) => [p.name, p.value?.value])),
        }));
    },
    close: () => browser.close(),
  };
}
// heading-demo.mjs  -  a docs page with four planted heading defects. node heading-demo.mjs
import { createServer } from 'node:http';
const page = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Heading demo</title><style>
 body{font:16px system-ui;margin:2rem;max-width:40rem}
 .looks-like-h2{font-size:1.5rem;font-weight:700;margin:1.2rem 0 .4rem}
 h3{font-size:1.15rem}
</style></head><body>
<h1>Billing</h1>
<p>How invoices are issued.</p>
<div class="looks-like-h2">Payment methods</div>
<p>Cards and bank transfer.</p>
<span role="heading" aria-level="2">Refunds</span>
<p>Fourteen days.</p>
<h4>Partial refunds</h4>
<p>Pro rata.</p>
<h2 aria-hidden="true">Legacy plans</h2>
<p>Closed to new customers.</p>
<h1>Contact</h1>
</body></html>`;
createServer((_, res) => res.end(page)).listen(9151, () => console.log('http://localhost:9151/'));

Steps

  1. Step 1.

    Start the demo server and leave it running in its own terminal.

    node heading-demo.mjs
    
    http://localhost:9151/
  2. Step 2.

    Print the tag list and the computed outline side by side. The walk descends open shadow roots, for the same reason the tab-stop script on How to test keyboard navigation on a website does: a component library keeps its section headings inside a shadow root, and a plain document query never reaches them.

    // headings.mjs  -  node headings.mjs <url>
    import { open } from './ax.mjs';
    const s = await open(process.argv[2]);
    try {
      console.log('-- tag outline (querySelectorAll) --');
      console.log(await s.page.evaluate(() => {
        const walk = (root, out) => {
          for (const e of root.querySelectorAll('h1,h2,h3,h4,h5,h6')) out.push(`  ${e.tagName} "${e.textContent.trim().slice(0, 40)}"`);
          for (const e of root.querySelectorAll('*')) if (e.shadowRoot) walk(e.shadowRoot, out);
          return out;
        };
        return walk(document, []).join('\n');
      }));
      console.log('-- computed tree (Accessibility.getFullAXTree) --');
      for (const n of await s.ax()) if (n.role === 'heading') console.log(`  level ${n.props.level} "${n.name}"`);
    } finally {
      await s.close();
    }
    
    node headings.mjs http://localhost:9151/
    
    -- tag outline (querySelectorAll) --
    H1 "Billing"
    H4 "Partial refunds"
    H2 "Legacy plans"
    H1 "Contact"
    -- computed tree (Accessibility.getFullAXTree) --
    level 1 "Billing"
    level 2 "Refunds"
    level 4 "Partial refunds"
    level 1 "Contact"

    Four entries each, and not the same four. The tag list carries Legacy plans, dropped by the tree for aria-hidden. The tree carries Refunds, missed by the selector because the element is a span with role="heading".

  3. Step 3.

    Find the text that looks like a heading and is in neither list. The scan keeps every leaf element whose font is at least 1.2 times the body size and at least semibold.

    // fake-headings.mjs  -  node fake-headings.mjs <url>
    import { open } from './ax.mjs';
    const s = await open(process.argv[2]);
    try {
      console.log(await s.page.evaluate(() => {
        const base = parseFloat(getComputedStyle(document.body).fontSize);
        const out = [];
        for (const e of document.querySelectorAll('body *')) {
          if (/^(H1|H2|H3|H4|H5|H6)$/.test(e.tagName)) continue;
          if (e.getAttribute('role') === 'heading') continue;
          if (!e.textContent.trim() || e.children.length) continue;
          const c = getComputedStyle(e);
          if (parseFloat(c.fontSize) >= base * 1.2 && Number(c.fontWeight) >= 600) {
            out.push(`  ${e.tagName.toLowerCase()}.${e.className} "${e.textContent.trim().slice(0, 30)}" ${c.fontSize}/${c.fontWeight} role=${e.getAttribute('role') ?? '(none)'}`);
          }
        }
        return out.join('\n') || '  (none)';
      }));
    } finally {
      await s.close();
    }
    
    node fake-headings.mjs http://localhost:9151/
    
      div.looks-like-h2 "Payment methods" 24px/700 role=(none)

    One candidate, 24 px against a 16 px body. Read it and decide: a heading, or emphasised text.

  4. Step 4.

    Print the computed outline with the level rules applied, so the result is a verdict and not a list.

    // outline.mjs  -  node outline.mjs <url>
    import { open } from './ax.mjs';
    const s = await open(process.argv[2]);
    try {
      const headings = (await s.ax()).filter((n) => n.role === 'heading');
      let prev = 0, h1 = 0;
      for (const h of headings) {
        const level = Number(h.props.level);
        const flags = [];
        if (level === 1 && ++h1 > 1) flags.push('SECOND H1');
        if (prev && level > prev + 1) flags.push(`SKIPPED ${prev + 1}..${level - 1}`);
        if (!prev && level !== 1) flags.push('DOES NOT START AT 1');
        console.log(`${'  '.repeat(level - 1)}${level} ${h.name || '(no name)'}${flags.length ? '   <-- ' + flags.join(', ') : ''}`);
        prev = level;
      }
      console.log(`${headings.length} headings, ${h1} at level 1`);
    } finally {
      await s.close();
    }
    
    node outline.mjs http://localhost:9151/
    
    1 Billing
    2 Refunds
        4 Partial refunds   <-- SKIPPED 3..3
    1 Contact   <-- SECOND H1
    4 headings, 2 at level 1

    The indent is the level, so a broken hierarchy shows before you read a flag.

  5. Step 5.

    Run the same script against a page that passes, to see the shape you are aiming at.

    node outline.mjs https://developer.mozilla.org/en-US/docs/Web/HTTP
    
    1 HTTP: Hypertext Transfer Protocol
    2 Guides
      3 Security and privacy
      3 Related resources
    2 Tools & resources
    2 Reference
    2 Help improve MDN
    2 In this article
    8 headings, 1 at level 1

    One level 1, no skips, and every level 3 sits under a level 2. Captured on 2026-09-11.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | The tag list and the tree hold different entries | aria-hidden, role="heading" or a shadow root is in play | Trust the tree. It is what assistive technology receives | | A candidate in step 3 | Text is styled as a heading and marked as body text | Replace the div with the heading element for its level | | SKIPPED 3..3 | A level was jumped, so the outline has a hole | Renumber the section, or add the missing level | | SECOND H1 | The document has two roots | Demote one, unless each is a separate top-level article | | (no name) at any level | The heading element is empty or holds only an image | Give it text, or an image alternative |

Common mistakes

Sign: A selector-based audit reports four headings and a clean run, and the page still fails a manual pass.Cause: querySelectorAll('h1,h2,h3,h4,h5,h6') and the accessibility tree both returned four entries on the demo page, and only two of them matched. The selector counted the aria-hidden h2 that Chrome had already removed, and missed the span carrying role=heading that Chrome had kept. Two counts agreeing is not two lists agreeing.
Sign: A section heading is on screen, is not reported as a defect, and is in no heading list.Cause: A styled div has no role, so it is absent from the tree, and it is not an h1 to h6 element, so it is absent from the tag list. Neither audit can flag what neither audit can see. Only a computed-style pass finds it, which is why step 3 exists.
Sign: aria-hidden is used to hide a heading from assistive technology while leaving it visible.Cause: aria-hidden removes the node from the tree and changes nothing on screen. On the demo page Legacy plans stays visible and the outline jumps from Partial refunds to Contact. Sighted readers see a section that the heading list has no entry for, and the content under it is reachable only by reading forward.

What to check next

FAQ

How do I check heading structure without a browser?

You cannot get the computed outline without one. A parser reads h1 to h6 from the markup, which misses role="heading", keeps aria-hidden headings and never resolves a shadow root. Use it as a first filter and confirm in the tree.

How do I test heading order?

Step 4 does it. Read the computed level of each heading in order, and flag any step of more than one level, any document that does not open at level 1, and any second level 1. The indent makes a hole in the hierarchy visible without reading the flags.

Is a skipped heading level a WCAG failure?

WCAG 2.2 does not name a skipped level as a failure of 1.3.1. Audit tools report it as a warning, because the heading list is the navigation and a hole in it sends the reader to the wrong section. Treat it as a defect with a low severity.

Should a page have exactly one h1?

One is the safe answer and the one the demo flags against. HTML allows more, and a page of several independent articles has a case for it. The test is whether a reader jumping between level 1 headings lands on peers.

Why does the tree show a heading with no name?

The element is empty, holds only an image with no alternative, or its text sits inside an aria-hidden child. Chrome computes the name from contents, so nothing readable in means nothing out. The entry stays in the list with no destination.

Verified

Verified by Maks VernyChrome 152.0.7977.76node 22.23.2puppeteer-core 25.10.0

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.

intermediate9 minpublished updated Maks Verny