Both schemes ship to the browser as WebAssembly. The choice is not the delivery vehicle, it is which resource the puzzle charges.
A proof-of-work challenge is symmetric: the client and one attacker node do the same work. So difficulty is capped by your slowest legitimate device, never by what would stop a bot. The only lever that moves the attacker's cost without moving yours is the work function itself.
"WebAssembly versus Argon2id" is a category error
This comparison is usually framed as WASM against Argon2id, and that framing is confused before the argument starts. WebAssembly is how the solver reaches the browser. Argon2id is what the solver computes. Both of the schemes below ship as WebAssembly - the Argon2id one is a compiled WASM build of the reference algorithm, delivered by the widely used hash-wasm library.
The real axis is which resource the puzzle bills:
- Compute-bound. Search for a nonce whose SHA-256 digest has N leading zero bits. Each probe is one cheap hash. The cost is CPU cycles, and it scales with how many probes you demand.
- Memory-hard. The same leading-zero search, but each probe is one full Argon2id evaluation, which pins a large block of RAM and walks it several times. The cost is memory bandwidth and capacity, and it is already present in a single probe.
Everything that follows comes from that one difference. The parameters below are Argon2id at 32 MiB with three passes and one lane - round numbers picked for this benchmark, in the range a browser challenge can afford without stalling a low-end phone. Nothing here describes a deployment; RFC 9106 gives its own recommended parameter sets for the general case. The transferable result is the ratio between the two work functions, and that holds whatever memory size you settle on.
What one probe costs
The per-probe costs below were measured on 8 August 2026, on the machine that serves this site, using the same solver code a browser runs: a compiled WebAssembly SHA-256 searcher, and the hash-wasm Argon2id build. Running both under the same WebAssembly engine matters. The same Argon2id parameters compiled to native Go take 72.6 ms against 154.6 ms here, so a browser-versus-native comparison moves the number by 2.13x on its own, before any argument about work functions starts.
Two kinds of number appear below and they are not interchangeable. Per-probe costs and solve times are measured. Where a figure is derived - a probe count multiplied by the measured per-probe cost - the table says so and prints it in its own column. Until 11 August 2026 the whole compute tier was derived; those searches have since been run, and both columns are shown together because the difference between them is instructive.
| Work function | Cost per probe | Method |
|---|---|---|
| SHA-256 | 0.3997 µs | median of 10 runs x 2,000,000 probes |
| Argon2id, 32 MiB, t=3, p=1 | 154.6 ms | median of 15 (min 150.2, max 180.8) |
| Argon2id, 48 MiB, t=3, p=1 | 226.5 ms | median of 15 (min 219.6, max 235.9) |
At 32 MiB that is a ratio of about 387,000 to one. It is the entire argument, and everything else is a consequence of it. The ratio is the part worth carrying away, not the absolute figures: raising the memory parameter moves it almost linearly, with 1.5x the memory costing 1.465x the time in the two rows above.
Do not read the fourth digit. Four independent runs of the SHA-256 probe on this machine - two harnesses, written separately against the same compiled searcher - landed at 0.3771, 0.3778, 0.3862 and 0.3997 µs, a spread of about six per cent. The figures above are the run the tables are built from, quoted in full so the arithmetic reproduces, but the honest precision is two significant figures: the ratio is "about 390,000", and the derived times below are good to roughly the same. A gap of five orders of magnitude does not need the fourth digit, and claiming it would be the same mistake as calling a derived number a measured one.
A measurement I threw away. My first SHA-256 figure was 35 µs per probe, taken through the browser's crypto.subtle.digest API. That number is nearly all promise-dispatch overhead, not hashing: it times one asynchronous call per probe, while a real solver runs a tight loop of hundreds of thousands of probes inside a single WebAssembly call and never returns to JavaScript.
Published, it would have understated the compute scheme's efficiency by a factor of ninety and made the comparison look far more favourable to memory-hardness than it is. The 0.3997 µs figure comes from the compiled searcher doing two million probes per call.
Why the bit counts run backwards
A leading-zero search at N bits needs 2N probes on average. Because a compute probe is nearly free, a compute-bound scheme has to demand an enormous number of them before the total is worth anything. Because a memory-hard probe is already expensive, a handful is enough.
So the difficulty settings look inverted. A memory-hard tier runs at a handful of bits. The compute tier needs twenty or more to impose comparable friction. That is not weakness on the memory-hard side - it is where the cost sits. In Argon2id the cost is in each probe, not in the count, and every extra bit still doubles the expected number of probes.
Here is the compute side, both ways. The derived column is the mean probe count multiplied by the measured per-probe cost. Beside it are fifteen real searches per difficulty, timed with the same compiled searcher a challenged browser is given.
The two columns do not agree, and they are not supposed to. The number of probes to a solution is geometrically distributed - trials until a first success - so its mean sits about 1.44 times its median, and arithmetic built on the mean will always overstate what a typical user waits. The same distribution has no upper bound, which is why the third column matters more than either of the others: across these searches the slowest run lands between three and eight times the median.
| Difficulty | Mean probes | Expected time (derived) | Median (measured) | Slowest of 15 |
|---|---|---|---|---|
| 14 bits | 16,384 | 6.5 ms | 7.0 ms | 40.9 ms |
| 16 bits | 65,536 | 26.2 ms | 19.7 ms | 75.7 ms |
| 20 bits | 1,048,576 | 419 ms | 215 ms | 1.27 s |
| 26 bits | 67,108,864 | 26.8 s | 19.07 s | 64.44 s |
And here is the memory-hard side at 32 MiB, where each row is fifteen complete searches rather than arithmetic:
| Difficulty | Median (measured) | Fastest | Slowest | Median probes |
|---|---|---|---|---|
| 4 bits | 1,403 ms | 622 ms | 2,636 ms | 9 |
| 5 bits | 2,633 ms | 1,868 ms | 11,722 ms | 17 |
| 6 bits | 6,550 ms | 1,805 ms | 47,067 ms | 42 |
Median probe counts sit below 2N because the number of trials is geometrically distributed: at four bits the mean is sixteen but the median is nine. Quote the median for what a person experiences and the mean for cost arithmetic, and say which one you are using.
Compare like with like across those two tables. The compute table's derived column holds means and its measured column holds medians, while the memory-hard table is medians throughout. For a geometric trial count the mean is about 1.45 times the median, so reading a derived mean against a measured median flatters the compute-bound scheme by roughly that factor. The measured compute column exists precisely so that comparison can be made properly, and it bears the arithmetic out: at 20 bits the derived mean is 419 ms and the measured median 215 ms. Neither correction is close to enough to change a conclusion about five orders of magnitude, but a table that quietly mixes the two is the kind of thing a reader is right to catch.
The tail nobody quotes
At six bits the median solve was 6,550 ms. The slowest of the same fifteen runs took 47,067 ms. That is not measurement noise and it is not a slow machine - it is the shape of the distribution. A leading-zero search has no upper bound, and a geometric tail means a minority of perfectly legitimate users will wait an order of magnitude longer than the median. The same effect is visible one row up: at five bits the median was 2,633 ms and the worst run took 11,722 ms.
A second run on 9 August took sixty samples per difficulty instead of fifteen, which is enough to quote percentiles rather than a worst-of-fifteen. Same parameters, same browser engine:
| Difficulty | p50 | p90 | p99 | Slowest of 60 |
|---|---|---|---|---|
| 4 bits | 1,241 ms | 5,671 ms | 7,274 ms | 11,491 ms |
| 5 bits | 2,790 ms | 11,942 ms | 18,491 ms | 20,117 ms |
| 6 bits | 6,054 ms | 19,974 ms | 24,675 ms | 33,359 ms |
p90 is the number to publish, not the maximum. At four bits it is 5.7 seconds against a 1.2 second median - one user in ten waiting more than four times the "typical" figure. A maximum is one sample and moves wherever the tail happens to land; p90 is a promise about one visitor in ten, and it is the strongest argument in this piece for keeping difficulty low.
A solve median is a soft number, and it is worth knowing how soft. Repeated passes of the same four-bit measurement on the same machine - fifteen searches each, nothing changed but the starting nonce - returned medians of 1,122, 1,241, 1,403, 1,522 and 1,901 ms. That is a 69 per cent spread. In one pass the five-bit and six-bit medians landed at 4,650 and 4,838 ms, close enough that a whole difficulty bit disappeared into the sampling noise.
Per-probe costs taken in those same passes moved by under three per cent, and the concurrency ratios reproduced to the percentage point. Same host, same hour, same code: what separates the two behaviours is not the machine but the quantity being estimated. A per-probe cost is an average over millions of events. A solve median is fifteen draws from a heavy-tailed distribution. Quote the first as a number; treat the second as an order of magnitude and always print its tail.
Any honest description of a proof-of-work challenge has to carry that number next to the median. "Sub-second" is true for most people at four bits and false for some of them, every time, forever. Publishing only the median is how a challenge that occasionally strands users gets described as invisible.
It also sets a practical ceiling that has nothing to do with attackers: each additional bit doubles both the median and the tail. The tail reaches unacceptable before the median does.
Why 26 bits is not a harder challenge, only a crueller one
Both schemes are symmetric. Your client and one attacker node run the same search. So the maximum difficulty you can set is bounded by your slowest legitimate device, and never by what would actually deter a bot. That is the trap in the obvious lever.
Follow the compute curve up and watch it break. At 20 bits a real user waits a measured 215 ms, which is defensible. At 26 bits the same user is looking at a measured median of 19 seconds and a worst case, in fifteen searches, of 64 - which nobody sits through, they close the tab. Meanwhile a machine renting parallel hashing capacity has barely noticed, because SHA-256 is the single most parallelised operation in computing: thousands of lanes on a commodity graphics card, and dedicated silicon sold by the shelf. Push the difficulty high enough to inconvenience that, and you have already locked out every phone you serve.
Memory-hardness changes which curve moves, but not in the way this article first claimed. Each parallel Argon2id lane needs its own block of RAM, so an attacker's concurrency is capped by memory capacity divided by block size rather than by core count. That part is arithmetic and it holds. The next step does not. Measured on four cores at 32 MiB, a native attacker running four Argon2id searches keeps 92 per cent of the throughput four independent searches would give, against 95 per cent for SHA-256. There is no meaningful parallelism tax. One search moves roughly 1.3 GB/s, so four sit near 5 GB/s and never come close to the memory bus. Bandwidth saturation is real in principle - at a larger block, or on a far wider machine - and it is absent here.
What is actually being charged for is narrower, and it survives measurement. Compiling the work to native Go makes an Argon2id probe 2.18 times faster than the same probe in WebAssembly. For SHA-256 the same move is worth 3.18 times. Memory does not get faster because you compiled the code, so memory-hardness shrinks the head start native tooling has over the browser it is impersonating. Treat 3.18x as a floor rather than a figure: it compares a browser against one Go core, and the real ceiling for SHA-256 is a graphics card or dedicated silicon, orders of magnitude further out and beyond what this machine can measure.
There is one more reason this matters, and it is the one that is easy to miss. TLS and JA4 fingerprinting works well until an adversary drives a real browser engine, at which point the fingerprint is a real browser's fingerprint because it is one. A memory-hard cost does not care what the handshake looked like. It bills the hardware, which is where the asymmetry actually lives.
A flood arrived while this was being written
On the evening of 7 August 2026 this site took a distributed flood lasting 12 minutes and 20 seconds. It is a small event by any external standard, and that is part of why it is useful: it is fully captured rather than reconstructed.
| Measure | Value |
|---|---|
| Challenges issued | 67,751 |
| Issued as memory-hard | 99.9% |
| Issue rate, median over 42 intervals | 93.6/s (peak 313.6/s) |
| Distinct source addresses | 923 |
| Distinct networks (ASNs) | 525, across 97 countries |
| Networks contributing one address only | 387 |
| Tracked outcomes, all unsolved | 1,287 |
| Incorrect proofs submitted | 0 |
The shape is worth as much as the totals. 387 of 525 networks contributed exactly one address, spread across 97 countries and mixing cloud providers with residential telcos. There is no subnet to block and no coherent fingerprint to match - which is precisely the case where the memory-hard argument has to carry the load on its own.
What this does not show. It is tempting to write that the botnet could not solve the challenge. Nothing here supports that. The count of incorrect proofs was zero, which means not one of those 923 addresses ever fetched the solver, ran it, and submitted a wrong answer. They did not fail the work - as far as the evidence goes, none of them attempted it.
The defensible statement is narrower and, I think, more interesting: they were handed a cost their tooling would not even try to pay, and every one of them walked away instead. Whether that is because the cost was prohibitive or because the tooling simply has no solver in it, this data cannot say.
Two controls run against that same window. The verifier never saturated once - but the honest reading of that is narrow: no solution was ever submitted, so there was nothing to verify. It is a fact about this flood, not a demonstration that verification stays cheap under a fleet that does solve. And while the flood was live, both a desktop and a Samsung S21 Ultra on 5G were served the same memory-hard challenge deliberately and solved it on the first attempt, fast enough that neither felt like waiting.
A second flood, and the two records that nearly fooled me
Two days after this article first went up, a larger one arrived: about 1.18 million requests over twenty-four minutes, against the twelve minutes and 67,751 challenges described above. The gateway logged 2,041 challenge outcomes. Two of them were incorrect proofs.
That looked like the finding. The section above rests entirely on the count of incorrect proofs being zero - it is the only reason it can say those addresses never attempted the work rather than tried and failed. Two bad proofs meant somebody had fetched the solver, run it, and submitted a wrong answer. On a much larger event, against a differently shaped swarm, that would have been the more interesting result by some distance.
Both of them were my own phone.
Four records carry my mobile address, all inside ten seconds: a solve, two bad proofs, then a solve that stuck. They land about a minute before I noticed I could not load my own site from that phone. Every other record in the file - 2,036 of them, across 1,788 addresses - is a challenge that was issued and never answered at all. Swarm solves: zero. Swarm bad proofs: zero.
So the result reproduced, on an event three times the size and against a different attack shape: not one of them attempted the work. One observation was an anecdote. Two independent ones is worth something. And this time the sentence does not have to trail off, because the fleet described below already measured what attempting it would have cost.
The rest of what the second flood showed is about price rather than proof. While absorbing 1.18 million requests at a peak of roughly 9,500 per second, the machine serving this site ran at a load average of 1.31 across four cores, with nothing queued. DNS kept answering on all four transports throughout, in 25 to 43 milliseconds. That is the asymmetry argument stated in the only units that matter: a swarm spent whatever a swarm costs, and it bought a third of one core.
One measurement note, because it cost me an hour. The event was not one burst. It ran hard for four minutes, went quiet for four, then came back heavier than before. Watching the log stream live it looked continuous and relentless; in a timeline sampled every five seconds it is obviously two waves with a gap in the middle. The shape was in the data the whole time and invisible in the medium I was using to watch it - which is the same mistake as reading a mean beside a median, wearing different clothes.
So we built the botnet and measured it
The section above leaves a question open on purpose: the flood cannot separate "nobody solved it" from "nobody could". Only one of those is a claim about the challenge. The way to settle it is not to reason about it - it is to be the attacker.
A fleet of native solvers was pointed at this site on 9 August. They fetch real challenge pages, solve them in compiled Go rather than in a browser, and submit winning nonces back through the ordinary path, while the daemon's own CPU accounting is read on the other side. Both sides of the trade get billed in the same run, which is the only way the answer means anything.
A botnet can absolutely solve this challenge. Four native solvers on four cores earned about 0.6 tokens per second per core at four bits, with no failures. Anyone claiming a memory-hard proof of work stops a determined attacker is selling something. What it does is set a price.
| Difficulty | Solvers | Tokens/s | Attacker CPU-s each | Our CPU-ms each | Asymmetry |
|---|---|---|---|---|---|
| 4 bits | 1 | 0.71 | 1.34 | 80.6 | 16.7x |
| 4 bits | 2 | 1.28 | 1.46 | 84.5 | 17.2x |
| 4 bits | 4 | 2.45 | 1.33 | 89.4 | 14.9x |
| 4 bits | 8 | 2.40 | 1.43 | 95.3 | 15.0x |
| 5 bits | 1 | 0.44 | 2.22 | 84.0 | 26.4x |
| 5 bits | 2 | 0.71 | 2.67 | 85.0 | 31.5x |
| 5 bits | 4 | 1.63 | 2.14 | 87.7 | 24.4x |
| 5 bits | 8 | 1.45 | 2.44 | 93.3 | 26.2x |
Asymmetry is attacker CPU per earned token divided by our CPU per verification. The median is about 16x at four bits and about 26x at five, and quoting it to more precision than that would be dishonest - the eight cells run from 14.9 to 17.2 and from 24.4 to 31.5. It roughly doubles per bit, as it must: the attacker pays 2N probes and we pay exactly one. That single column is the entire design.
Notice the direction the spread runs. Our per-verification cost climbs from 81 ms to 95 ms as the fleet grows, so the asymmetry slowly erodes under load rather than holding constant. It is a favourable trade, not a free one, and a table with one row per difficulty would have hidden that.
This also settles, with evidence this time, something asserted earlier in this article on worse grounds. During the flood the verifier never saturated - but nothing had been submitted, so that proved nothing about verification under load. Under a fleet that actually solves, it saturated once across eight runs. The original reasoning was right; the evidence first offered for it was not.
The concurrency cost lands on the wrong person
The same run measured what happens when several searches run at once, as a throughput fraction of what that many independent searches would give. The difficulty here is deliberately one bit, unlike the tables earlier: contention does not depend on difficulty, and a cheap challenge produces thousands of samples per minute rather than dozens, which is what makes the baseline stable enough to divide by.
| Searches at once | Argon2id, native | SHA-256, native | Argon2id, browser | SHA-256, browser |
|---|---|---|---|---|
| 2 | 90% | 99% | 61% | 95% |
| 4 (= cores) | 92% | 95% | 53% | 90% |
| 8 | 47% | 47% | 22% | 44% |
Read the middle row, and read it as bad news. A native attacker parallelises Argon2id at 92 per cent, essentially as well as SHA-256 at 95 per cent. The scheme that falls apart under concurrency is the browser one, at 53 per cent. The eight-solver row is oversubscription on a four-core box and both collapse there for the ordinary reason.
This is the opposite of what this article originally argued, and the mechanism explains why. At 32 MiB with three passes a single search moves about 1.3 GB/s, so four of them sit near 5 GB/s - well short of what the memory bus will carry. Argon2id is memory-hard at these parameters, but it is not memory-bandwidth-bound on four cores, and only the second one taxes parallelism. Larger blocks or a much wider machine would reach that wall. This one does not.
Which leaves the cost sitting with the person it should not: someone with the site open in several tabs on a busy laptop pays the concurrency penalty that the botnet does not. That is a real argument for keeping difficulty low and issuing challenges rarely, and it is not an argument this article expected to be making.
What a bigger machine changes, and what it does not
Every figure on this page comes from one machine: four cores of an AMD EPYC 7542 under KVM, Zen 2 at 2.9 GHz. That is worth pressing on, because a proof of work is always priced in someone else's hardware. Some of these numbers travel to another machine and some do not.
More cores do not shorten a solve. At one lane the work is one thread, and the measurement says so rather than the parameter: across a probe loop the process used 1.00 cores for SHA-256 and 1.07 for Argon2id, the excess being the runtime's own garbage collection and compiler threads. The WebAssembly build is single-threaded with no shared memory, so even more lanes would be computed in sequence - a different digest, the same single core. A visitor waits on one core however many the machine has.
Cores buy throughput, and throughput is the attacker's currency. That is why the fleet result is quoted per core: about 0.6 tokens per second per core. A 128-core server is not a faster solver, it is 128 solvers, and if scaling held it would earn roughly 77 tokens a second. Whether it holds is the thing four cores cannot answer. Argon2id at 32 MiB with three passes touches about 96 MiB per probe, which at the native figure above is 1.32 GB/s per search, so 128 of them would want something like 169 GB/s. That is arithmetic, not a measurement, and it lands in the same range as what a wide server actually delivers - so the memory wall this article looked for at four cores and did not find may well bind at 128. Where it binds, we have not measured and cannot claim.
Core speed does shorten a solve, and that one lands on the user. Nothing running at 6 GHz is a Zen 2 core, so a faster machine brings clock and instructions per cycle together and a solve gets proportionally shorter. The direction that matters for design is the opposite one: the ceiling is set by the slowest phone in the audience, never by the fastest server in the attacker's rack.
Which is the reason this piece does not lean on its absolute figures. CPU, memory, virtualised or bare metal, a 7542 or something two generations newer: every millisecond here moves with all of them, and chasing that surface has no end. What survives a change of hardware is the ratio between the two work functions measured on one machine, the shape of the distribution with its median far below its tail, and who pays - one core per solve, N cores per attacker, and a concurrency penalty that falls on the browser. Read the milliseconds as this machine's. Read the ratios as the finding.
Where each one belongs
These are complements, not rivals. The useful split is breadth against depth.
| Situation | Work function | Reasoning |
|---|---|---|
| Ordinary traffic | none | zero friction for the overwhelming majority |
| Mild suspicion | compute, low bits | cheap, finely gradable, and invisible to everyone else |
| Scored suspicion | compute, scaled up | per-client difficulty; the server stays cheap under a spray |
| Active, resourced attack | memory-hard, low bits | the adversary has hardware, so charge the resource it cannot rent cheaply |
| Endpoints a human needs mid-attack | either, capped low | a diagnostic page has to stay solvable exactly when things are broken |
Two things belong on the memory-hard side specifically. The parameters must be fixed by the server and carried in a signed token, never accepted from the client - if the client can negotiate the memory size down, the memory-hardness is optional and therefore absent. And the payload is larger, so the solver wants to be cached before it is needed rather than fetched at the moment of an attack.
The failure mode to design around is not a bot getting through. It is walling off people who cannot participate at all: clients without JavaScript, assistive technology, and machine clients like resolvers, which legitimately send no user agent and cannot run a WASM challenge. A proof of work is one tier among several, and treating it as a gate on everything blocks real traffic in the name of stopping fake traffic. We have made a version of that mistake before, when a blocklist update started banning search-engine crawlers that were behaving exactly as they should.
Measure it yourself in two minutes
Nothing here needs our stack. The Argon2id side needs only the public hash-wasm package and Node 18 or newer, and it reproduces the per-probe cost and the full-solve distribution in the same run.
# argon-bench.mjs - one probe, then a full leading-zero search
import { argon2id } from 'hash-wasm';
const SALT = new TextEncoder().encode('example-salt-value');
const P = { salt: SALT, parallelism: 1, iterations: 3,
memorySize: 32768, hashLength: 32, outputType: 'binary' };
// leading zero bits of a byte array
const lz = a => { let n = 0;
for (const b of a) { if (b === 0) { n += 8; continue; }
for (let j = 7; j >= 0; j--) { if (((b >> j) & 1) === 0) n++; else return n; } }
return n; };
// cost of a single probe - this is the whole point
let t = process.hrtime.bigint();
await argon2id({ ...P, password: 'token:0' });
console.log('one probe', Number(process.hrtime.bigint() - t) / 1e6, 'ms');
// a full solve at 4 bits - expect ~16 probes on average, and a long tail
t = process.hrtime.bigint();
for (let nonce = 0; ; nonce++) {
const h = await argon2id({ ...P, password: `token:${nonce}` });
if (lz(h) >= 4) { console.log('solved', nonce + 1, 'probes',
Number(process.hrtime.bigint() - t) / 1e6, 'ms'); break; }
}
Run the solve loop fifteen times before believing any single figure. One run tells you almost nothing here - the distribution is geometric and individual runs land anywhere from one probe to fifty.
Verification is the other half, and it is the part that makes this affordable to operate: recompute the one hash for the submitted nonce, check the leading-zero count against the difficulty you issued, and confirm that the difficulty and parameters came from your own signed token rather than from the request. One evaluation, constant time in the size of the search.
The whole harness, and what it will tell you that this page does not
The listing above is the short version. All four rigs behind this article are published at github.com/Ozy-666/dns-pow-bench, together with the raw output of four complete passes taken on 11 August 2026: per-probe and full-solve cost for both work functions, a concurrency rig, and the compiled-searcher harness the SHA-256 figure came from. It needs Node 18 and one public package, and nothing from this stack.
Run it and the per-probe numbers will not match the ones above. That is not a defect in either, and it is worth understanding before it looks like one.
| Per probe | This article | Published scripts, same machine |
|---|---|---|
| SHA-256 | 0.3997 µs | 1.1003 µs |
| Argon2id, 32 MiB, t=3, p=1 | 154.6 ms | 160.8 ms |
| Ratio | ~387,000x | ~146,000x |
The Argon2id figures agree to within four per cent, which is what agreement looks like across two builds of the same library. The SHA-256 figures differ by 2.75x, and the reason is the whole subject of the discarded measurement described earlier: our figure comes from a compiled searcher that runs two million probes inside one WebAssembly call, while the published script crosses back into JavaScript for every probe. The ratio is therefore a property of two implementations, not of the two algorithms. An attacker uses the fast searcher, so 387,000x is the realistic figure for what the article argues; 146,000x is what a reader reproduces with a public library and a naive loop. Both are honest, and neither means anything without the implementation stated next to it.
How every number on this page was measured
Four separate exercises produced the figures here, and they do not all carry the same weight. This is the whole ledger, including the two rigs that are not published and why.
| Numbers | Kind | Samples | Code |
|---|---|---|---|
| Per-probe costs | measured | 10 x 2,000,000 probes; n=15 | wasm-bench, argon-bench |
| Compute-tier solve times, 14 to 26 bits | measured and derived, side by side | n=15 searches; 2N x per-probe | wasm-bench |
| Memory-hard solve times, 4 to 6 bits | measured | n=15, and n=60 in the second run | argon-bench |
| Concurrency, browser columns | measured | 8 repetitions per configuration | conc-bench |
| Concurrency, native columns | measured | same run, native Go | not published |
| Fleet table and asymmetry | measured | 8 runs against the live endpoint | not published |
| Flood counts and outcomes | counted | gateway counters and outcome log | not applicable |
The fleet driver stays unpublished, and the reason is not that it contains anything clever. It is a working solver bot pointed at a live challenge endpoint, and packaging that for download is a different act from describing what it measured. The method is in the section above and the accounting is arithmetic anyone can repeat: attacker CPU seconds per earned token, divided by our CPU milliseconds per verification. The native concurrency columns come from the same rig. Everything a reader needs to reproduce the argument - both work functions, both cost models, the concurrency behaviour - is in the published repository.
These measurements are published under CC BY 4.0. Reuse the numbers and the tables freely, including commercially, as long as you credit dnsdoh.art and link back to this page.
Corrections
Four claims on this page were published wrong and are recorded here rather than repaired quietly. Each is stated as it appeared, then as it stands.
Derived figures presented as measured
Published: "every number in this article was measured", with the compute tier introduced as measured in the browser's own solver. Corrected: the per-probe costs and the memory-hard solves were measured; the compute-tier times were arithmetic, a probe count multiplied by a per-probe cost. Worse, those derived rows were means printed beside a memory-hard table of medians, which for a geometric trial count are about 1.44 apart - in the direction that flattered the compute-bound scheme. Those searches have since been timed and both columns now appear side by side.
It was caught by a second rig, built independently against the same solver, whose author noticed all four compute rows reproduced from a single multiplication. That is what a second harness is for: it does not find the bug you were looking for.
The parallelism argument ran backwards
Published: "memory bandwidth saturates long before the cores do. Solving many in parallel makes each one slower." Corrected: a native attacker running four Argon2id searches on four cores keeps 92 per cent of the throughput four independent searches would give, against 95 per cent for SHA-256. There is no parallelism tax on the attacker at these parameters. The penalty that does exist runs the other way, falling on the browser at 53 per cent - on a person with several tabs open, not on the fleet.
The claim was never measured. It followed from the definition of a memory-hard function, it sounded right, and it supported the argument being made. An unmeasured claim that supports your argument is the one you are least likely to check.
Two attempts at measuring it were thrown away first, and both failed the same way. A shared work queue let one straggler hold the window open while finished workers idled, and that idle time was billed as memory contention. The giveaway was scaling efficiency recovering from 56 per cent at two workers to 80 per cent at four, which no contention effect can do; a later variant reported 137 per cent, which is impossible. Nothing failed. Both were caught by a number that could not be true.
A mistyped cell in the fleet table
Published: 95.3 ms as our cost per verification at five bits and eight solvers, carried down by hand from the row above and highlighted as that column's maximum. Corrected: 93.3 ms. No conclusion depended on it. It was caught by the table's own arithmetic - the asymmetry printed beside it, 26.2, divided into the attacker's 2.44 CPU-seconds gives 93, not 95 - which is the argument for reading a derived column back against the raw data with a script rather than with eyes.
A claim about the code, and an error bar that was too small
Published: "both benchmarks in this article are a few dozen lines against public libraries... there is nothing proprietary in either." Corrected: the page had four rigs by then, not two, and the SHA-256 harness drives a compiled WebAssembly searcher - a reader could not have run it whatever they installed. The fix was to publish the harness rather than soften the sentence, and everything except the fleet driver is now in the repository.
Published: a 12 per cent gap between two four-bit medians, described as the error bar. Corrected: repeated passes of that measurement span 1,122 to 1,901 ms, a spread of 69 per cent, and at one point put five bits and six bits close enough together to be indistinguishable. The tables stand, because they are what those runs produced, but the confidence attached to any single solve median was overstated. Per-probe costs, which carry the argument, held within 2.7 per cent.
The general lesson
When a symmetric defence is not working, the instinct is to turn it up. But a symmetric cost has a ceiling set by your weakest legitimate user, and turning it up walks you toward that ceiling faster than it walks the attacker toward theirs. Every doubling costs a phone more than it costs a rented server, because the phone was closer to giving up when you started.
The lever that works is not the magnitude, it is what you are charging for. Move the cost onto a resource where the attacker's advantage is smallest - here, memory instead of hash throughput - and the same wall-clock friction for a real person becomes a genuinely different proposition for a farm. That reframing generalises well past proof of work: whenever a control is symmetric, look for the axis on which it stops being symmetric, rather than for a bigger number on the axis you have.
And keep the discipline about what the evidence supports. This site handed out 67,751 challenges in twelve minutes and received zero wrong answers. The satisfying conclusion is that the wall held. The supportable one is that nobody tried to climb it - which is a good outcome, and a different claim, and the difference is the sort of thing that turns out to matter later. Here it mattered within two days. Building the fleet that nobody in the flood had bothered to build showed the wall is climbable at about 0.6 tokens per second per core, and that the question worth asking was never whether it holds, but what it charges.
The measurement scripts
Four rigs, a few dozen lines each, published at github.com/Ozy-666/dns-pow-bench with the raw output of four passes. The interesting part is the ratio they produce, not the code that produces it - but a page that says "we measured" and ships no tool is asking to be believed.
Set up encrypted DNSRelated reading: how an open resolver becomes an amplification weapon, when our blocklist banned Googlebot, keeping admin interfaces off the public internet entirely, and an anti-DDoS setting that refused every POST for five weeks.
Ozy-666 builds and operates dnsdoh.art, an encrypted DNS resolver serving DoH, DoH3, DoT and DoQ. The flood figures were counted from the challenge gateway's own counters and outcome log during a 12 minute 20 second event that began at 18:51 local time on 7 August 2026. The per-probe costs and full-solve distributions were benchmarked on the same machine on 8 August 2026, in the same WebAssembly solvers the browser runs; the compute-tier solve times are derived from those measurements rather than timed, as the correction above sets out. Three further passes on 11 August 2026, with the scripts now published, put error bars on all of it. The desktop and S21 Ultra solves described at the end were run deliberately, on the evening of the flood, against the live service.