110,162 blocked domains. 550,810 bytes. Zero collisions.
Those are the numbers my own Node script printed on Monday night, after it hashed a popular ad blocklist down to five bytes per domain. Then I diffed the file against the one a $2 chip downloads every week. Same bytes.
A Pi-hole on a $2 chip
The chip runs esp32-c3-adblock, a DNS sinkhole for the ESP32-C3. Ad and tracker domains get 0.0.0.0 back; everything else is forwarded upstream. It hit #12 on GitHub trending this Monday with 196 stars in a day, and Tom's Hardware and XDA have both written it up.
The C3 has no PSRAM. Per the maintainer in issue #3, it has about 400 KB of RAM in total and roughly 170 KB free at runtime. The default blocklist is over 2 MB of text. So the project never holds the domains at all.
Each domain becomes a 64-bit FNV-1a hash, cut down to its low 40 bits. The hashes are sorted and written to flash as a flat file of 5-byte rows. A query hashes the name and then each parent (ads.tracker.example.com, tracker.example.com, example.com) and searches for each, so one listed domain covers its subdomains.
I did not flash the firmware. I read it and the Python list builder, then rebuilt the core trick in Node to check the numbers myself.
I rebuilt it in Node
The script ports the builder's parser (hosts lines, bare domains, ||domain^ rules), runs on the same two public lists the project uses by default (StevenBlack's hosts file and Hagezi Light), and does this:
const FNV_OFFSET = 0xcbf29ce484222325n;
const FNV_PRIME = 0x100000001b3n;
function fnv64(str) {
let h = FNV_OFFSET;
for (const c of Buffer.from(str, 'utf8')) {
h = BigInt.asUintN(64, (h ^ BigInt(c)) * FNV_PRIME);
}
return h;
}
const MASK40 = (1n << 40n) - 1n;
const hashes = [...domains].map((d) => Number(fnv64(d) & MASK40));
const table = Float64Array.from(new Set(hashes)).sort();
// write each as 5 little-endian bytes: that file IS the blocklist
A 40-bit value fits safely in a JavaScript number, so only the hashing needs BigInt. The run, trimmed:
domains : 110,162
40-bit entries : 110,162 collisions 0 (birthday expects 0.006)
blob : 550,810 bytes = 0.53 MiB; 5.00 B/domain
same as strings : 2,201,104 bytes of text (avg 19.0 chars)
hash+sort time : 398 ms
flat search : hits avg 15.8 max 17; misses avg 16.8 max 17
vs release blob : shared 110,162, identical bytes true
My file matched that day's weekly release from the project byte for byte. That is the title: 2.2 MB of domain text (before any pointers or a hash table) becomes 550 KB of hashes, a quarter of the size.
The README's numbers hold up
I checked each claim against my run and the birthday bound, which says n random b-bit values hold about n² / 2^(b+1) colliding pairs.
- 141k domains in 0.67 MB. 141,000 × 5 bytes is 705,000 bytes, which is 0.67 MiB. Checks out.
- 0 collisions at 141k. The bound expects 0.009 pairs. My 110k list had 0.
- 32 bits would cost about 7 collisions at 250k. The bound gives 7.3.
- 1 collision at 537k. The bound expects 0.13, so one is a 1-in-8 event: plausible, slightly unlucky. I could not rebuild that list (Hagezi retired its source folder); today's aggressive pair builds to 357,201 domains, with 0 collisions.
- About 18 flash reads per lookup. A flat binary search took at most 17 reads on 110k and 19 on 357k.
That last one is already out of date, in a good way. PR #2, merged October 4, keeps a 4,096-entry sample of the table in RAM (20 KB). A lookup does 12 steps in RAM, then one flash read of a bucket, about 27 rows by my count. The maintainer measured roughly 115 queries per second going to 165, and fully cached lookups also stop near 165. The search is no longer the bottleneck; per-packet UDP and WiFi work is.
The collision that actually matters
Here is the part I would argue with. The builder counts collisions inside the list, and the README calls one of those an over-block. But if two blocked domains share a hash, both were going to be blocked anyway. That collision costs nothing. It even saves five bytes.
The collision that costs you is an unlisted name landing on a listed hash. That is a false block: some site you never meant to block just stops resolving. Its rate per lookup is just n / 2^b, the list size over the hash space.
So I measured it. I took 253,678 real domains from Hagezi's ultimate list that are not in the default list, used them as queries, and swept the hash width:
| Bits | Bytes | Pairs inside list (expected) | False blocks (expected) | Rate per lookup |
|---|---|---|---|---|
| 24 | 3 | 360 (362) | 1,682 (1,660) | 6.5e-3 |
| 28 | 3.5 | 22 (23) | 108 (104) | 4.1e-4 |
| 32 | 4 | 0 (1.4) | 7 (6.5) | 2.6e-5 |
| 40 | 5 | 0 (0.01) | 0 (0.03) | 1.0e-7 |
The measurements sit right on the formula. At 40 bits, roughly one lookup in ten million lands on a stranger's hash. Say a home network touches 20,000 distinct names in a year. At about three lookups each, it should expect to wrongly block none of them. At 32 bits, that same year would cost you one or two.
Store hashes, not strings
The idea is bigger than ad blocking. If all you ever ask a list is "is this in it?", you do not need the list. You need fixed-size hashes and a known false-positive budget.
Who should care: anyone holding a large allowlist or blocklist in memory. A spam filter's domain list, a tracker list shipped inside a browser extension, leaked key fingerprints on an edge function, user IDs behind a feature flag. Each is usually a Set of strings. Each could be a sorted buffer of fixed-size rows that needs no parsing at load and a fraction of the memory.
Pick the width from the budget, not from habit. Choose an acceptable false-block rate per lookup, then solve n / 2^b for b. A million entries at one in ten million needs about 44 bits, so six bytes.
The HN thread mostly asked for a Bloom filter instead. A contributor tried one in PR #5: 2 KB of bits. With 141k entries setting two bits each, my arithmetic says all but about 3 in 100 million of those bits end up set, so it passes everything. At the same one-in-ten-million rate, a Bloom filter needs about 33.5 bits per entry and 23 probes. The sorted table spends 40 bits and one search. Bloom filters shine when 1% false positives is fine. Here they would save a sixth of the space and pay for it in random reads.
What you give up
- False blocks are permanent. A name that collides collides every time, until the list changes. Nothing can explain the block; there is no string to show.
- You cannot list or edit the entries. The maintainer notes in issue #8 that an allow rule cannot carve one subdomain out of a blocked parent. On the device, domains you add by hand live in a separate 200-entry list in RAM.
- Any change is a rebuild. Hashing and sorting took my laptop 0.4 seconds, so compute is not the cost. Shipping a new file is. The project rebuilds it weekly in CI. There is also no header, so any file whose size divides by five loads as a list. PR #21 is adding a magic number and a CRC for exactly that.
What I ran, and what I didn't
I ran my own Node script (155 lines, no dependencies) on the real public lists, plus a few lines of arithmetic for the Bloom numbers. I did not run the project's Python builder or flash the firmware. Throughput and RAM figures are the maintainer's.
What is the biggest list you hold as strings today that only ever answers "is it in there?", and what false-positive rate could you actually live with?
Top comments (0)