How to Find Broken Links with Cypress
Use Cypress to check in-page fragment targets and HTTP links separately. Get runnable tests, redirect handling, troubleshooting, and guidance for keeping checks reliable.
To find broken links with Cypress, check in-page fragment links against elements rendered in the current document, and check HTTP links by requesting their destinations with cy.request(). These are different checks: a fragment such as #pricing does not make a separate HTTP request, while a server response alone does not prove that a link reaches the intended content.
The examples below collect links from an application route, skip non-HTTP schemes, validate same-page fragments, and request HTTP destinations. Set a policy for redirects and external URLs that fits your app. Cypress does not include a built-in broken-link checker; this approach uses its documented commands to build one.
1. Decide what counts as a broken link
Before writing the test, choose what the test should report. A useful starting policy is:
- Same-document fragment: the decoded fragment must match an element ID in the rendered document.
- HTTP destination: the response status must meet your policy. Cypress treats 2xx and 3xx as successful by default when
failOnStatusCodeis enabled. - Redirect: follow redirects if only the final response matters; disable following if the redirect itself or its destination matters.
- Content: if a link must reach a particular page, assert on the final URL or relevant response content as well as status.
- Third-party destination: decide whether it belongs in a scheduled monitoring check rather than the main deterministic UI suite.
A 200 response can still be the wrong page, and a 3xx response can be a valid route. Make those distinctions explicit in the test rather than labeling every non-200 status broken.
2. Add a Cypress test that checks fragments and HTTP links
This example assumes a configured baseUrl and a page at /page. It uses Cypress’s bundled browser to parse URLs, resolves relative links against the page URL, checks fragments only when they point back to that page, and makes direct requests for HTTP destinations.
describe('links on /page', () => {
it('checks same-page fragments and HTTP destinations', () => {
cy.visit('/page')
cy.location('href').then((pageHref) => {
const pageUrl = new URL(pageHref)
cy.get('a[href]').then(($anchors) => {
const links = [...$anchors].map((anchor) => ({
href: anchor.getAttribute('href'),
text: anchor.textContent.trim(),
}))
// Make the empty-anchor case an explicit policy decision.
expect(links.length, 'number of links to inspect').to.be.greaterThan(0)
links.forEach(({ href, text }) => {
const label = text || '(link has no text)'
// Empty href and fragment-only href refer to this document.
if (href === '' || href.startsWith('#')) {
if (href === '#') {
// A bare # has no target. Change this policy if your app allows it.
expect(href, `bare fragment on ${pageUrl.href}, link ${label}`).not.to.equal('#')
return
}
const fragment = href.slice(1)
const id = decodeURIComponent(fragment)
const target = [...document.querySelectorAll('[id]')]
.some((element) => element.id === id)
expect(target, `missing fragment target #${id} on ${pageUrl.href}, link ${label}`)
.to.equal(true)
return
}
let destination
try {
destination = new URL(href, pageUrl)
} catch (error) {
throw new Error(`Invalid href ${JSON.stringify(href)} on ${pageUrl.href}: ${error.message}`)
}
// Mail, telephone, JavaScript, and other non-HTTP links are not web requests.
if (!['http:', 'https:'].includes(destination.protocol)) return
// A path-plus-fragment link to this same document still needs a DOM check.
const sameDocument = destination.origin === pageUrl.origin &&
destination.pathname === pageUrl.pathname &&
destination.search === pageUrl.search
if (sameDocument && destination.hash) {
const id = decodeURIComponent(destination.hash.slice(1))
const target = [...document.querySelectorAll('[id]')]
.some((element) => element.id === id)
expect(target, `missing fragment target #${id} on ${pageUrl.href}, link ${label}`)
return
}
cy.request({
method: 'GET',
url: destination.href,
failOnStatusCode: false,
followRedirect: true,
timeout: 30000,
}).then((response) => {
expect(
response.status,
`HTTP status for ${destination.href} linked from ${pageUrl.href} (${label})`,
).to.be.within(200, 399)
})
})
})
})
})
})
The test checks the rendered anchors found at visit time. The fragment check uses the current page’s DOM, and HTTP links are requested directly without loading each destination in a browser. The example allows 2xx and 3xx after following redirects; adjust the range and redirect settings to match your routing policy.
Important implementation note: document inside a Cypress test callback is the test runner’s document, not necessarily the application’s document. To make the fragment lookup reliable, query the AUT document using Cypress’s yielded window. Replace each fragment lookup above with a helper that receives the AUT document:
function hasId(win, id) {
return [...win.document.querySelectorAll('[id]')]
.some((element) => element.id === id)
}
// Inside the cy.get('a[href]').then(...) callback, get the AUT window once:
cy.window().then((win) => {
// Use hasId(win, id) for each same-document fragment check.
})
For a complete version, move the per-link work into a helper called from within cy.window().then((win) => ...) and use hasId(win, id) for fragment assertions. Cypress commands enqueued inside .each() or a .then() callback run through Cypress’s command queue; avoid creating an unbounded number of requests for a page with a very large link set.
3. A focused, runnable fragment-link test
This version handles encoded fragments, links with a path and fragment, and pages with no anchors. It reads the application’s rendered document through cy.window().
describe('fragment links on /page', () => {
it('points every same-document fragment to an existing id', () => {
cy.visit('/page')
cy.location('href').then((href) => {
const pageUrl = new URL(href)
cy.get('body').then(($body) => {
const anchors = [...$body[0].querySelectorAll('a[href]')]
const fragmentLinks = anchors
.map((anchor) => ({ href: anchor.getAttribute('href'), text: anchor.textContent.trim() }))
.filter(({ href }) => href === '' || href.startsWith('#') || href.includes('#'))
// This route is allowed to have no fragment links. Use an assertion here instead
// if at least one fragment link is a requirement for this page.
if (fragmentLinks.length === 0) return
cy.window().then((win) => {
fragmentLinks.forEach(({ href, text }) => {
if (!href || href === '#') {
// Empty href navigates to the current URL; bare # has an empty fragment.
// Set the application's desired policy here.
return
}
const destination = new URL(href, pageUrl)
const sameDocument = destination.origin === pageUrl.origin &&
destination.pathname === pageUrl.pathname &&
destination.search === pageUrl.search
if (!sameDocument || !destination.hash) return
let id
try {
id = decodeURIComponent(destination.hash.slice(1))
} catch (error) {
throw new Error(`Malformed encoded fragment ${destination.hash} in ${href}: ${error.message}`)
}
const exists = [...win.document.querySelectorAll('[id]')]
.some((element) => element.id === id)
expect(exists, `missing #${id} for link ${href} (${text || 'no link text'})`)
.to.equal(true)
})
})
})
})
})
})
For large or dynamically rendered pages, ensure the target sections have rendered before collecting anchors. If the application updates the page after initial load, wait for the app-specific ready condition before inspecting it.
4. Check HTTP links with cy.request()
cy.request() is useful for checking a running server endpoint without visiting and rendering every linked page. It defaults to GET, requires a response, and follows redirects by default. A relative URL is resolved using the current page host or configured baseUrl, depending on when the request is made; resolving to an absolute URL first makes the destination explicit.
The earlier test uses failOnStatusCode: false so the assertion can include the source href and status. If you keep the default true, Cypress fails the request command on a status outside its accepted 2xx/3xx range before your later assertion runs.
Inspect a redirect instead of following it
Use followRedirect: false when the redirect status or destination is part of the link contract. Cypress exposes the normalized redirectedToUrl value for inspection.
cy.request({
url: 'https://app.example.test/old-route',
failOnStatusCode: false,
followRedirect: false,
}).then((response) => {
expect(response.status, 'old route redirects').to.be.within(300, 399)
expect(response.redirectedToUrl).to.include('/new-route')
})
Check that a successful response is the intended page
cy.request('https://app.example.test/help')
.then((response) => {
expect(response.status).to.equal(200)
expect(response.body).to.include('Help center')
})
A status check is only as useful as the policy behind it. Some applications intentionally use 204 endpoints or redirects; others need a final 200 page and a recognizable title or body marker.
5. Filter link types and resolve URLs carefully
| Link form | Recommended handling |
|---|---|
#section |
Decode the fragment and look for an exact matching id in the current document. |
/guide#install |
Resolve against the current page. If it is the same document, check the fragment in the DOM; if it is another page, request the URL and consider separately validating the destination fragment. |
https://host/path |
Make a direct request only if that host is in scope for this suite. |
mailto:team@example.test, tel:+1... |
Skip HTTP checks; these are actions, not web page requests. |
javascript: or custom scheme |
Do not pass it to cy.request(). Decide whether the link is allowed by your application. |
Empty href or bare # |
Set an explicit policy. They often mean current page or top-of-page navigation, but may indicate an incomplete link. |
Use new URL(href, pageUrl) to normalize relative destinations before checking protocol, origin, path, and query. A fragment is URL-encoded; decode it before comparing with an element ID. Avoid building a CSS selector directly from arbitrary fragment text. Comparing IDs in JavaScript avoids selector escaping errors and handles unusual ID characters.
Duplicate IDs are invalid HTML and can hide a defect: a simple existence check passes if at least one element has the ID. If uniqueness matters, assert exactly one match for each fragment target.
6. Keep external-link checks reliable
Cypress advises against visiting origins your team does not control in tests. A direct request can avoid browser CORS restrictions, but it does not make the external service stable: it may throttle automated traffic, block the request, require authentication, or have a temporary outage. Keep app-owned routes in the main deterministic suite. If third-party link health is a real requirement, isolate it in a scheduled or separately reported check so a remote outage does not obscure a UI regression.
cy.intercept() has a different job: it observes, waits for, or stubs traffic created by the application. It is not a substitute for requesting every destination in the page. Use it to control app behavior in UI tests; use cy.request() when you need to inspect a real server response.
7. Troubleshooting common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| The fragment assertion fails even though the section is visible. | The test queried the Cypress runner document, or the page had not finished rendering. | Use cy.window() to inspect the AUT document, and wait for the app-specific ready state before collecting links. |
An encoded fragment such as #getting%20started is reported missing. |
The test compared encoded URL text directly with the element ID. | Decode the fragment with decodeURIComponent, handling malformed encodings as a test error. |
| A relative link request goes to the wrong host. | The relative URL was resolved from a different current page or base URL context. | Resolve it with new URL(href, pageUrl) and pass the resulting absolute URL to cy.request(). |
| A redirect causes a failure despite a working destination. | The test expects only 200, while Cypress follows redirects or the route legitimately returns a redirect. | Define whether 3xx is acceptable. Follow redirects for final status checks, or set followRedirect: false and assert the redirect target. |
| A 200 link still leads to the wrong content. | Status alone only proves that a response was returned. | Assert the final URL, a title marker, or expected response content. |
| Some external links fail intermittently. | Remote throttling, bot blocking, authentication, rate limiting, or an outage. | Keep third-party checks out of the core UI suite or isolate and schedule them; do not silently treat every transient failure as an app regression. |
| The test fails on a page with no anchors. | The test assumes links must exist. | Choose explicitly whether zero links is valid; return for an empty set or assert a minimum when the page requires navigation. |
| Many links make the test slow or flaky. | Requests are serialized through the Cypress command queue and destinations may be slow. | Limit the audit to app-owned links, select representative routes, and move broad link inventories to an isolated scheduled job. |
cy.request() times out. |
The server did not respond within the request timeout or is unreachable from the test environment. | Check environment connectivity and server health; tune the timeout for known slow endpoints rather than masking the failure globally. |
8. Performance, reliability, and cost
Fragment checks are local DOM work and add no network request per link. HTTP checks make one request per destination, so a large site can turn a single test into a slow serial audit. Deduplicate identical normalized URLs, restrict the suite to owned routes, and use a representative page set. If broader coverage is needed, run it separately from the fastest pull-request UI checks.
A direct request is lighter than launching a browser page for every URL, but network reliability still depends on the target server and test environment. Cypress’s request assertions are evaluated once rather than retried like query assertions. A timeout, DNS issue, or remote 5xx therefore needs a clear classification in the failure output.
The main costs are test time, request volume against your own services, and the maintenance burden of deciding what statuses and redirects are valid. Avoid sending high-volume automated checks to third-party sites without considering their policies.
9. Or skip the browser setup
If the job is capturing a clean visual of a page rather than testing its links, ScreenshotNeo is a website screenshot API and MCP server. Its one-call API returns a screenshot or PDF, and it can also capture a page after accepting cookie consent and removing known consent banners, newsletter popups, and chat widgets. Those clean captures can help document a broken-link report, but they do not replace Cypress assertions or prove a destination works.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
See the ScreenshotNeo API documentation for request options. Cookie banners, popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The Free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000.
Create a free ScreenshotNeo account and get 1,000 screenshots a month with no card.
10. FAQ
Does Cypress have a built-in broken-link command?
No. Use Cypress commands such as cy.get(), cy.window(), and cy.request() to implement the checks your application needs.
Should I check every external link on every test run?
Usually not in the core UI suite. Third-party availability and access policies are outside your app’s control; isolate that monitoring if it is required.
Can a passing status check guarantee a useful link?
No. Check the redirect destination or expected content when the route’s meaning matters.
Does CORS prevent cy.request() from checking a different origin?
cy.request() runs as a direct server request and bypasses browser CORS. The destination can still reject or throttle automated requests.
Sources
- Cypress:
cy.request()— request defaults, redirects, status handling, and response inspection. - Cypress: Cross Origin Testing — guidance about testing origins you do not control.
- Cypress blog: Testing Links in Web Apps — checking anchor targets in a rendered page.
- Cypress:
cy.intercept()— observing or stubbing application traffic.


