ScreenshotNeo

BlogHow-to

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.

By the ScreenshotNeo team4 October 202611 min read

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.

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 failOnStatusCode is 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.

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.

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.

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.

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.

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

No. Use Cypress commands such as cy.get(), cy.window(), and cy.request() to implement the checks your application needs.

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.

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