- tsv is a toolchain for TypeScript/JS, CSS, and Svelte in Rust. Performance is its priority - after correctness, and the numbers compare favorably to Oxc and Biome for its supported - languages. -
-- Rather than supporting many languages, tsv focuses on Svelte/HTML, TypeScript/JS, and CSS. - This lets it be smaller when it's all you need, a quality that's more relevant when used in - the browser via wasm: -
-- tsv's formatter is similar to Oxfmt - and Biome. Today it can format Svelte, TypeScript, and CSS, - plus HTML and JS (as strict-mode TypeScript): -
- {#each format_groups as group (group.language)} -- The parse rows that build a full JS AST are directly comparable: tsv and oxc-parser both - serialize the AST to JSON in Rust and deserialize it in JS, native and wasm alike. The - tsv-internal and tsv_wasm-internal rows are tsv's parse-only numbers - they build the native - AST but skip JS-side materialization, so they show raw in-engine speed rather than a - cross-tool comparison. -
- {#each parse_groups as group (group.language)} -+ tsv formats JS with its TypeScript parser, so JS and TypeScript share one chart. Each + chart's ratios are against its highlighted row — hover another row to re-anchor them — and a + minus means that many times worse. +
+ {#if unstable_entries.length} + + {/if} + {#each format_groups as group (group.language)} +
+ tsv and oxc-parser share a mechanism:
+ both serialize the AST to JSON in Rust and hand it to the JS engine's
+ JSON.parse, native and wasm alike. The deliverables differ: tsv's default wire
+ (tsv json / tsv-wasm json) carries a per-node loc
+ (line/column) object that oxc-parser's span-only AST omits, at ~{parse_ts_loc_cost} the time
+ without it on the TypeScript corpus. The no-locs entries drop it for a
+ span-only wire of Oxc's kind, the closest comparison with oxc-parser but not an equal one:
+ Oxc's AST also writes out default-valued fields tsv omits (optional: false,
+ decorators: [], typeAnnotation: null), about a third more bytes,
+ and its call also returns comments and module records, so much of the gap between them is
+ payload. The internal entries build tsv's in-Rust AST and stop — no
+ serialization, nothing materialized in JS — so they show raw engine speed, and their gap to
+ the json entries is what serializing and handing off costs tsv, not a
+ cross-tool comparison.
+
+ yuku-parser, a JS/TS parser written in Zig, emits the same span-only AST as Oxc, so it too
+ compares against the no-locs entries. It hands JS a compact binary buffer that
+ decodes into objects when its program is read, so the bench reads it to time
+ the same fully materialized result. swc's AST (a Module root with
+ span offsets) matches neither tsv wire.
+
+ The size of each tool's artifact, split by where it runs and grouped by what it does. Bars
+ and ratios are raw bytes, which track what a runtime compiles or parses and what sits on
+ disk; the gz beside each estimates the download. tsv-wasm,
+ tsv (napi), and tsv (ffi) are tsv's full builds, parser and
+ formatter for Svelte, TypeScript/JS, and CSS in one artifact, beside format-only and
+ parse-only subsets. Each group's ratios are against its smallest build, highlighted; hover
+ another row to re-anchor them.
+
+ The same harness runs under three JS runtimes — Node, Deno, and Bun; the in-process charts + above are the Node run, chosen as the default N-API host, not for speed. The native entry + differs by runtime: Node and Bun load tsv's N-API addon, Deno its C-FFI library, which + shares its code but crosses a different binding boundary and aborts on panic where the addon + unwinds. So a per-runtime delta on the same row comes from the JS engine, the host's N-API + implementation, or that binding and build, not from tsv's algorithms. +
+ ++ Each row is the mean time of one sweep, a pass over its group's timed file set. Within a + group every tool is timed on the same files — the intersection of what every timed tool + accepted, so a file one rejects drops out for everyone. The count above each chart is that + intersection, and a chart that runs short of the corpus total says beneath it what was left + out and which rows failed it. +
++ These are warm numbers: every row runs warmup sweeps before it is timed, so a cold one-shot + call pays more. Each native or wasm call also pays a string encode across the binding + boundary, and a decode wherever it hands back text or JSON, which the JS tools skip. +
++ Each row is timed until it has both a few seconds of sweeps and at least + {format_count_maybe(sweeps.floor)} sweeps{canonical_floor_note}, so the multi-second rows + stop near that floor. After outlier cleaning, rows keep from + {format_count_maybe(sweeps.sample_size_min)} to {format_count_maybe(sweeps.sample_size_max)} + timings. A steady reading over a handful of sweeps is thinner evidence than one over + hundreds, so the harness's check for unstable rows proves less for the slow rows — Prettier + among them, the default anchor of every format chart. +
+
+ Rows run in a fixed order, not interleaved or shuffled: the reference row, then tsv's rows,
+ then the alternatives. A forced garbage collection before each row limits what one row
+ leaves for the next, but nothing pins the process to a core or holds the laptop CPU's clock
+ steady, so whatever remains, thermal drift included, falls on the later rows. Against every
+ alternative that bias counts for tsv; against the reference row, which runs first, it counts
+ against tsv — Prettier in the format charts, and svelte/compiler, acorn-typescript, and
+ parseCss in the parse ones. The size of that bias hasn't been measured; no
+ row's timings shift more than ~{format_share_approx(sweeps.drift_max)} from its first half
+ to its second.
+
+ One asymmetry isn't isolated: Oxfmt's programmatic format is async-only (as is
+ Prettier's, without the N-API hop), so each call pays an N-API task dispatch and promise
+ resolution inside its timing that tsv's sync call doesn't, and the native work may run off
+ the JS thread — still one file at a time, since each call is awaited before the next, but
+ not strictly one thread.
+
+ Every in-process timing on this page comes from {format_count(corpus_counts.files)}
+ .svelte, .ts/.js, and .css files of
+ real-world code, vendored in the
+
+
+ fuzdev/corpora{corpus_snapshot_commit ? `@${corpus_snapshot_commit}` : ''}
+ snapshot so one clone reproduces it. They're the author's libraries, apps, and sites
+ (the fuz.dev ecosystem plus personal SvelteKit projects), and upstream framework source
+ (Svelte, SvelteKit, and the svelte.dev site).
+
<style> blocks of the corpus's Svelte components, concatenated per
+ corpus
+ collection{corpus_counts.harvested_css && corpus.css
+ ? ` — ${format_count(corpus_counts.harvested_css)} of its ${format_count(corpus.css)} entries`
+ : ''}. Even so, CSS is the weakest sample, with just
+ {format_count_maybe(corpus_counts.standalone_css)} standalone files.
+ - All numbers are single-threaded: every library formats or parses one file at a time, measured - sequentially with no cross-file parallelism. These are per-file, single-core latency and - throughput numbers - not the multi-core batch throughput a CLI gets when it formats many files - at once, which most of these tools (tsv included) can do. -
-
- What's measured: around 5,500 files (~15 MB) of
- .svelte/.html, .ts/.js, and
- .css, from three sources: the fuz.dev libraries and apps, upstream framework
- source (Svelte, SvelteKit, and the svelte.dev site), and formatter conformance fixtures
- (Prettier's and prettier-plugin-svelte's own test suites - deliberately tricky edge cases, not
- typical code, so they skew the corpus toward hard cases).
-
Each source links its upstream at the commit the snapshot vendored.
+