[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #22 · 2026-10-06 · Example · Easy · for [testing](https://cdpfleet.com/cross-browser-testing), [monitoring](https://cdpfleet.com/monitoring)

# 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.

[Chromium](https://cdpfleet.com/docs/browsers/chromium)

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](https://cdpfleet.com/docs/cases/proxy-paths)). 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

1.  Open the front page and collect its same-site links (73 unique URLs).
2.  Check all 73 with `fetch()` inside the page, eight at a time; record status, redirects and failures.
3.  Navigate to the first 10 links one by one with `page.goto`, counting every request the browser makes.
4.  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](https://github.com/cdpfleet/cdpfleet/tree/main/cases/22-link-checker), 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:pass@proxy.example.com:8000`

 

```node
// npm install playwright@1.60.0
// 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();
}
```

```python
# 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()
```

```java
// 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();
      }
    }
  }
}
```

```csharp
// 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
// go get github.com/playwright-community/playwright-go@v0.6000.0
// 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:** `fetch` gives 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.evaluate` call 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.

---
Source: https://cdpfleet.com/docs/cases/link-checker · Markdown: https://cdpfleet.com/docs/cases/link-checker.md · All docs for AI tools: https://cdpfleet.com/llms.txt
