Skip to content
Issue docs

Links to a #fragment that does not exist on the target page

Suggestionbroken_fragment_linkIssue 205

What is this issue?

This is advice, not a defect. The page links to a section of a page — /pricing#plans, or #faq on the same page — and the page it points at has no element with that id.

The part of a link after # is a fragment. A browser opens the page and scrolls to the element whose id matches it (or, in older markup, to <a name="…">). When nothing matches, the browser simply stays at the top of the page. The link "works", but it does not take the reader where it promised.

For a link to pass this check:

  • The target page has an element whose id (or an <a> whose name) is exactly the fragment. Ids are case-sensitive: #Plans does not reach id="plans".

Example: a docs page renames its "Installation" heading from id="install" to id="installation". Every "see installation" link across the site still opens the page, but at the top.

Fragments that are not section anchors are never judged: #top, hash-router routes (#/settings), hash-bang routes (#!/…), text fragments (#:~:text=…) and script state such as #page=2.

Why it matters

  • The reader lands in the wrong place. On a long page, "the top" can be thousands of words away from the section the link named. Many readers assume the link is wrong and leave.

  • It is a sign of drift. Missing anchors usually mean a heading was renamed or a section removed, and the links that pointed at it were never updated. The same edit has often broken more than one link.

  • Search engines use section links. Google can show "jump to" links to a section of a page in results. Anchors that exist, with links pointing at them, are what that is built from.

  • Tables of contents depend on it. An in-page table of contents with one dead entry is a visible defect on the page itself.

Effect on the health score

None. This is a suggestion: it carries no severity and deducts nothing. The link still reaches the right page, so nothing is broken for a search engine — what is lost is the reader's place on it.

How to fix it

  1. Find what the link meant. Open the target page and find the section the link should land on.

  2. Give that section the id the link uses, or change the link to the id the section has. Prefer changing the link if the id is already used elsewhere:

    <!-- The link -->
    <a href="/docs/setup#install">Installation steps</a>
    
    <!-- The target, before: renamed heading, new id -->
    <h2 id="installation">Installation</h2>
    
    <!-- Fix A: point the link at the existing id -->
    <a href="/docs/setup#installation">Installation steps</a>
    
    <!-- Fix B: keep old links working with an extra anchor -->
    <h2 id="installation"><span id="install"></span>Installation</h2>
  3. Match the case exactly. #Pricing and id="pricing" do not match.

  4. Keep ids stable when you rename headings. If your site generates heading ids from heading text, renaming a heading silently breaks every link to it. Pin the id in the source, or keep the old one as an extra anchor.

  5. Render ids in the HTML. If heading anchors are added by a script, render them on the server instead — search engines and link previews see the HTML.

When to leave it alone

  • A fragment your own JavaScript reads. A tab or accordion that opens from #pricing-tab with no matching element works in the browser and is safe to ignore.

Examples

Example 1: A table of contents that matches

Passes because: every fragment has an element with that id.

<nav>
  <a href="#install">Install</a>
  <a href="#configure">Configure</a>
</nav>
<h2 id="install">Install</h2>
<h2 id="configure">Configure</h2>

Example 2: A renamed heading

Reported because: /docs/setup no longer has id="install".

<!-- On /blog/getting-started -->
<a href="/docs/setup#install">see the installation steps</a>

<!-- On /docs/setup -->
<h2 id="installation">Installation</h2>

Corrected version:

<a href="/docs/setup#installation">see the installation steps</a>

Example 3: A case mismatch

Reported because: ids are case-sensitive.

<a href="#Pricing">Pricing</a>
<section id="pricing">…</section>

Example 4: Fragments that are not section anchors

Not judged: none of these names an element.

<a href="#top">Back to top</a>
<a href="/app#/settings">Settings</a>
<a href="/report.pdf#page=4">Page 4</a>
<a href="/blog/post#:~:text=key%20finding">The key finding</a>

How PixyScan detects this

  1. Records, for every page it parses, two lists:

    • every element id on the page, then every <a name> — the two things a fragment can land on. Case is kept. At most 2 000 per page;
    • every fragment the page links to on its own site, with the page it points at. A same-page link (#faq, or the page's own address with a fragment) points at the page itself. At most 200 distinct links per page.
  2. Records only fragments that name an element. An empty fragment, #top (which the HTML standard always resolves to the top of the page), hash-router paths (#/…), hash-bang routes (#!…), text fragments (#:~:text=…) and anything containing = or & are left out. Fragments are percent-decoded the way a browser compares them.

  3. Waits until the crawl has finished, because the target page may be crawled before or after the page that links to it.

  4. Joins the two lists across the scan and reports a fragment link when the target page's ids do not include the fragment.

  5. Refuses to judge a target it cannot vouch for:

    • a page the crawl did not parse (not fetched, excluded, past the page limit, not HTML) — nothing is known about its ids;
    • a page whose id list hit the 2 000 cap — an id beyond the cap is missing from the list, not from the page;
    • a page that did not answer 2xx — a link to a 404 is the broken-link finding, and a redirect lands on a page this list does not describe.
  6. Raises one suggestion per linking page with the number of such links and up to five examples (same-page ones first), each with the target page and the missing fragment.

Ids are read from the HTML the server sent. An id that a script adds after the page loads is not seen, so a site that builds heading anchors in JavaScript can be reported for anchors that do exist in the browser.

What we store

Storage Level

Page Level


Database Table / Prisma Model

PageFragmentFacts (page_fragment_facts) — one row per parsed page, written by the crawler's page write and read by one post-crawl join. The finding itself is stored on audit_issues.details.


Fields Used — PageFragmentFacts

Field Type Description
page_fragment_facts.url_id String The page the row describes (one row per page)
page_fragment_facts.scan_id String The scan it belongs to
page_fragment_facts.element_ids String[] Every element id, then every <a name>, case kept, at most 2 000. Empty for a page with none — that page is where every fragment is missing
page_fragment_facts.element_ids_truncated Boolean True when the page had more ids than the cap, or one too long to store. Links into such a page are not judged
page_fragment_facts.fragment_links Json [{ targetUrl, fragment }] — the fragments this page links to, at most 200. targetUrl is normalised the way page addresses are stored, or null for the page itself. Null when the page links to no fragment
urls.url String Resolves each targetUrl to the page it names
urls.status_code Int Only a target that answered 2xx is judged

Stored Fields on the finding

Field Type Description
message String "N link(s) on this page point to a #fragment that does not exist on the target page"
url String The page carrying the links
brokenFragmentLinkCount Int How many distinct (target, fragment) links on the page are missing their anchor
samples Array Up to five: { targetUrl, fragment, samePage }, same-page ones first
truncated Boolean True when there were more than five
recommendation String How to fix it

Detection Dependencies

  • HTML Document — element ids and <a name> targets, and every internal link's fragment
  • HTTP Response — the target's status code
  • The whole crawl — a target is judged only once it has been parsed

Further reading