Repository navigation
feat(pow): multi-threaded PoW captcha solving via web workers - #3021
Conversation
Move PoW solving off the main thread into a pool of web workers that divide and conquer the nonce space: with N workers, worker i tries nonces i, i+N, i+2N, ... so every nonce is covered exactly once with no overlap. The first worker to find a hash meeting the difficulty wins and the rest are terminated. The worker count is a build-time env var (PROSOPO_POW_WORKERS, default 8). Falls back to the existing synchronous solver when web workers are unavailable (e.g. SSR) or a single worker is configured. - util: add solvePoWOffset(data, difficulty, start, step); solvePoW now delegates to it - procaptcha-pow: add powWorker (worker entry) and multiThreadedSolver (orchestrator); Manager awaits solvePoWParallel - config: expose PROSOPO_POW_WORKERS to the browser bundle
…er failure Address review feedback: - solvePoWOffset now validates start/step/difficulty are sane integers instead of hanging or producing non-integer nonces. - solvePoWParallel falls back to the synchronous solver when a worker errors or worker construction throws, instead of failing the captcha.
…-solving # Conflicts: # packages/procaptcha-pow/src/services/Manager.ts # packages/util/src/solverService.ts
… tests - Bundle the PoW web worker via Vite's "?worker&inline" (matching the cryptoWorker pattern) so it is inlined into the consuming bundle. The previous "new Worker(new URL(...))" referenced a module that the library build tree-shook away (it is only referenced at runtime), which broke the procaptcha-bundle build with an unresolved powWorker.js entry. Adds the matching "?worker&inline" type declaration. - Reuse a module-level TextEncoder and share the per-nonce hash/target check between solvePoW and solvePoWOffset instead of duplicating the loop. - Add unit tests for solvePoWOffset (round-trip, disjoint slices, parity with solvePoW, divide-and-conquer, and input validation). - Fix biome formatting.
Clamp the configured PROSOPO_POW_WORKERS to navigator.hardwareConcurrency (when available) and a hard ceiling of 16, so a misconfigured build can't spawn an unbounded number of workers. Also clarify the changeset wording (solvePoWParallel is internal, not a public export).
# Conflicts: # packages/util/src/solverService.ts
Benchmark: this PR vs
|
| Difficulty | Solves | main | 1 worker | 2 workers | 4 workers | 8 workers (default) | 10 workers |
|---|---|---|---|---|---|---|---|
| 3 | 25 | 14 ms | 18 ms (0.8×) | 14 ms (1.0×) | 16 ms (0.9×) | 17 ms (0.8×) | 21 ms (0.7×) |
| 3.5 | 25 | 75 ms | 53 ms (1.4×) | 33 ms (2.3×) | 25 ms (3.0×) | 31 ms (2.4×) | 39 ms (1.9×) |
| 4 | 20 | 559 ms | 338 ms (1.7×) | 183 ms (3.1×) | 134 ms (4.2×) | 139 ms (4.0×) | 217 ms (2.6×) |
| 4.5 | 12 | 2.30 s | 1.11 s (2.1×) | 665 ms (3.5×) | 482 ms (4.8×) | 497 ms (4.6×) | 628 ms (3.7×) |
p90 solve time / p90 longest frame gap
| Difficulty | main | 1 worker | 2 workers | 4 workers | 8 workers | 10 workers |
|---|---|---|---|---|---|---|
| 3 | 32 ms / 9 ms | 31 / 9 | 22 / 9 | 24 / 9 | 27 / 15 | 39 / 16 |
| 3.5 | 199 ms / 11 ms | 101 / 9 | 60 / 9 | 43 / 9 | 62 / 14 | 66 / 25 |
| 4 | 1.53 s / 12 ms | 649 / 11 | 387 / 17 | 301 / 16 | 300 / 75 | 495 / 67 |
| 4.5 | 3.47 s / 26 ms | 1.37 s / 22 | 845 / 16 | 658 / 21 | 718 / 30 | 838 / 114 |
Takeaways
- At the default difficulty (4), the PR's default of 8 workers is ~4× faster than
main: mean 559 → 139 ms, p90 1.53 s → 300 ms. - Up to 2× of that comes from leaving the main thread, not from parallelism.
main'ssolvePoWdoesawait setTimeout(0)every 1,000 hashes, and browsers clamp nested timers to ≥4 ms, so it spends most of its time asleep. One worker running the PR's loop, with no yields, is already 1.4–2.1× faster. - 4 workers was the sweet spot here. 8 was no faster, and 10 (every core) was clearly slower. 8 and 10 workers also caused frame drops on the page (p90 frame gap 67–114 ms), because the main thread has to compete for a core.
- At difficulty 3 there is no gain. Worker start-up (blob URL, spawn, parse) costs about as much as the solve itself.
Suggestions
- Cap the pool at
hardwareConcurrency - 1so the page always keeps a core. Consider lowering the default from 8 to 4. On this machine that loses nothing, and on a 4-core phone the current default takes every core. - The worker loop runs
textEncoder.encode(nonce + data)for every nonce, so it misses the buffer-reuse trick that perf(util): optimise solvePoW hot loop #2717 added tosolvePoW. Porting it should raise per-worker throughput further.
Caveats: one machine, one browser, small samples. The 4.5 row includes one unlucky challenge (11 s on main), so its p90 is more trustworthy than its max.
…buffer in solvePoWOffset Benchmarks showed 8+ workers on a 10-core machine were no faster than 4 and dropped frames, so the pool now defaults to 4 and is capped at hardwareConcurrency - 1. solvePoWOffset now shares solvePoW's buffer-rewriting message writer instead of encoding nonce + data per attempt, which makes each worker ~40% faster.
Re-benchmark after
|
| Difficulty | Solves | main | 1 worker | 2 workers | 4 workers (new default) | 8 workers | 10 → capped to 9 |
|---|---|---|---|---|---|---|---|
| 3 | 25 | 14 ms | 15 ms (0.9×) | 11 ms (1.3×) | 10 ms (1.4×) | 13 ms (1.1×) | 13 ms (1.0×) |
| 3.5 | 25 | 73 ms | 37 ms (2.0×) | 23 ms (3.1×) | 18 ms (4.1×) | 19 ms (3.9×) | 22 ms (3.4×) |
| 4 | 20 | 536 ms | 192 ms (2.8×) | 110 ms (4.9×) | 78 ms (6.9×) | 72 ms (7.4×) | 81 ms (6.6×) |
| 4.5 | 12 | 2.25 s | 816 ms (2.8×) | 489 ms (4.6×) | 285 ms (7.9×) | 250 ms (9.0×) | 316 ms (7.1×) |
p90 solve time / p90 longest frame gap
| Difficulty | main | 4 workers (new default) | 8 workers | 9 workers |
|---|---|---|---|---|
| 4 | 1.47 s / 12 ms | 167 ms / 13 ms | 167 ms / 19 ms | 146 ms / 31 ms |
| 4.5 | 3.38 s / 13 ms | 417 ms / 15 ms | 345 ms / 42 ms | 518 ms / 103 ms |
Takeaways
- At the default difficulty (4), the PR now solves in 78 ms, against 536 ms on
main(~7×). The last run took 134 ms with 4 workers and 139 ms with 8, so the buffer change alone almost halves solve time. - Difficulty 3 no longer loses to
main. Worker start-up is still roughly the size of the solve at that level, but it is now a small win rather than a loss. - 4 workers stays smooth (p90 frame gap ≤15 ms) and gets within ~10% of 8. 8 and 9 workers still drop frames at difficulty 4.5. That's why 4 is the default rather than "cores - 1", even with the cap.
Caveats as before: one machine, small samples, and one unlucky challenge in the 4.5 set that skews the max column.
Re-opens the work from #2710, which was merged and then backed out of
mainby #3020 so it could go back through review. Same branch, same commits.