Design a Web Crawler
Frontier design, per-host politeness under RFC 9309, URL and content deduplication, and how not to get banned.
frontier.next() -> { url, host } | null; frontier.complete(item, { status })A crawler is a queue with manners. Fetching pages is the easy part. The design problems are deciding what to fetch next, not hurting the sites you fetch from, and recognising that you have already seen this page under a different URL.
The frontier
The Mercator design, still the standard shape, splits the frontier in two. Front queues hold URLs by priority. Back queues hold one queue per host. A worker takes the host whose next-allowed time has passed, fetches one URL from it, and returns the host with a new next-allowed time. Priority and politeness then stay independent, and a site with a million links cannot monopolise your workers.
Politeness
robots.txt became an IETF standard in 2022 as RFC 9309, and its status rules are not symmetric. A 4xx means the file is unavailable and you may crawl anything. A 5xx or a transport error means it is unreachable, and you must assume complete disallow. Cached copies should not be used for more than 24 hours unless the file is unreachable, and parsers must accept at least 500 KiB. Crawl-delay was deliberately left out of the standard because implementations never agreed, so honour it when present and derive your own rate anyway: one in-flight request per host, widening on 429 and 503.
Duplicates
Canonicalise before the seen-check — lowercase the host, drop the fragment, strip tracking parameters — or you will crawl one page a dozen times. Identical content needs a similarity hash rather than equality: Google's crawler used a 64-bit simhash and treated documents within a Hamming distance of 3 as near-duplicates.
Example
// Politeness-aware frontier in the Mercator shape: priority queues in front,
// one queue per host behind, and a heap ordered by when each host may next be
// touched. Front queues decide what matters; back queues decide who may go now.
import { setTimeout as sleep } from 'node:timers/promises';
const DEFAULT_DELAY_MS = 1000; // at most one request per host per second
function heapPush(h, item) {
h.push(item);
for (let i = h.length - 1; i > 0;) {
const p = (i - 1) >> 1; // `>> 1` is shorthand for Math.floor(x / 2)
if (h[p].notBefore <= h[i].notBefore) break;
[h[p], h[i]] = [h[i], h[p]];
// Destructuring swap. Longhand:
// const tmp = h[p]; h[p] = h[i]; h[i] = tmp;
i = p;
}
}
function heapPop(h) {
const top = h[0];
const last = h.pop();
if (h.length > 0) {
h[0] = last;
for (let i = 0;;) {
const l = 2 * i + 1, r = l + 1;
let m = i;
if (l < h.length && h[l].notBefore < h[m].notBefore) m = l;
if (r < h.length && h[r].notBefore < h[m].notBefore) m = r;
if (m === i) break;
[h[m], h[i]] = [h[i], h[m]];
i = m;
}
}
return top;
}
export class Frontier {
constructor() {
this.front = [[], [], []]; // priority 0 (highest) .. 2
this.back = new Map(); // host -> url[]
this.ready = []; // min-heap of { host, notBefore }
this.hostDelay = new Map(); // host -> ms, from robots.txt or measured
}
add(url, priority = 1) {
this.front[priority].push(url);
}
// Move one URL from the highest non-empty front queue into its host queue.
#fill() {
for (const q of this.front) {
if (q.length === 0) continue;
const url = q.shift();
const host = new URL(url).host;
let queue = this.back.get(host);
if (queue === undefined) {
queue = [];
this.back.set(host, queue);
heapPush(this.ready, { host, notBefore: 0 }); // eligible immediately
}
queue.push(url);
return true;
}
return false;
}
async next() {
for (;;) {
if (this.ready.length === 0 && !this.#fill()) return null; // drained
if (this.ready.length === 0) continue;
const slot = this.ready[0];
const queue = this.back.get(slot.host);
if (!queue || queue.length === 0) {
heapPop(this.ready); // host has nothing left; forget it
this.back.delete(slot.host);
this.#fill();
continue;
}
const wait = slot.notBefore - Date.now();
if (wait > 0) { await sleep(Math.min(wait, 100)); continue; }
heapPop(this.ready);
return { url: queue.shift(), host: slot.host };
}
}
// Called after every fetch, success or failure. The host becomes eligible
// again only after its delay, so politeness is enforced here and nowhere else.
complete({ host }, { status } = {}) {
const base = this.hostDelay.get(host) ?? DEFAULT_DELAY_MS;
// longhand: this.hostDelay.has(host) ? this.hostDelay.get(host) : DEFAULT_DELAY_MS
// `??` and `||` differ here: a configured delay of 0 survives `??`,
// whereas `||` would silently replace it with the default.
const delay = status === 429 || status === 503 ? base * 4 : base;
heapPush(this.ready, { host, notBefore: Date.now() + delay });
}
}When to use it
- Crawl a few thousand partner sites nightly for price changes without being blocked by any of them.
- Build a search index over your own documentation estate, spread across several domains and generators.
- Audit a large site for broken links and duplicate content before a migration.
- Diagnose why a crawler is re-fetching the same article hundreds of times under different URLs.
More examples
robots.txt with the RFC 9309 status rules
This is the function that decides whether your crawler is a guest or an attacker. Note that treating an error as permission is not a small bug: a site under load returns 5xx, your crawler reads that as a green light, adds load, and gets your address blocked at the edge. Keep the cache, and keep the stale-on-unreachable fallback, so a one-minute outage does not stall a whole host for a day.
// RFC 9309 status handling. The asymmetry is the part people get wrong:
// 4xx -> "unavailable": the crawler MAY access any resource.
// 5xx or transport failure -> "unreachable": MUST assume complete disallow.
const CACHE_MS = 24 * 60 * 60 * 1000; // section 2.4: not more than 24 hours
const PARSE_LIMIT = 500 * 1024; // section 2.5: at least 500 KiB
const cache = new Map(); // origin -> { policy, fetchedAt }
export async function robotsFor(origin, { userAgent }) {
const hit = cache.get(origin);
if (hit && Date.now() - hit.fetchedAt < CACHE_MS) return hit.policy;
let policy;
try {
const res = await fetch(`${origin}/robots.txt`, {
headers: { 'user-agent': userAgent },
redirect: 'follow',
signal: AbortSignal.timeout(10_000),
});
if (res.status >= 400 && res.status < 500) {
policy = { allowAll: true }; // unavailable: crawl freely
} else if (!res.ok) {
policy = { disallowAll: true }; // unreachable: stop
} else {
// Truncate before parsing. A multi-megabyte robots.txt is either a
// mistake or an attempt to exhaust your parser.
const body = (await res.text()).slice(0, PARSE_LIMIT);
policy = parseRobots(body, userAgent);
}
} catch {
// A timeout, DNS failure or TLS error is "unreachable", not "allowed". The
// RFC does let you keep using a stale cached copy while it is unreachable,
// which is kinder than stopping dead on one bad minute.
policy = hit?.policy ?? { disallowAll: true };
}
cache.set(origin, { policy, fetchedAt: Date.now() });
return policy;
}
// Two details worth getting right in parseRobots: group lines by user-agent and
// use the most specific matching group only (not the union with *), and pick the
// longest matching Allow or Disallow rule, where Allow wins a tie.Near-duplicate detection with simhash
Strip boilerplate before hashing and this is what stops you re-indexing the same article under a print URL, a mobile URL and three tracking parameters. Reach for it whenever you store crawled content: exact hashing catches almost nothing on the real web, because a timestamp or a rotating advert in the footer changes every byte-level hash.
// 64-bit simhash - the near-duplicate test described in Manku, Jain and Das
// Sarma, "Detecting near-duplicates for web crawling" (WWW 2007). Documents
// whose hashes differ in at most 3 of the 64 bits count as near-duplicates.
// Unlike MD5, a one-word change moves a few bits rather than all of them.
import { createHash } from 'node:crypto';
function featureHash(token) {
return createHash('md5').update(token).digest().readBigUInt64BE(0);
}
// Shingles, not single words: word frequency alone cannot tell a page from a
// reshuffling of the same page.
function* shingles(text, n = 4) {
const words = text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
for (let i = 0; i + n <= words.length; i++) yield words.slice(i, i + n).join(' ');
}
export function simhash(text) {
const weights = new Array(64).fill(0);
for (const token of shingles(text)) {
const h = featureHash(token);
for (let bit = 0; bit < 64; bit++) {
// `1n << BigInt(bit)` is shorthand for 2n ** BigInt(bit).
weights[bit] += (h & (1n << BigInt(bit))) === 0n ? -1 : 1;
}
}
let out = 0n;
for (let bit = 0; bit < 64; bit++) if (weights[bit] > 0) out |= 1n << BigInt(bit);
return out;
}
export function nearDuplicate(a, b, k = 3) {
let x = a ^ b;
let bits = 0;
while (x !== 0n) { x &= x - 1n; bits++; } // clears the lowest set bit each pass
return bits <= k;
}
// Scanning every stored hash is O(n) and hopeless at scale. The paper's trick:
// split the 64 bits into blocks, build one sorted table per permutation of the
// blocks, and look up by block prefix - any pair within 3 bits must share at
// least one whole block.
export function blocks(h) {
return [0n, 16n, 32n, 48n].map((shift) => Number((h >> shift) & 0xffffn));
}URL canonicalisation and the seen-set
The two-layer seen-set is how you keep a Bloom filter's speed without accepting its errors: the filter answers "definitely new" for free, and only its maybes cost a query. Reach for canonicalisation first in any crawl that seems far larger than the site it is crawling - that is almost always session ids, sort parameters or a calendar generating infinite distinct URLs for the same page.
// Canonicalise before the seen-check. The WHATWG URL parser already lowercases
// the host, resolves dot segments and drops default ports, so the work left is
// the part that is policy rather than parsing.
const TRACKING = /^(utm_|gclid$|fbclid$|mc_eid$|ref$)/;
export function canonicalise(raw, base) {
const u = new URL(raw, base); // throws on a malformed href - catch and drop
u.hash = ''; // fragments never reach the server
u.hostname = u.hostname.replace(/\.$/, ''); // trailing root dot
if (u.pathname === '') u.pathname = '/';
// Drop tracking parameters and sort the rest: query order is not meaning on
// the overwhelming majority of sites. This is a heuristic, and on the rare
// site where order matters it will merge two distinct pages - which is why
// the rule belongs in one reviewable function rather than inline.
const keep = [...u.searchParams].filter(([k]) => !TRACKING.test(k));
keep.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
u.search = new URLSearchParams(keep).toString();
return u.toString();
}
// The seen-set is the other half. Exact membership in a database is correct and
// costs a round trip per URL. A Bloom filter is in-memory and O(1), and its
// false positives mean silently never fetching a page you have never seen.
// That is a reasonable trade at ten billion URLs and a bad one at ten thousand:
// on a site audit, the pages you drop are the finding.
export class SeenSet {
constructor(bloom, db) { this.bloom = bloom; this.db = db; }
async addIfNew(url) {
const key = canonicalise(url);
if (!this.bloom.mightContain(key)) { // definitely new - no query needed
this.bloom.add(key);
await this.db.insertUrl(key);
return true;
}
// Possible false positive: confirm against the exact set. This keeps the
// filter as a cheap negative cache instead of the source of truth.
const existed = await this.db.insertUrlIfAbsent(key);
return existed === false;
}
}
Discussion