Guides · 2026-09-23 · By VSNARY | Emmanuel Orta · 2 views
How to find broken internal links and dead anchor links
A link audit is an inventory, a fetch, a classification and a second pass over in-page anchors. The classification is where most audits go wrong, and the anchors are where most audits never look.
Start from the sitemap, fetch every page, and collect every internal link on those pages. Request the targets the sitemap does not list and record each status. Classify by what the request was: a redirect is working but worth updating, a 400 on an API endpoint without parameters is correct, a 404 on a page link is broken. Then check that every in-page link has a matching id in the rendered page, and trace each defect to the template that generated it.
Most internal link audits are a crawl and a list of non-200 responses. That finds some broken links, reports others that are not broken, and misses a whole class of dead links entirely: the ones that point at a place on the same page. This guide covers the full method, in the order we ran it on crawlcheck.io, with the pitfalls that produced wrong answers along the way.
- A full link audit is seven steps: inventory, collect, request, classify, check anchors, trace to templates, and update redirected links.
- A status code answers the request that was made: a 400 from an API endpoint linked without its parameters is correct, not broken.
- Links to
#fragmentsfetch nothing, so a status-code crawl cannot find dead anchors; they need a crawler that checks fragments, such as Screaming Frog with Crawl Fragment Identifiers switched on, or a check of the rendered page. - On a templated site most broken links come from shared code, so the fix belongs in the template.
- On crawlcheck.io, 2,260 link targets came down to four real defects, all from two templates.
Step 1: how do you build the inventory? #
Start from the sitemap. It is the site’s own statement of which pages exist. Fetch it, list every URL, and request each one. Every URL in a sitemap should answer 200 on its final hop; a sitemap that lists redirects or errors is itself the first finding. Checking it first also tells you whether it can be trusted as the inventory. The sitemap audit guide covers what to look for in the file itself.
Step 2: collect every internal link #
On each fetched page, collect every href that points inside the site: relative paths, and absolute URLs on your own host. Strip fragments and query strings for this pass, and de-duplicate. On crawlcheck.io, 924 pages produced 2,260 distinct targets. Many will already be in the sitemap. The ones that are not are the interesting part: pages the site links to but does not declare, and addresses that should not exist.
Collect from the served HTML, not from the source templates. Links assembled at render time, or included only for certain records, appear only in the output. Skip links inside <script> and <template> blocks; text that looks like a URL in a script is not a link a reader or crawler follows.
What counts as one address? #
Before comparing links with the sitemap, normalise them, or the same page counts several times and every variant looks like a separate defect. These are different URLs to a crawler even when they show the same page:
| Variant | Example | What to do |
|---|---|---|
| Scheme | http:// vs https:// | link only to https; the http address should redirect |
| Host | www. vs the bare domain | link only to the one the site serves; the other should redirect |
| Trailing slash | /pricing vs /pricing/ | use the form the canonical tag declares |
| Case | /Pricing/ | paths are case-sensitive; link in the canonical case |
| Tracking parameters | ?utm_source=… | never on internal links; they split one page into many URLs |
Every variant that redirects is a working link with a cost; every variant that answers 200 without redirecting is a duplicate page. Both are worth fixing, but neither is a broken link, and an audit that counts them as broken buries the real defects.
Step 3: request what the sitemap does not cover #
Request each target that is not in the sitemap, with a short timeout, and record the final status and whether it redirected. Run requests in parallel if the site can take it, but keep the count modest: a link audit that trips your own rate limit produces a list of 429s that are about the audit, not the links. From a terminal, one line per URL is enough:
while read u; do
curl -s -o /dev/null -w "%{http_code} %{num_redirects} $u\n" -L --max-time 10 "$u"
done < targets.txtStep 4: how do you tell a broken link from a correct error? #
| Answer | Usually means | Action |
|---|---|---|
| 200 | working | none |
| 301, 308 | moved permanently | update the link to the final address |
| 302, 307 | temporary redirect | check it is meant to be temporary |
| 400 on an API endpoint | request needs parameters | correct; exclude from the broken list |
| 401, 403 | login or firewall | check the page is meant to be public |
| 404, 410 on a page link | broken | fix the link or restore the page |
| 429 | the audit was rate-limited | slow down and re-request |
| 5xx | server error | re-request; persistent errors are defects |
On crawlcheck.io, six links answered 400. Every one was an API endpoint linked from documentation as an address, which needs a domain in the request to do anything. A 400 is the correct answer to an incomplete request.
Step 5: how do you find broken anchor links? #
A link to #section works only if the same page contains an element whose id matches. Checking this means reading each rendered page, listing every link that starts with #, and confirming each target exists. In a browser console, one line does it for the open page:
[...document.querySelectorAll('a[href^="#"]')]
.map(a => decodeURIComponent(a.getAttribute('href').slice(1)))
.filter(id => id && !document.getElementById(id))An empty result means every anchor resolves. Run it on the rendered document, not the source: sections drawn only for some records exist in the template but not on the page. On crawlcheck.io, one report carried 38 in-page anchors, and three pointed at sections drawn only for local businesses. Query for any element with the id, not a specific kind of element; our first check looked for a section element with a given id, reported it missing, and was wrong because the id was on a different element. A check narrower than the question it claims to answer produces confident false positives.
Duplicate ids matter here too. If two elements share an id, the link jumps to the first one, which is often not the one intended. A rendered-page check should list ids that appear more than once and are the target of a link.
Step 6: trace each defect to its template #
On a site built from templates, a broken link is rarely a one-off. It is an assumption in shared code that fails for some input. Our one broken page link came from a table that links every row to a directory profile, used on a page whose single row is deliberately not in the directory. The three dead anchors came from a list that linked every section, drawn or not. Fixing each at the template fixed every page of that kind at once. After the anchor fix, a report for a local business still linked all three sections, and all three were there.
Step 7: decide what redirects to keep #
A redirected internal link works, but it costs every crawler an extra request and dilutes the signal of which address is canonical. Update internal links to point at final addresses. Keep the redirect for anyone arriving from outside. When you remove a page that was linked, redirect it to its closest replacement rather than letting it 404; the canonical URL guide covers keeping one address per page.
What did the audit find on our own site? #
What about links to other sites? #
Outbound links break more often than internal ones, because you control none of the pages they point at. Check them on a slower schedule, monthly is usually enough, and treat their errors with more caution: a 403 or 429 from another site is often its bot protection refusing an automated request, not a dead page. Open any outbound link that fails the automated check in a browser before you remove it, and replace a genuinely dead source with an archived copy or a current equivalent rather than deleting the citation.
How often should you run it? #
Run the full audit after any change to shared templates, navigation or routing, because those are the changes that break links on many pages at once. Between those, a check of the sitemap and the pages it lists catches most regressions. The method needs nothing beyond a sitemap, an HTTP client and a way to read rendered HTML. A CrawlCheck scan checks the homepage’s links and reads the sitemap on every run, and the accessibility section reports skip links that point at a missing id, which is the same dead-anchor defect in its most visible place.
Every figure above came out of this scanner.
Point it at your own domain and see the same measurements, free.
The main product
Found this on your own site? We fix it for $749.
Scan free to see where you stand. The fix is one site, every finding implemented and re-measured, with a sealed before and after.
Questions this post answers
What is the best starting point for an internal link audit?
The sitemap. It is the site's own list of pages, so fetch every URL in it first, then collect the links on those pages.
Is a 400 response always a broken link?
No. An API endpoint linked from documentation will answer 400 when requested without its required parameters. That is the correct response to an incomplete request.
How do I find broken in-page anchors?
List every link starting with # on the rendered page and check that an element with that id exists on the same page. A status-code crawl cannot find these, because the link fetches nothing.
Why does a crawler not report dead anchor links?
Because a link to #section requests no new page, so there is no status code to check, and most crawlers ignore the part after the hash by default. Screaming Frog checks them if you enable Crawl Fragment Identifiers; otherwise read the rendered page and look for the id.
Should internal links point at redirects?
They work, but each costs an extra request. Update internal links to the final address and keep the redirect for visitors arriving from elsewhere.
Why trace a broken link back to its template?
On a templated site, one defect in shared code usually breaks the same link on every page built from it. Fixing the template fixes all of them.
How often should I audit internal links?
After any change to shared templates, navigation or routing, and with a lighter sitemap check between those.
Does Google Search Console show broken internal links?
Partly. Its Page indexing report lists URLs that returned 404 when Google crawled them, and inspecting one can show a page that referred to it. It reflects Google's own crawl schedule and does not check in-page anchors, so it adds to an audit rather than replacing one.
Can Screaming Frog find broken anchor links?
Yes, if you switch it on. Enable Crawl Fragment Identifiers under Config > Spider > Advanced, crawl the site, then apply the Broken Bookmark filter on the URL tab. The setting is off by default, so a standard crawl does not report them.
Related findings
Comments
Comments are read before they appear. Nothing is published automatically, and no account is needed.
Writing about this? Facts, live figures and marks — every number on that page is dated and traceable to a scan.