Multithreading
How the default worker pool parallelizes analysis, and when to run single-threaded.
Chessalyzer is multithreaded out of the box: one analyzePGN call spreads the file across worker threads and all your CPU cores. You don't configure anything to get it — this page explains what actually happens, how to tune it, and the few cases where threads step aside.
What happens under the hood
main thread: read file → cut into chunks → dispatch ─┐
├─► worker 1: parse → replay → track
PGN file ─────┤ ├─► worker 2: parse → replay → track
└────────────────────────────────────────┘─► worker N: parse → replay → track
│
main thread: merge tracker states ◄── snapshots ◄──────────┘- The main thread streams the file and cuts it into chunks sized in bytes, always aligned to whole games.
- Each worker receives raw chunk bytes and does everything itself: parse, replay, and accumulate tracker state locally. Nothing per-game crosses the thread boundary — that's what keeps the pool fast.
- When the pool drains, each worker posts back one plain
{ index, state }snapshot per tracker. The main thread folds them into your tracker instances via each tracker'smerge, then runsonFinishonce. Per-gameonGameEndhooks run on the workers, not the main thread.
maxGames is enforced by the workers themselves, so filtered-down counting stays accurate without extra coordination.
State is partial until the end
Because merging happens at pool drain, your tracker instances hold incomplete state while an
analysis is running. Reading tracker.state mid-flight won't throw — it will just quietly show
you a fraction of the truth. Await analyzePGN first; only then is state final.
Tuning the pool
Defaults are sensible for most files (one worker per available core). When you do tune, workers takes an object (or a plain number for the thread count):
await analyzePGN('games.pgn', {
trackers: [tiles],
workers: { count: 8, chunk: { targetBytes: 4 * 1024 * 1024 } },
});| Option | What it controls | Default |
|---|---|---|
workers | Thread count (shorthand: workers: 8) | all available cores |
chunk.targetBytes | Chunk size target, extended to the next game boundary | tuned for throughput |
chunk.maxLines | Safety cap on lines per chunk | |
chunk.minLines | Minimum lines before a byte-target chunk may be emitted |
Bigger chunks mean less dispatch overhead; smaller chunks balance better when games vary wildly in length. Measure with your own file before micro-tuning.
When threads step aside
Pass workers: false to force everything onto the main thread:
await analyzePGN('games.pgn', { trackers: [tiles], workers: false });Two situations call for it — or force it:
- Filters. A
filteris a JavaScript closure, and closures can't cross the worker boundary, so filtered analyses are single-threaded automatically. Passing explicitworkersoptions together with a filter is rejected as an error. - Custom tracker development. Custom trackers need their module setup (
id,workerModule: import.meta.url,merge) before they can join the pool; while sketching one out,workers: falsekeeps things simple and debuggable. TheworkerModuleURL also requires an unbundled Node ≥ 22 or Bun runtime.