CrawlCheck

Guides · 2026-09-23 · By · 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.

Key points
  • 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 #fragments fetch 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.
The seven-step internal link audit1inventoryfrom sitemap2collectevery href3requestthe rest4classifyby request5anchors#id exists?6traceto template7redirectsupdate linksSteps 4 and 5 are where most audits go wrong: one reports correct errors as broken, the other is never run at all.
Status-code crawls cover steps 1 to 3. The judgement in step 4 and the separate check in step 5 are what turn a list of codes into a list of defects.

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.

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:

VariantExampleWhat to do
Schemehttp:// vs https://link only to https; the http address should redirect
Hostwww. vs the bare domainlink 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.txt
Is this link broken? Read the request before the statusa non-200 answer301 / 308moved; the linkworks but costsa hop400 on an APIan API needingparameters:correct answer401 / 403login orfirewall: is itmeant to be public?404 / 410on a page link:brokenupdate the hrefexclude from the listcheck intentfix link or restore page
A status code answers the request that was made. Six 400s on our own site were documentation linking API addresses; counting them as broken would have tripled the defect count with no defect behind it.
AnswerUsually meansAction
200workingnone
301, 308moved permanentlyupdate the link to the final address
302, 307temporary redirectcheck it is meant to be temporary
400 on an API endpointrequest needs parameterscorrect; exclude from the broken list
401, 403login or firewallcheck the page is meant to be public
404, 410 on a page linkbrokenfix the link or restore the page
429the audit was rate-limitedslow down and re-request
5xxserver errorre-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.

How a dead anchor link happensTHE PAGE AS RENDERED<nav> linkshref="#scores"href="#findings"href="#local-pack"<section id="scores"><section id="findings">(local section not drawn: not a local business)WHAT THE CHECK RETURNSOK#scores — id foundOK#findings — id foundDEAD ANCHOR#local-pack — no element carries this idthe link jumps nowhere
A link to #local-pack fetches nothing, so no status-code crawl can flag it. It fails only on pages where the template skipped the section, which is why the check must read the rendered page, not the template.

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 #

One template, many broken pagesShared templatelinks every row toa profile pagepage A: link workspage B: link workspage C: row not in directorypage D: link worksThe fixin the template:link only rowsthat have a profile
Fixing the page that showed the defect fixes one page. Fixing the template fixes every page built from it, including ones the audit has not reached yet. Then test a contrasting page where the link should still work.

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? #

Our own audit, by the numbers924pages fetched from the sitemap2,260distinct internal link targets6answered 400: API addresses, correct1broken page link (one template)3dead in-page anchors (one template)
crawlcheck.io, September 2026. Over two thousand link targets reduced to four real defects, both traced to shared templates, once correct errors were separated from broken links.

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.

Scan a domain — 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

How anything measured in this article was measured15client identitiesone second, one address5machine filesapex and www114named agentsresolved from robots.txt24sections scoredreach, read, quoteHow anything measured here was measured15 client identities5 machine files114 named agents24 sections scoredone second, one addressapex and wwwresolved from robots.txtreach, read, quote
No account, nothing installed, and the same sequence on every domain — which is what makes one scan comparable to another. Run it on your own site.

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.

All guides · The dataset · How the dataset works