Chessalyzer

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 ◄──────────┘
  1. The main thread streams the file and cuts it into chunks sized in bytes, always aligned to whole games.
  2. 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.
  3. 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's merge, then runs onFinish once. Per-game onGameEnd hooks 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 } },
});
OptionWhat it controlsDefault
workersThread count (shorthand: workers: 8)all available cores
chunk.targetBytesChunk size target, extended to the next game boundarytuned for throughput
chunk.maxLinesSafety cap on lines per chunk
chunk.minLinesMinimum 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 filter is a JavaScript closure, and closures can't cross the worker boundary, so filtered analyses are single-threaded automatically. Passing explicit workers options 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: false keeps things simple and debuggable. The workerModule URL also requires an unbundled Node ≥ 22 or Bun runtime.

On this page