Cases / #22 · 2026-10-06 · Easy
Check every link on a page: 73 links in seconds, from inside the browser
A link checker that stays the browser: collect the links, verify them with fetch() inside the page eight at a time, and compare with navigating to each one.
Run on production on 2026-10-06: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Checking that every link on a page still works is the oldest crawl job there is, and the obvious browser version — open the page, click or navigate to each link, read the status — is also the slowest: every navigation loads a whole page with its stylesheets, scripts and images through your proxy. Doing the checks from your own HTTP client instead is fast, but then the checks come from a different IP, a different TLS stack and a different user agent than the browsing session (four ways to make a request). The middle path is to let the browser do the requests without navigating.
What we used, and why
| What | Why |
|---|---|
chromium, headless: "new" | One session, one thread; nothing here is browser-specific. |
page.$$eval("a[href]", …) | Collects every link as an absolute URL, same-site only, de-duplicated, fragments dropped. |
fetch(url, { cache: "no-store" }) inside page.evaluate, 8 URLs per call | The browser's network stack, proxy, cookies and headers — but no navigation, no rendering, no sub-resources. One round trip per wave of eight. |
page.goto for a 10-link sample | The baseline: what a user-like check costs per link. |
page.on("request") counter | Shows what the navigations really fetched: not 10 pages but 288 requests. |
How it works
- Open the front page and collect its same-site links (73 unique URLs).
- Check all 73 with
fetch()inside the page, eight at a time; record status, redirects and failures. - Navigate to the first 10 links one by one with
page.goto, counting every request the browser makes. - Print one row per method: links checked, OK / redirected / broken, seconds, seconds per link, requests made.
The code
The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL
import { chromium } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const START = 'https://books.toscrape.com/'; // 70-odd same-site links on the front page
const WAVE = 8; // links checked at once
const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: 'new' }),
});
if (!res.ok) throw new Error(`launch ${res.status} ${await res.text()}`);
const { wsUrl } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
const summary = (method, results, seconds, extra = {}) => ({
method,
links: results.length,
ok: results.filter((r) => r.status >= 200 && r.status < 300).length,
redirected: results.filter((r) => r.redirected).length,
broken: results.filter((r) => r.status === 0 || r.status >= 400).length,
broken_urls: results.filter((r) => r.status === 0 || r.status >= 400).map((r) => r.url).slice(0, 5),
seconds,
seconds_per_link: Math.round((seconds / results.length) * 100) / 100,
...extra,
});
try {
const page = await browser.newPage();
await page.goto(START, { timeout: 60000 });
// Every unique same-site link on the page, as absolute URLs.
const links = await page.$$eval('a[href]', (as, origin) => [...new Set(as.map((a) => a.href))].filter((h) => h.startsWith(origin) && !h.includes('#')), new URL(START).origin);
// Way 1: fetch() inside the page, WAVE links at a time — the browser's proxy, cookies
// and TLS, no navigation, no rendering, no assets.
const t1 = Date.now();
const fetched = [];
for (let i = 0; i < links.length; i += WAVE) {
const wave = links.slice(i, i + WAVE);
fetched.push(...await page.evaluate((urls) => Promise.all(urls.map(async (url) => {
try { const r = await fetch(url, { cache: 'no-store' }); return { url, status: r.status, redirected: r.redirected }; } catch { return { url, status: 0, redirected: false }; }
})), wave));
}
const inPage = summary(`fetch() in the page, ${WAVE} at a time`, fetched, (Date.now() - t1) / 1000);
// Way 2: navigate to each link, like a user — full page loads with all their assets.
// Only a sample: this is the slow way, and it is the same work for every link.
const sample = links.slice(0, 10);
let requests = 0;
page.on('request', () => { requests++; });
const t2 = Date.now();
const navigated = [];
for (const url of sample) {
try { const r = await page.goto(url, { timeout: 60000 }); navigated.push({ url, status: r ? r.status() : 0, redirected: r ? r.url() !== url : false }); } catch { navigated.push({ url, status: 0, redirected: false }); }
}
const byNav = summary('page.goto each link (10-link sample)', navigated, (Date.now() - t2) / 1000, { requests_made: requests });
console.log(JSON.stringify([{ ...inPage, requests_made: links.length }, byNav], null, 2));
} finally {
await browser.close();
}
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
import time
from urllib.parse import urlparse
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
START = "https://books.toscrape.com/" # 70-odd same-site links on the front page
WAVE = 8 # links checked at once
LINKS_JS = "(as, origin) => [...new Set(as.map(a => a.href))].filter(h => h.startsWith(origin) && !h.includes('#'))"
FETCH_JS = """urls => Promise.all(urls.map(async url => {
try { const r = await fetch(url, { cache: 'no-store' }); return { url, status: r.status, redirected: r.redirected }; }
catch { return { url, status: 0, redirected: false }; }
}))"""
def summary(method, results, seconds, **extra):
broken = [r for r in results if r["status"] == 0 or r["status"] >= 400]
return {
"method": method,
"links": len(results),
"ok": sum(1 for r in results if 200 <= r["status"] < 300),
"redirected": sum(1 for r in results if r["redirected"]),
"broken": len(broken),
"broken_urls": [r["url"] for r in broken][:5],
"seconds": round(seconds, 3),
"seconds_per_link": round(seconds / len(results), 2),
**extra,
}
res = requests.post("https://starter.cdpfleet.com/chromium/session", headers={"x-api-key": KEY},
json={"proxy": os.environ["PROXY_URL"], "headless": "new"}, timeout=60)
if not res.ok:
raise SystemExit(f"launch {res.status_code} {res.text}")
with sync_playwright() as p:
browser = p.chromium.connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
try:
page = browser.new_page()
page.goto(START, timeout=60000)
# Every unique same-site link on the page, as absolute URLs.
origin = f"{urlparse(START).scheme}://{urlparse(START).netloc}"
links = page.eval_on_selector_all("a[href]", LINKS_JS, origin)
# Way 1: fetch() inside the page, WAVE links at a time — the browser's proxy, cookies
# and TLS, no navigation, no rendering, no assets.
t1 = time.time()
fetched = []
for i in range(0, len(links), WAVE):
fetched.extend(page.evaluate(FETCH_JS, links[i:i + WAVE]))
in_page = summary(f"fetch() in the page, {WAVE} at a time", fetched, time.time() - t1, requests_made=len(links))
# Way 2: navigate to each link, like a user — full page loads with all their assets.
# Only a sample: this is the slow way, and it is the same work for every link.
sample = links[:10]
requests_ = {"n": 0}
page.on("request", lambda _: requests_.__setitem__("n", requests_["n"] + 1))
t2 = time.time()
navigated = []
for url in sample:
try:
r = page.goto(url, timeout=60000)
navigated.append({"url": url, "status": r.status if r else 0, "redirected": r.url != url if r else False})
except Exception:
navigated.append({"url": url, "status": 0, "redirected": False})
by_nav = summary("page.goto each link (10-link sample)", navigated, time.time() - t2, requests_made=requests_["n"])
print(json.dumps([in_page, by_nav], indent=2, ensure_ascii=False))
finally:
browser.close()
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1. env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.util.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final String START = "https://books.toscrape.com/"; // 70-odd same-site links on the front page
static final int WAVE = 8; // links checked at once
static final String LINKS_JS = "(as, origin) => [...new Set(as.map(a => a.href))].filter(h => h.startsWith(origin) && !h.includes('#'))";
static final String FETCH_JS = "urls => Promise.all(urls.map(async url => {"
+ " try { const r = await fetch(url, { cache: 'no-store' }); return { url, status: r.status, redirected: r.redirected }; }"
+ " catch { return { url, status: 0, redirected: false }; } }))";
static final Gson GSON = new GsonBuilder().serializeNulls().create();
record Result(String url, int status, boolean redirected) {}
static JsonObject summary(String method, List<Result> results, double seconds, int requestsMade) {
List<Result> broken = results.stream().filter(r -> r.status == 0 || r.status >= 400).toList();
JsonObject row = new JsonObject();
row.addProperty("method", method);
row.addProperty("links", results.size());
row.addProperty("ok", results.stream().filter(r -> r.status >= 200 && r.status < 300).count());
row.addProperty("redirected", results.stream().filter(r -> r.redirected).count());
row.addProperty("broken", broken.size());
JsonArray urls = new JsonArray();
broken.stream().limit(5).forEach(r -> urls.add(r.url));
row.add("broken_urls", urls);
row.addProperty("seconds", seconds);
row.addProperty("seconds_per_link", Math.round(seconds / results.size() * 100) / 100.0);
row.addProperty("requests_made", requestsMade);
return row;
}
public static void main(String[] args) throws Exception {
String body = "{\"proxy\": " + GSON.toJson(System.getenv("PROXY_URL")) + ", \"headless\": \"new\"}";
HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chromium/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException("launch: " + res.statusCode() + " " + res.body());
String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
try (Playwright playwright = Playwright.create()) {
Browser browser = playwright.chromium().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
Page page = browser.newPage();
page.navigate(START, new Page.NavigateOptions().setTimeout(60000));
// Every unique same-site link on the page, as absolute URLs.
URI startUri = URI.create(START);
String origin = startUri.getScheme() + "://" + startUri.getAuthority();
List<String> links = new ArrayList<>();
for (JsonElement e : GSON.toJsonTree(page.evalOnSelectorAll("a[href]", LINKS_JS, origin)).getAsJsonArray()) links.add(e.getAsString());
// Way 1: fetch() inside the page, WAVE links at a time — the browser's proxy, cookies
// and TLS, no navigation, no rendering, no assets.
long t1 = System.currentTimeMillis();
List<Result> fetched = new ArrayList<>();
for (int i = 0; i < links.size(); i += WAVE) {
List<String> wave = links.subList(i, Math.min(i + WAVE, links.size()));
for (JsonElement e : GSON.toJsonTree(page.evaluate(FETCH_JS, wave)).getAsJsonArray()) {
JsonObject o = e.getAsJsonObject();
fetched.add(new Result(o.get("url").getAsString(), o.get("status").getAsInt(), o.get("redirected").getAsBoolean()));
}
}
JsonObject inPage = summary("fetch() in the page, " + WAVE + " at a time", fetched, (System.currentTimeMillis() - t1) / 1000.0, links.size());
// Way 2: navigate to each link, like a user — full page loads with all their assets.
// Only a sample: this is the slow way, and it is the same work for every link.
List<String> sample = links.subList(0, Math.min(10, links.size()));
int[] requests = {0};
page.onRequest(req -> requests[0]++);
long t2 = System.currentTimeMillis();
List<Result> navigated = new ArrayList<>();
for (String url : sample) {
try {
Response r = page.navigate(url, new Page.NavigateOptions().setTimeout(60000));
navigated.add(new Result(url, r != null ? r.status() : 0, r != null && !r.url().equals(url)));
} catch (PlaywrightException e) {
navigated.add(new Result(url, 0, false));
}
}
JsonObject byNav = summary("page.goto each link (10-link sample)", navigated, (System.currentTimeMillis() - t2) / 1000.0, requests[0]);
JsonArray out = new JsonArray();
out.add(inPage);
out.add(byNav);
System.out.println(new GsonBuilder().serializeNulls().setPrettyPrinting().disableHtmlEscaping().create().toJson(out));
} finally {
browser.close();
}
}
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Diagnostics;
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
const string Start = "https://books.toscrape.com/"; // 70-odd same-site links on the front page
const int Wave = 8; // links checked at once
const string LinksJs = "(as, origin) => [...new Set(as.map(a => a.href))].filter(h => h.startsWith(origin) && !h.includes('#'))";
const string FetchJs = @"urls => Promise.all(urls.map(async url => {
try { const r = await fetch(url, { cache: 'no-store' }); return { url, status: r.status, redirected: r.redirected }; }
catch { return { url, status: 0, redirected: false }; }
}))";
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chromium/session",
new { proxy = Environment.GetEnvironmentVariable("PROXY_URL"), headless = "new" });
if (!res.IsSuccessStatusCode) throw new Exception($"launch: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
static JsonObject Summary(string method, List<(string Url, int Status, bool Redirected)> results, double seconds, int requestsMade)
{
var broken = results.Where(r => r.Status == 0 || r.Status >= 400).ToList();
return new JsonObject
{
["method"] = method,
["links"] = results.Count,
["ok"] = results.Count(r => r.Status >= 200 && r.Status < 300),
["redirected"] = results.Count(r => r.Redirected),
["broken"] = broken.Count,
["broken_urls"] = new JsonArray(broken.Take(5).Select(r => (JsonNode)r.Url).ToArray()),
["seconds"] = Math.Round(seconds, 3),
["seconds_per_link"] = Math.Round(seconds / results.Count, 2),
["requests_made"] = requestsMade,
};
}
using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
var page = await browser.NewPageAsync();
await page.GotoAsync(Start, new() { Timeout = 60000 });
// Every unique same-site link on the page, as absolute URLs.
var origin = new Uri(Start).GetLeftPart(UriPartial.Authority);
var links = (await page.EvalOnSelectorAllAsync<JsonElement>("a[href]", LinksJs, origin)).EnumerateArray().Select(e => e.GetString()!).ToList();
// Way 1: fetch() inside the page, Wave links at a time — the browser's proxy, cookies
// and TLS, no navigation, no rendering, no assets.
var sw1 = Stopwatch.StartNew();
var fetched = new List<(string, int, bool)>();
for (int i = 0; i < links.Count; i += Wave)
{
var wave = links.Skip(i).Take(Wave).ToArray();
var results = await page.EvaluateAsync<JsonElement>(FetchJs, wave);
foreach (var r in results.EnumerateArray())
fetched.Add((r.GetProperty("url").GetString()!, r.GetProperty("status").GetInt32(), r.GetProperty("redirected").GetBoolean()));
}
var inPage = Summary($"fetch() in the page, {Wave} at a time", fetched, sw1.Elapsed.TotalSeconds, links.Count);
// Way 2: navigate to each link, like a user — full page loads with all their assets.
// Only a sample: this is the slow way, and it is the same work for every link.
var sample = links.Take(10).ToList();
int requests = 0;
page.Request += (_, _) => Interlocked.Increment(ref requests);
var sw2 = Stopwatch.StartNew();
var navigated = new List<(string, int, bool)>();
foreach (var url in sample)
{
try
{
var r = await page.GotoAsync(url, new() { Timeout = 60000 });
navigated.Add((url, r?.Status ?? 0, r != null && r.Url != url));
}
catch (Exception e) when (e is PlaywrightException or TimeoutException)
{
navigated.Add((url, 0, false));
}
}
var byNav = Summary("page.goto each link (10-link sample)", navigated, sw2.Elapsed.TotalSeconds, requests);
var outRows = new JsonArray { inPage, byNav };
Console.WriteLine(outRows.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
}
finally
{
await browser.CloseAsync();
}
// go get github.com/playwright-community/[email protected]
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"os"
"sync"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
const start = "https://books.toscrape.com/" // 70-odd same-site links on the front page
const wave = 8 // links checked at once
const linksJS = "(as, origin) => [...new Set(as.map(a => a.href))].filter(h => h.startsWith(origin) && !h.includes('#'))"
const fetchJS = "urls => Promise.all(urls.map(async url => {" +
" try { const r = await fetch(url, { cache: 'no-store' }); return { url, status: r.status, redirected: r.redirected }; }" +
" catch { return { url, status: 0, redirected: false }; } }))"
func launch(name string, options map[string]any) (map[string]any, error) {
body, _ := json.Marshal(options)
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+name+"/session", bytes.NewReader(body))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
return nil, fmt.Errorf("launch %s: %s %s", name, res.Status, msg)
}
var session map[string]any
return session, json.NewDecoder(res.Body).Decode(&session)
}
type result struct {
URL string
Status int
Redirected bool
}
type row struct {
Method string `json:"method"`
Links int `json:"links"`
OK int `json:"ok"`
Redirected int `json:"redirected"`
Broken int `json:"broken"`
BrokenURLs []string `json:"broken_urls"`
Seconds float64 `json:"seconds"`
SecondsPerLink float64 `json:"seconds_per_link"`
RequestsMade int `json:"requests_made"`
}
// Evaluate returns whole numbers as int and others as float64.
func num(v any) int {
switch n := v.(type) {
case int:
return n
case float64:
return int(n)
}
return 0
}
func summary(method string, results []result, d time.Duration, requestsMade int) row {
r := row{Method: method, Links: len(results), BrokenURLs: []string{}, RequestsMade: requestsMade}
for _, x := range results {
switch {
case x.Status >= 200 && x.Status < 300:
r.OK++
}
if x.Redirected {
r.Redirected++
}
if x.Status == 0 || x.Status >= 400 {
r.Broken++
if len(r.BrokenURLs) < 5 {
r.BrokenURLs = append(r.BrokenURLs, x.URL)
}
}
}
r.Seconds = math.Round(d.Seconds()*1000) / 1000
r.SecondsPerLink = math.Round(d.Seconds()/float64(len(results))*100) / 100
return r
}
func main() {
session, err := launch("chromium", map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": "new"})
if err != nil {
log.Fatal(err)
}
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
log.Fatal(err)
}
defer pw.Stop()
browser, err := pw.Chromium.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
log.Fatal(err)
}
defer browser.Close()
page, err := browser.NewPage()
if err != nil {
log.Fatal(err)
}
if _, err := page.Goto(start, playwright.PageGotoOptions{Timeout: playwright.Float(60000)}); err != nil {
log.Fatal(err)
}
// Every unique same-site link on the page, as absolute URLs.
u, _ := url.Parse(start)
v, err := page.EvalOnSelectorAll("a[href]", linksJS, u.Scheme+"://"+u.Host)
if err != nil {
log.Fatal(err)
}
var links []string
for _, l := range v.([]any) {
links = append(links, l.(string))
}
// Way 1: fetch() inside the page, wave links at a time — the browser's proxy, cookies
// and TLS, no navigation, no rendering, no assets.
t1 := time.Now()
var fetched []result
for i := 0; i < len(links); i += wave {
end := i + wave
if end > len(links) {
end = len(links)
}
v, err := page.Evaluate(fetchJS, links[i:end])
if err != nil {
log.Fatal(err)
}
for _, x := range v.([]any) {
m := x.(map[string]any)
fetched = append(fetched, result{URL: m["url"].(string), Status: num(m["status"]), Redirected: m["redirected"].(bool)})
}
}
inPage := summary(fmt.Sprintf("fetch() in the page, %d at a time", wave), fetched, time.Since(t1), len(links))
// Way 2: navigate to each link, like a user — full page loads with all their assets.
// Only a sample: this is the slow way, and it is the same work for every link.
sample := links
if len(sample) > 10 {
sample = sample[:10]
}
var mu sync.Mutex
requests := 0
page.OnRequest(func(playwright.Request) { mu.Lock(); requests++; mu.Unlock() })
t2 := time.Now()
var navigated []result
for _, link := range sample {
r, err := page.Goto(link, playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
if err != nil || r == nil {
navigated = append(navigated, result{URL: link})
continue
}
navigated = append(navigated, result{URL: link, Status: r.Status(), Redirected: r.URL() != link})
}
mu.Lock()
n := requests
mu.Unlock()
byNav := summary("page.goto each link (10-link sample)", navigated, time.Since(t2), n)
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
enc.Encode([]row{inPage, byNav})
fmt.Print(buf.String())
}
What we got
| Method | Links | OK | Redirected | Broken | Seconds | Per link (s) | Requests |
|---|---|---|---|---|---|---|---|
| fetch() in the page, 8 at a time | 73 | 73 | 0 | 0 | 7.629 | 0.1 | 73 |
| page.goto each link (10-link sample) | 10 | 10 | 0 | 0 | 22.248 | 2.22 | 288 |
From the Node.js run on 2026-10-06. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.
Raw output (Node.js)
[
{
"method": "fetch() in the page, 8 at a time",
"links": 73,
"ok": 73,
"redirected": 0,
"broken": 0,
"broken_urls": [],
"seconds": 7.629,
"seconds_per_link": 0.1,
"requests_made": 73
},
{
"method": "page.goto each link (10-link sample)",
"links": 10,
"ok": 10,
"redirected": 0,
"broken": 0,
"broken_urls": [],
"seconds": 22.248,
"seconds_per_link": 2.22,
"requests_made": 288
}
]Takeaways
- In-page fetch checked 73 links in 4–13 seconds — 0.06–0.17 s per link — through a residential proxy (one slow-proxy run took 54 s). Navigating cost 1.3–2.2 s per link in the same runs: 12–25× more, because each page load pulled in its assets (288 requests for 10 pages).
- The checks are indistinguishable from the browser browsing: same exit, same TLS fingerprint, same cookies and
Accept-Language, because they *are* the browser. Your own HTTP client would have been faster still, and a different visitor. - Status, not content:
fetchgives you the status code and whether the request was redirected, which is all a link check needs. If you need the page's rendered state, that is a navigation, and it costs what navigations cost. - Waves, not one-by-one: eight concurrent fetches per
page.evaluatecall means one proxy round trip per eight links. Raise the wave size until the site or the proxy pushes back. - All 73 links were fine — the useful output of a link checker is usually an empty list.