BlogEngineering

Engineering

How we crawl a million URLs in bounded memory

Most desktop crawlers keep the whole crawl in RAM and slow to a crawl (sorry) once a site gets big. Here's the streaming pipeline behind RacingRoach, and the actual measured numbers behind "bounded memory" — not a marketing footnote.

The memory gauge during a synthetic 1M-URL crawl — rising, then flattening, never climbing unbounded.

Here's the failure mode we built RacingRoach to avoid: you point a desktop crawler at a site with a few hundred thousand pages, walk away, and come back to a beachballing app and a machine that's swapped itself into the ground. It's not a bug in those tools — it's an architecture. Most crawlers hold the entire crawl, every page record, every discovered link, in memory for the life of the run. That works fine at ten thousand URLs. It stops working at a million.

We took the opposite approach: never hold the full crawl in RAM. Not "try to keep it small" — an actual invariant, enforced through every stage of the pipeline, that we test against directly. The result is a crawl that stays in the hundreds of megabytes at a million URLs instead of the tens of gigabytes a RAM-resident crawler needs.

The pipeline

Every URL moves through four stages, each one bounded so it can only hold so much work before it applies backpressure to the stage behind it:

the pipeline
Frontierbounded queue of URLs to visit, deduped via an in-RAM xxh3-128 hash set
Fetchtower stack — concurrency limit → rate limit → retry → timeout
Parselol_html streaming parser — never builds a full DOM
Storeredb, one transaction per page
Discovered links loop back into the frontier; nothing waits on an unbounded buffer.

The parser is the detail people usually get wrong first. A full DOM parse (the kind a browser or a library like jsdom builds) allocates a tree for the entire page and keeps it around for anything downstream that wants to query it. We use lol_html, a streaming HTML rewriter: it fires callbacks as it scans through the byte stream and never materialises a tree. We pull out titles, headings, links, and structured data as the bytes go by, then let the page content go — only the extracted fields and a compact body-text record get stored.

Storage is the other half. Every page's record, its URL-index entry, the frontier cursor advancing past it, and any newly discovered links all commit in one redb transaction. That's not just for memory — it's what makes a crawl crash-safe. Kill the process mid-crawl, or close your laptop lid, and the on-disk file is never left in a half-written state. Reopen it and the crawl picks up exactly where it stopped.

The invariant isn't a setting you switch on for big sites. It's how the pipeline is built — which is also why it's the same code path whether you're crawling 500 pages or 5 million.

What "bounded" actually measures like

We don't like citing round marketing numbers for something this checkable, so here's a synthetic HTTP crawl through the real pipeline (concurrency 16, ~1.5 KB pages, single dev machine) at three sizes, in the default on-disk storage mode:

23 MB10k pages
101 MB100k pages
746 MB1M pages
100k pages, disk store4.3k pages/sec
1M pages, disk store3.3k pages/sec
100k pages, in-RAM store278 MB · 8.5k pages/sec
1M pages, in-RAM store1.76 GB · 7.7k pages/sec
Peak RSS, default disk-backed store vs. the optional in-RAM store. Same pipeline, different trade-off.

Two things worth calling out, because they're the parts a reader should be skeptical of by default:

There's also a second storage mode: an optional in-RAM store for people who'd rather trade memory for speed and skip the disk file entirely — roughly 2.4× the RAM for about 2× the throughput, no .redb file left behind. The disk-backed store is the default because it's the one that scales past what your machine's RAM can hold; the in-RAM mode is there for smaller, throwaway crawls where you want the extra speed and don't care about resuming later.

Why this is the whole point

None of this is exotic engineering — bounded queues, streaming parsers, and transactional writes are decades-old techniques. What's unusual is applying all three to a desktop SEO crawler instead of treating "just buy more RAM" as an acceptable answer. The payoff shows up exactly when you need it least to fail: the 300k-page client site, the migration audit at 2am, the crawl you kicked off before a meeting and want to still be running when you get back.

If you want to see the gauge for yourself, point the free build at whatever site currently makes your crawler struggle and watch what happens to memory as the count climbs.

RR

RacingRoach team

Technical SEO & engineering

Notes on the parts of technical SEO that don't fit in a tweet — crawl budget, rendering, and the unglamorous art of fixing redirects at scale.