DEV Community

linkbruno.com
linkbruno.com

Posted on

How to find broken links with Playwright (a test you can drop into CI)

If you already use Playwright for end-to-end tests, you have everything you need to check a page for broken links. The browser can collect every link after JavaScript has run, and Playwright's built-in API client can request each one without opening a new tab. This post builds a small, practical broken link test step by step, and covers the parts that usually go wrong: false positives, rate limits and redirects.

The idea

  1. Open a page with page.goto().
  2. Collect every href from <a> elements (and src from images, if you want).
  3. Request each unique URL with Playwright's request fixture.
  4. Fail the test if any of them return an error.

Step 1: collect the links

a.href in the browser returns an absolute URL, so relative links are resolved for you.

// tests/broken-links.spec.ts
import { test, expect } from '@playwright/test';

const START_URL = process.env.START_URL ?? 'http://localhost:3000/';

async function collectLinks(page) {
  const hrefs = await page.$$eval('a[href]', (anchors) =>
    anchors.map((a) => (a as HTMLAnchorElement).href)
  );
  return [...new Set(hrefs)]
    .filter((u) => u.startsWith('http'))          // skip mailto:, tel:, javascript:
    .map((u) => u.split('#')[0]);                  // the fragment is not sent to the server
}
Enter fullscreen mode Exit fullscreen mode

Filtering to http drops mailto:, tel: and javascript: links, which a request can't check. Stripping the #fragment avoids requesting the same page many times; checking that the anchor itself exists is a separate test (see the end).

Step 2: check each link

Playwright's request fixture is an APIRequestContext. It doesn't load the page in a browser, so it is fast, and it follows redirects by default.

async function checkLink(request, url: string) {
  const opts = { timeout: 15_000, maxRedirects: 10, failOnStatusCode: false };
  try {
    let res = await request.head(url, opts);
    // Many servers answer HEAD with 405 or 403 even when GET works.
    if ([403, 405, 501].includes(res.status())) {
      res = await request.get(url, opts);
    }
    return { url, status: res.status() };
  } catch (err) {
    return { url, status: 0, error: String(err).split('\n')[0] };
  }
}
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  • HEAD first, GET as a fallback. HEAD is cheap because the server doesn't send the body. But plenty of servers and CDNs reject it, so retry with GET before you call a link broken.
  • failOnStatusCode: false. You want the status code back, not an exception.

Step 3: put it in a test

test('no broken links on the home page', async ({ page, request }) => {
  await page.goto(START_URL);
  const links = await collectLinks(page);

  const results = [];
  for (const url of links) {
    results.push(await checkLink(request, url));   // one at a time on purpose
  }

  const broken = results.filter((r) => r.status === 0 || r.status >= 400);
  for (const b of broken) console.log(`${b.status}  ${b.url}  ${b.error ?? ''}`);

  expect(broken, `${broken.length} broken links on ${START_URL}`).toEqual([]);
});
Enter fullscreen mode Exit fullscreen mode

Run it:

START_URL=https://staging.example.com/ npx playwright test broken-links
Enter fullscreen mode Exit fullscreen mode

The test checks links one at a time. That is slower than Promise.all, but firing fifty requests at once at a single external host is a good way to get rate-limited, which leads to the next section.

Step 4: deal with false positives

The first run on a real site usually "finds" links that work fine in a browser. Typical causes:

  • 429 Too Many Requests. You checked too many links on one host too quickly. Group URLs by host and add a pause between requests to the same host.
  • 403 Forbidden from sites behind bot protection. The page is fine for people; the site just refuses automated clients.
  • 999 from LinkedIn. It is not a real HTTP status; it is LinkedIn refusing the request.
  • Timeouts from slow servers that are up but sluggish.

Don't fail the build on these. Report them separately:

const BLOCKED = new Set([401, 403, 429, 999]);

const broken  = results.filter((r) => r.status === 0 || (r.status >= 400 && !BLOCKED.has(r.status)));
const blocked = results.filter((r) => BLOCKED.has(r.status));

if (blocked.length) console.warn('Could not verify:', blocked.map((b) => b.url));
Enter fullscreen mode Exit fullscreen mode

A failing check that is retried once a little later also removes most one-off network blips.

Step 5: more than one page

To cover a whole site, read your sitemap and loop over its URLs:

test('no broken links across the sitemap', async ({ page, request }) => {
  const xml = await (await request.get(new URL('/sitemap.xml', START_URL).href)).text();
  const pages = [...xml.matchAll(/<loc>(.*?)<\/loc>/g)].map((m) => m[1]).slice(0, 50);

  const seen = new Map();
  for (const p of pages) {
    await page.goto(p);
    for (const url of await collectLinks(page)) {
      if (!seen.has(url)) seen.set(url, await checkLink(request, url));
    }
  }
  const broken = [...seen.values()].filter((r) => r.status === 0 || r.status >= 400);
  expect(broken).toEqual([]);
});
Enter fullscreen mode Exit fullscreen mode

Caching results in a Map means a link in your header or footer is checked once, not once per page. Cap the number of pages while you experiment, and raise the test timeout (test.setTimeout(...)) for larger sites.

Images and anchors

The same pattern works for images: collect img[src] with page.$$eval('img', imgs => imgs.map(i => i.currentSrc || i.src)). For in-page anchors, collect links with a #fragment, open the target page and check that document.getElementById(fragment) exists. A link to a removed section returns 200, so a status check alone will never catch it.

Running it in CI

Point START_URL at your preview deployment so a pull request that introduces a broken link fails before it merges. Keep external links in a separate, non-blocking job if your build shouldn't depend on other people's servers.

Where this stops

A Playwright test catches the links that are broken when it runs. Most links break later: a partner moves a page, a docs site restructures, a domain expires. That is the gap scheduled monitors fill. If you'd rather not maintain the crawler, linkbruno.com checks a site on a schedule and sends an email or Slack alert when a link stops working.

For a single page or a staging check in CI, the test above is about 60 lines and catches most problems before your readers do.

Top comments (0)