Skip to content

feat(pow): multi-threaded PoW captcha solving via web workers - #3021

Merged
HughParry merged 12 commits into
mainfrom
feat/pow-multi-worker-solving
Oct 26, 2026
Merged

HughParry merged 12 commits into
mainfrom
feat/pow-multi-worker-solving

Conversation

@goastler

Copy link
Copy Markdown
Member

Re-opens the work from #2710, which was merged and then backed out of main by #3020 so it could go back through review. Same branch, same commits.

goastler added 8 commits June 17, 2026 00:56
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
@HughParry

Copy link
Copy Markdown
Contributor

Benchmark: this PR vs main

Merged main into this branch (057752d8e, no conflicts). tsc, biome, and the util (213) and procaptcha-pow (136) unit tests all pass.

Setup: Apple M5 (10 cores, navigator.hardwareConcurrency = 10), Chrome 155 (headed, driven by Playwright). Each config solves the same set of random 120-character challenges. Configs are interleaved per challenge, so thermal drift affects every config equally. Timings cover the whole solvePoW / solvePoWParallel call, worker start-up included.

  • main: solvePoW on the main thread. This is what main's Manager calls, and the PR leaves that function unchanged.
  • 1 worker: the PR's worker code with start=0, step=1. Not a real config, because the PR falls back to solvePoW at 1 worker. It is here to separate "off the main thread" from "parallel".
  • PR, N workers: solvePoWParallel, with PROSOPO_POW_WORKERS=N baked in at build time.
  • Frame gap: the longest gap between requestAnimationFrame ticks during a solve. ~16 ms is a normal frame; higher means jank.

Only difficulties that take ≲2 s on main were run (default is 4).

Mean solve time (speed-up vs main)

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's solvePoW does await 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 - 1 so 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 to solvePoW. 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.
@HughParry

Copy link
Copy Markdown
Contributor

Re-benchmark after 70351a316

Changes since the last benchmark:

  • Default pool size is now 4 workers, down from 8.
  • The pool is capped at navigator.hardwareConcurrency - 1, so the page always keeps a core free.
  • solvePoWOffset reuses one message buffer. It now shares solvePoW's buffer-rewriting writer (from perf(util): optimise solvePoW hot loop #2717) instead of running TextEncoder.encode(nonce + data) for every nonce. It also rejects a negative start now, and a new test covers digit rollovers with step > 1.
  • Per-thread speed: in Node, the worker loop went from 707 to 1,017 kH/s (+44%). solvePoW's own speed is unchanged.

Same setup and challenges as the previous comment: M5, 10 cores, Chrome 155. The main column uses main's unchanged solvePoW.

Mean solve time (speed-up vs main)

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.

@HughParry
HughParry marked this pull request as ready for review October 26, 2026 15:20
@HughParry
HughParry merged commit f13677e into main Oct 26, 2026
20 of 22 checks passed
@HughParry
HughParry deleted the feat/pow-multi-worker-solving branch October 26, 2026 16:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants