Links to a #fragment that does not exist on the target page
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>whosename) is exactly the fragment. Ids are case-sensitive:#Plansdoes not reachid="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
Find what the link meant. Open the target page and find the section the link should land on.
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>Match the case exactly.
#Pricingandid="pricing"do not match.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.
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-tabwith 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
Records, for every page it parses, two lists:
- every element
idon 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.
- every element
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.Waits until the crawl has finished, because the target page may be crawled before or after the page that links to it.
Joins the two lists across the scan and reports a fragment link when the target page's ids do not include the fragment.
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.
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