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
- Open a page with
page.goto(). - Collect every
hreffrom<a>elements (andsrcfrom images, if you want). - Request each unique URL with Playwright's
requestfixture. - 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
}
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] };
}
}
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([]);
});
Run it:
START_URL=https://staging.example.com/ npx playwright test broken-links
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));
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([]);
});
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)