Benchmark
This page measures the practical question behind ttsc: how much wall-clock time changes when a real TypeScript project moves from the legacy tsc/eslint path to TypeScript-Go backed builds, checks, and linting.
The fixtures are real repositories cloned into prepared legacy, ttsc, and ttsc-lint branches. The dashboard reports the fastest command-line run per cell on the same machine, system noise can only push individual runs slower, never faster, so the minimum is the closest we can get to the workload itself. Absolute seconds still depend on the host shown below; the ratios are the useful signal. Projects are displayed by upstream GitHub stars so the most recognizable fixtures appear first.
Loading benchmark results…
Reading the Results
The Summary tab keeps only the strongest result in each category. Build and Type-check compare one unit of work across the legacy compiler, ttsc in single-threaded and checker-pool modes.
Lint is intentionally split out. The legacy path is tsc + eslint, two separate passes over the project. The ttsc-lint path runs ttsc with @ttsc/lint loaded and invokes those cells with --diagnostics, so the runner parses the lint sidecar’s own @ttsc/lint time line instead of deriving lint cost from total-minus-total noise. The chart separates that measured lint portion from the rest of the compiler run. The rest stays blue because @ttsc/lint also builds a TypeScript-Go Program and reports normal TypeScript diagnostics for the project. If the same command also runs source transform hosts such as typia or nestia, the chart shows that transform-host time as a second compiler segment after the green lint segment, because that work is still part of the TypeScript compile path rather than ESLint-style lint work. The chart also shows the pure lint-to-lint ratio.
The practical takeaway from this split: @ttsc/lint is measured from the single native @ttsc/lint time line, not as the whole check sidecar. The sidecar wall clock is still recorded for audit, but it includes compiler work that belongs in the blue segment. Adding @ttsc/lint to a project that already runs ttsc is roughly free; the win shows up as the eslint column disappearing.
Older published snapshots did not carry direct @ttsc/lint timings. When a row lacks the parsed @ttsc/lint sample, the dashboard falls back to the old estimate: ttsc-lint total minus the matching plain ttsc total. If that fallback lands at or below zero for the single-threaded row, the dashboard substitutes a synthetic value derived from the fastest available row for the same project:
ST_overhead = round(ST_ttsc * (MT_overhead / MT_ttsc))Fallback rows are labelled delta est. so the chart stays explicit about which bars are derived from total-command deltas rather than direct plugin timing.
Methodology
Each fixture is checked out as legacy, ttsc, and ttsc-lint. The ttsc branches run ttsc prepare before timing so plugin binary build time is not included in compiler measurements. No benchmark-only tsconfig is used; the runner invokes the repository’s normal TypeScript configuration for the command being measured.
The run count is configured by TTSC_BENCH_RUNS and TTSC_BENCH_WARMUP, and the chosen values are recorded in website/public/benchmark.json along with the raw per-run wall-clock samples, retry counts, deterministic failures, and, for ttsc-lint build/check cells, parsed @ttsc/lint timings. The dashboard reduces each cell’s samples to its minimum at render time, so the published JSON never carries derived statistics that would drift out of sync with the chosen reduction. A missing bar means that project does not currently have a valid comparable command for that tool.
The host panel’s Legacy TypeScript field is the installed typescript version in the fixture legacy clone. The current fixture set resolves to a single version, so the panel shows one value; if future fixtures diverge, the runner records per-project versions and the host panel reports that the value varies by fixture.
Comparisons
- Build:
tscemit on the legacy branch versusttscemit on thettscbranch. - Type-check:
tsc --noEmitversusttsc --noEmit. - Lint: legacy
tsc + eslintversusttsc + @ttsc/lint, plus the isolatedeslinttime versus the measured@ttsc/lintoverhead. - Format: legacy
prettier --checkversusttsc format, shown as the bare default command and the--singleThreadedserial run. The checker-pool sweep is intentionally not shown for format because format rules do not use the TypeScript-Go checker pool.
Threading variants
Build, type-check, and lint cells for ttsc are measured under four threading configurations, in display order:
| Variant | Flag | What it caps |
|---|---|---|
single | --singleThreaded | Forces every stage serial: parse, type-check, lint engine. |
checkers2 | --checkers 2 | Parse and lint stay parallel; the TypeScript-Go checker pool is capped at 2 workers. |
checkers4 | --checkers 4 | Same, capped at 4 checker workers. |
checkers8 | --checkers 8 | Same, capped at 8 checker workers. |
The previous binary single / multi axis for compiler cells (where multi meant “default, uncapped, usually one checker per CPU”) is gone. The 4-point spectrum is the diminishing-returns curve of the checker pool alone, parse parallelism is held constant, so a glance at the four bars per project shows whether 8 checkers is overkill, 2 is enough, or the rule walk has already saturated.
Format is the exception. ttsc format does not type-check, so --checkers N does not change the formatter’s worker count. The Format chart therefore keeps only the meaningful axis: --singleThreaded versus the bare ttsc format default. The fixture source is expected to be pre-formatted so the format cell measures the no-op steady-state cost rather than the first-edit-apply pass. The current snapshot relabels the former checkers4 format samples as the bare ttsc format default row because the flag does not affect formatter work.
Why Threading Doesn’t Always Help
The dashboard exposes multiple threading variants of ttsc: serial execution with --singleThreaded, plus checker-pool caps with --checkers 2, 4, and 8. The less constrained variants are usually faster, but the size of the win swings widely across projects: type-check on vscode is roughly 2× the single-threaded time, while format on shopping-backend lands within a percent of the single-threaded run. Two mechanics drive that spread.
Per-build chunking caps the parallel win. TypeScript-Go’s worker pool lives inside one program. A fixture that splits the work across many tsc -p or ttsc -p invocations, rxjs type-checks with three separate compiler calls (cjs / esm / types tsconfigs) and formats with two (observable / rxjs packages); nestjs runs one per published package (nine for type-check, nine for format); shopping-backend is a single small program, pays parse-startup overhead per process, and that overhead cannot be parallelized across the OS process boundary. The multi-threaded gain is therefore bounded by how much real work each individual invocation has to do. vscode type-checks 6,000+ files in one program, so the parse pool saturates, and the MT-vs-ST gap is large. nestjs’s per-package programs each touch a few hundred files, where the parse phase is already short and threading saves only the inner loop, not the harness overhead.
Format is almost pure parse + rule walk, with no checker pool to scale. ttsc format does not need the TypeScript checker. The format rules (format/semi, format/quotes, format/trailing-comma, format/print-width, and friends) walk the AST and never resolve a type. The single-checker pin that the lint sidecar uses for type-aware rules therefore does not apply, and the only parallelism left to gate is the parse work group and the rule walk. On a representative format cell (per-phase timings on this host with TTSC_FORMAT_TIMING):
| Project | Files | Parse MT | Parse ST | Engine.Run MT | Engine.Run ST |
|---|---|---|---|---|---|
vue | 437 | ~270 ms | ~900 ms | ~130 ms | ~900 ms |
vscode | 6 097 | ~2 200 ms | ~5 700 ms | ~1 000 ms | ~1 000 ms |
nestjs/core | 352 | ~165 ms | ~230 ms | ~13 ms | ~13 ms |
Two patterns fall out of that table. On small file counts (nestjs/core), the rule walk is in the tens of milliseconds, under the goroutine pool’s coordination overhead, so making it parallel does not move the needle. On a single-program mid-size fixture (vue), both parse and the rule walk parallelize cleanly and MT is roughly 4× the ST time inside the format command. On the very large fixture (vscode), parse parallelizes well but Engine.Run plateaus because a handful of huge files (the editor’s service layer) dominate the longest-running rule walks and the others finish early, leaving CPUs idle. Filesystem and OS page cache effects also matter more here: a 6 000-file parse on WSL2 hits disk read latency that the work group cannot fan out.
The ttsc format wall-clock includes plenty of non-format work. The harness measures pnpm exec ttsc format ... end-to-end. Inside that, only runFormat itself is threading-sensitive; pnpm, the Node launcher, the plugin-binary cache check, and the lint sidecar’s startup contribute hundreds of milliseconds that look the same under MT and ST. On a project where runFormat itself is 200 ms, the harness wall clock is dominated by that fixed cost and MT / ST converge.
The published numbers reflect those mechanics, large single-program fixtures (vue, typeorm, vscode) show a clear MT win on the Format chart; multi-program fixtures (rxjs, nestjs) and small single-program fixtures (zod, shopping-backend) sit within noise of each other. The type-check (noEmit) column has a wider spread because the checker pass adds parallelizable work on top of parse and scales with file count. The summary chart’s “single-threaded” column is the right comparison when you want to know how ttsc behaves on a one-core CI box; the multi-threaded column is the right answer for a developer workstation.
Reproduce
The runner starts from an empty experimental/benchmark/.work directory. It packs the local ttsc workspace into tarballs, clones the fixture branches, installs those tarballs into the ttsc and ttsc-lint clones, runs ttsc prepare, then measures each cell sequentially so projects do not compete for CPU during timing.
git clone https://github.com/samchon/ttsc.git
cd ttsc
corepack enable
pnpm install
rm -rf experimental/benchmark/.work
node experimental/benchmark/bench.mjsThe runner defaults to 5 measured runs + 1 warmup per cell. The dashboard takes the minimum across those samples, so more samples push the published number toward the noise-free best case rather than averaging it. Override with TTSC_BENCH_RUNS / TTSC_BENCH_WARMUP when you want a quicker spot-check (TTSC_BENCH_RUNS=1 TTSC_BENCH_WARMUP=0 finishes a sweep in roughly half an hour but a single cold spike then sets the cell’s number) or a steadier sampling run (e.g. TTSC_BENCH_RUNS=10 doubles every sample and lowers the chance of an outlier squatting on the minimum). Results are written to experimental/benchmark/.work/report.md, experimental/benchmark/.work/report.json, and website/public/benchmark.json.
The published snapshot pins every fixture’s legacy branch to the Legacy TypeScript version shown in the host panel and on each project row (currently v6.0.3) so the tsc baseline reflects the same major version the ttsc rows run against. Earlier snapshots ran legacy on TypeScript 4.9.5 / 5.x; the matrix kept its shape but the cross-row comparison was apples-to-oranges. The fixtures’ ttsc and ttsc-lint branches use the local tarballs packed by the runner.
The fastest cells (ttsc:build:single, ttsc:noEmit:single) finish in 2–8 seconds, which is short enough that ambient host load, a concurrent build, an editor running language servers, a stray Node script, can move a single sample by 30–60 %. The runner therefore checks the 1-minute load average against the logical CPU count at startup and warns when the ratio exceeds 0.5; set TTSC_BENCH_REQUIRE_QUIET=1 to turn the warning into a hard error for publication runs, or TTSC_BENCH_SKIP_LOAD_CHECK=1 to suppress it during a development iteration where the noise floor is not the question being asked.