Faster Istanbul coverage for JavaScript and TypeScript. It replaces the slow step of Istanbul coverage, inserting the coverage counters into your code, with Rust code built on the Oxc parser. The coverage numbers stay the same.
Unofficial. This is an independent community project that builds on Oxc crates. It is not part of, affiliated with, or endorsed by the Oxc project.
You need Vitest 4.1.5 or newer.
1. Install the Istanbul coverage provider and this package:
npm install -D @vitest/coverage-istanbul oxc-coverage-instrument2. Add the instrumenter to the coverage config in vitest.config.ts. This
is the only change. compat: 'istanbul' keeps the coverage numbers equal to
those of Vitest's own instrumenter:
import { defineConfig } from 'vitest/config';
import { createOxcInstrumenter } from 'oxc-coverage-instrument/vitest';
export default defineConfig({
test: {
coverage: {
provider: 'istanbul',
instrumenter: (options) => createOxcInstrumenter({ ...options, compat: 'istanbul' }),
},
},
});3. Run your tests with coverage, as before:
npx vitest run --coverageTypeScript, TSX, and JSX work without extra setup: Vite compiles them as usual,
and the coverage points at your .ts and .tsx lines. Your other coverage
settings (include, exclude, reporter, thresholds, Vitest projects,
browser mode) stay as they are.
4. Optional: see more untested cases. Remove compat: 'istanbul' to use
the default profile. It also counts the branches of optional chains, ??=,
||=, and &&=, and gives anonymous functions the name that they get at
runtime. Your branch percentage can then change, so check your branch
thresholds (see More detail with the default profile).
The examples show React, a monorepo with Vitest projects, and CI
reports (lcov, cobertura, coverage thresholds).
Vitest has two coverage providers. v8 reads the coverage that the V8 engine
collects. istanbul inserts counters into your code before the tests run; that
works in every browser that Vitest's browser mode supports, and gives Istanbul's
exact statement, branch, and function counts. This package replaces the step
that inserts the counters.
The table shows that step on the test suites of four open-source projects,
with the setup above (compat: 'istanbul'). It was measured on a GitHub-hosted
runner with a cold Vitest cache, as in CI
(method and full results). The instrumenter
columns give the time of all Vitest workers together, for all source files of
the run. The last column gives the time of the whole vitest run:
| Project | Files instrumented | Vitest's instrumenter | oxc-coverage-instrument |
Speedup | Whole run |
|---|---|---|---|---|---|
| h3 (HTTP framework) | 81 | 1.5 s | 0.1 s | 13.4x | 19.4 s → 17.3 s |
| hono (web framework) | 636 | 11.8 s | 0.9 s | 13.3x | 29.4 s → 26.3 s |
| date-fns (date library) | 1061 | 4.0 s | 0.5 s | 7.5x | 37.4 s → 35.1 s |
| es-toolkit (utility library) | 802 | 5.7 s | 1.0 s | 5.7x | 197.6 s → 194.3 s |
The times are rounded; the speedup comes from the unrounded times.
- The coverage numbers stay the same. The covered and total counts of lines, statements, functions, and branches are equal to those of Vitest's own instrumenter in all four projects.
- The whole test run gains 2.1 s to 3.3 s. That is 11% of the run for h3 and hono, 6% for date-fns, and 2% for es-toolkit. The workers instrument files at the same time, so the time that you save is less than the sum in the table. The rest of the run (the tests themselves, the coverage collection, and the reports) does not change.
- Local reruns gain less. Vitest 5 caches the instrumented files on disk, so a rerun with a warm cache skips this step.
The numbers above use the strict profile (compat: 'istanbul'), which gives
the same coverage data as Vitest's instrumenter. The default profile, without
compat, records more. It adds a branch entry for each optional chain
(a?.b) and each ??=, ||=, and &&=. It also gives anonymous functions the
name that they get at runtime: the report shows const onRequest = () => {}
as onRequest, not as (anonymous_12). A branch entry has two or more
paths, for example the two sides of a ?? b. On the same four projects:
| Project | Branch entries | Branch paths that no test ran | Functions without a name | Branch coverage |
|---|---|---|---|---|
| hono | 2143 → 2375 | 296 → 385 | 1055 → 414 | 93.3% → 92.1% |
| h3 | 1302 → 1491 | 195 → 276 | 268 → 157 | 92.7% → 91.0% |
| date-fns | 776 → 1064 | 211 → 254 | 252 → 45 | 88.4% → 89.4% |
| es-toolkit | 1609 → 1668 | 54 → 68 | 332 → 274 | 98.4% → 98.0% |
Each extra untested path is a case that your tests do not check, for example
user?.name when user is null. The default profile shows you where these
cases are. Your branch percentage changes by up to 1.7 points, up or
down, so check your branch thresholds when you switch.
Choose:
| You use now | Then |
|---|---|
istanbul |
Add the instrumenter line with compat: 'istanbul'. Same numbers, less time. |
v8 and you are happy |
Stay on v8. Switch when you need coverage in Firefox or WebKit, or Istanbul's exact counts. |
babel-plugin-istanbul (nyc, Babel builds) |
Replace it with oxc-coverage-instrument/babel in the Babel config. The coverage is the same. Flow and some syntax proposals are not supported. See the npm package README. |
vite-plugin-istanbul |
Use the instrumenter option of vite-plugin-istanbul 9 and newer. See the npm package README. |
Karma with karma-coverage |
Register oxc-coverage-instrument/karma in coverageReporter.instrumenters. See Karma. |
@storybook/addon-coverage (Vite builder) |
Give istanbul.instrumenter to the addon and make it use vite-plugin-istanbul 9. See examples/storybook-vite. The webpack builder does not accept an instrumenter yet. |
Jest with coverageProvider: 'babel' |
Add ['oxc-coverage-instrument/babel', { onlyWithIstanbul: true }] to the Babel config. coverage-final.json stays the same. See examples/jest-babel. |
Istanbul coverage has four steps and one data format. This project has one part for each, and a sixth part for coverage from the V8 engine:
your code ─► 1 Instrument ─► tests run ─► 2 Coverage data ─► 3 Remap ─► 4 Summary ─► 5 Reports
V8 engine coverage ─► 6 V8 conversion ─┘
With the Vitest setup above, Vitest uses part 1 only. Vitest does steps 2 to 5
with its own Istanbul packages. Those steps are fast: on the four projects
above they take 0.9 s to 3.7 s per run, and the remap and the reports take
0.1 s to 1.1 s of that
(steps per project).
With oxc-coverage-instrument they take about the same time, a little more
in three of the four projects. Vitest can also load a custom coverage provider
or a custom reporter, but this package does not ship one. Parts 2 to 6 are for
your own scripts, for example a CI step that writes the reports, and for tools
that you build.
| Part | What it does | npm function | Rust crate | Replaces |
|---|---|---|---|---|
| 1. Instrument | Adds a counter to each statement, function, and branch. | instrument(), createOxcInstrumenter(), oxc-coverage-instrument/babel |
oxc_coverage_instrument |
istanbul-lib-instrument, babel-plugin-istanbul, swc-plugin-coverage-instrument |
| 2. Coverage data | Sets the format of the counts: coverage-final.json. |
plain JSON | oxc_coverage_types |
the data model of istanbul-lib-coverage, without its merge |
| 3. Remap | Moves the positions from the built JavaScript back to your source files. | remapCoverageMap() |
oxc_coverage_source_maps |
istanbul-lib-source-maps |
| 4. Summary | Calculates the totals and the percentages, per file and for the project. | summarizeCoverage() |
oxc_coverage_report |
the summaries of istanbul-lib-coverage and istanbul-lib-report |
| 5. Reports | Writes text, lcov, cobertura, html, and other formats. |
createReport(), writeHtmlReport() |
oxc_coverage_reports |
a part of istanbul-reports |
| 6. V8 conversion | Turns the coverage that V8 records into the format of part 2. | v8ToIstanbul() |
oxc_coverage_v8 |
ast-v8-to-istanbul; more detail than v8-to-istanbul |
All npm functions are in the oxc-coverage-instrument package. They take and
return the coverage data as a JSON string.
In the snippets below, coverageJson is a coverage map: one entry per file,
keyed by the file path. Vitest writes it to coverage/coverage-final.json when
the json reporter is on (it is on by default):
import { readFileSync } from 'node:fs';
const coverageJson = readFileSync('coverage/coverage-final.json', 'utf8');instrument() and v8ToIstanbul() return the data of one file, not a map.
Put it in a map before you pass it on:
JSON.stringify({ [file.path]: file }), where file is the parsed result.
What it does. It reads a source file and writes a copy with a counter on each statement, function, and branch. When the tests run the copy, each counter adds one each time its code runs.
Why use it. This is the slowest step of Istanbul coverage. In Vitest it is 5.7x to 13.4x faster than Vitest's own instrumenter (see Is it worth it?).
How. In Vitest, use the setup at the top of this README. In
vite-plugin-istanbul 9 and newer, pass createOxcInstrumenter() as its
instrumenter option. In Storybook with the Vite builder, give the same
instrumenter to @storybook/addon-coverage (see
examples/storybook-vite). In your own build
step or plugin, call instrument():
import { instrument } from 'oxc-coverage-instrument';
const result = instrument(source, 'src/app.ts', { sourceMap: true, stripTypescript: true });
// result.code: the JavaScript code with the counters
// result.coverageMap: the coverage data of this file, as JSON, with all counts at 0
// result.sourceMap: the source map of result.codeWhen the code runs, the counters go into globalThis.__coverage__, keyed by the
file path. That object is the coverage map that the other parts read.
karma-coverage 2 accepts a custom instrumenter. Register
oxc-coverage-instrument/karma and select it for your files:
// karma.conf.js
module.exports = (config) => {
config.set({
preprocessors: { 'src/**/*.js': ['coverage'] },
reporters: ['progress', 'coverage'],
coverageReporter: {
instrumenters: { oxc: require('oxc-coverage-instrument/karma') },
instrumenter: { '**/*.js': 'oxc' },
instrumenterOptions: { oxc: { compat: 'istanbul' } },
},
});
};karma-coverage matches the instrumenter patterns against the absolute file
path, so start each pattern with **/. With compat: 'istanbul', the
statement, function, and branch maps and the hit counts are the same as with
the default instrumenter of karma-coverage. The Karma example runs both on lodash in
headless Chrome and compares the coverage-final.json files.
stripTypescript: true removes the TypeScript types. Without it, the output
keeps the types, and plain Node.js cannot run it. JSX stays JSX either way, so
compile it after this step. The Vitest adapter strips the types for you. The
npm package README lists all
options.
What it does. It is the format of the result. For each file it records each
statement, function, and branch, its position, and how many times it ran. This
is the coverage-final.json that Vitest's json reporter writes.
Why use it. Every part reads and writes this format, and it is the same as Istanbul's. nyc and the other Istanbul tools read the output of this project, and this project reads their output.
How. In JavaScript it is plain JSON: JSON.parse() it. In Rust, use the
types of oxc_coverage_types (FileCoverage, CoverageMap). The npm package
has no function to merge two coverage files.
What it does. Your tests often run built JavaScript, not your TypeScript
source. The counts then point at lines of the built file. A source map tells
which built line comes from which source line. This part uses the source map
to move each position back to your .ts or .tsx file.
Why use it. You need it when you instrument built output, for example a bundle, and want the coverage on your source files. Vitest remaps on its own.
How. Each file entry of the coverage data can hold its source map in
inputSourceMap. remapCoverageMap() uses it:
import { remapCoverageMap } from 'oxc-coverage-instrument';
const remapped = remapCoverageMap(coverageJson);The file path in the result comes from sources in the source map, so set
sources (or sourceRoot) to the path that you want in the report. When the
maps are separate files, read them first and pass them to
remapCoverageMapWithLoader(coverageJson, { [filePath]: mapJson }). To skip
this step, pass the source map to instrument() as inputSourceMap and set
composeInputSourceMap: true. The coverage data then points at the source from
the start.
What it does. It counts the covered and total lines, statements, functions, and branches, and calculates the percentages. It does this per file and for the whole project.
Why use it. Use it for a coverage threshold in CI, a badge, or a comment on
a pull request. It is about as fast as istanbul-lib-coverage; the gain is
that the same package also writes the reports. The command line does the same
check with report --fail-under 80, which exits with code 2 under the
threshold.
How.
import { summarizeCoverage } from 'oxc-coverage-instrument';
const { total, files } = summarizeCoverage(coverageJson);
if (total.lines.pct < 80) process.exitCode = 1;What it does. It writes the coverage in the formats that people and tools read:
| Format | For |
|---|---|
text, text-summary |
The table in your terminal. |
json-summary |
Scripts and badges. |
lcov |
Codecov, Coveralls, SonarQube, and editor plugins. Writes lcov.info. |
cobertura |
GitLab, Azure DevOps, and Jenkins. |
html |
People: a page per file that marks the lines that did not run. |
It does not write clover, json, teamcity, text-lcov, or html-spa.
Why use it. One script writes all the formats from one
coverage-final.json. The lcov and cobertura reports are 2.6x to 2.9x
faster than istanbul-reports. The text reports take about the same time.
The html report is about 32 times slower, because it highlights the source
code when it writes the pages (see the benchmarks).
How.
import { writeFileSync } from 'node:fs';
import { createReport, writeHtmlReport } from 'oxc-coverage-instrument';
writeFileSync('coverage/lcov.info', createReport(coverageJson, 'lcov', { rootDir: process.cwd() }));
writeHtmlReport(coverageJson, 'coverage/html', { rootDir: process.cwd() });rootDir makes the paths in the reports relative to your project. The
vitest-ci-reports example writes lcov,
cobertura, and html after a Vitest run, and fails the run under a
threshold. The WebAssembly build has no html report.
Other reporters read the same coverage-final.json. For
monocart-coverage-reports,
give the parsed file to add() and call generate(). No adapter is necessary.
The monocart example does this after a Vitest run and on
the real lodash build, and checks that the totals are the same.
What it does. This is a different way to get coverage. The V8 engine in Node.js and Chrome can record which byte ranges of a script ran, with no counters in the code. This part turns that record into the coverage data of part 2, with statements, functions, and branches from the source code.
Why use it. Use it when you collect V8 coverage yourself, for example with
node:inspector or with Playwright's page.coverage, and want Istanbul data and
reports. Vitest's v8 provider does this step on its own.
How. Pass the script source and the functions array that V8 gives for
that script:
import { v8ToIstanbul } from 'oxc-coverage-instrument';
const file = JSON.parse(v8ToIstanbul(source, 'src/app.js', JSON.stringify(script.functions)));
const coverageJson = JSON.stringify({ [file.path]: file });V8 does not record each branch path. When a count is the same as the count of the code around it, V8 removes that range. A branch path with no range of its own therefore gets a count from the code around it:
- A ternary path, or the implicit
elseof anif, gets the count of the code where the branch starts, minus the counts of the other paths. - An
ifbody, aswitchcase, an operand of&&,||or??, and a default value get the count of the code around them.
V8 has no range for a default value, so a default parameter gets the call
count of its function, not the number of calls that used the default. An
optional chain (?.) path with no range gets 0, and so does the right side
of ||=, &&= and ??=. When an istanbul ignore comment removes one path
of a ternary or an if, the other path does not take the remainder.
On the lodash 4.17.21 build, this rule gives a branch total within 1% of the Istanbul instrumentation of the same workload.
Each part is a separate Rust crate, so a Rust tool takes only the parts it needs:
[dependencies]
oxc_coverage_instrument = "0.20"use oxc_coverage_instrument::{instrument, InstrumentOptions};
let source = "function add(a, b) { return a + b; }";
let result = instrument(source, "add.js", &InstrumentOptions::default()).unwrap();
assert_eq!(result.coverage_map.fn_map["0"].name, "add");
println!("{}", result.code);The oxc-coverage-instrument command instruments files and writes reports. It
is not on crates.io, so install it from Git. It keeps TypeScript types in its
output:
cargo install --git https://github.com/fallow-rs/oxc-coverage oxc_coverage_instrument_cli
oxc-coverage-instrument src/app.js -o dist/app.js --source-map
oxc-coverage-instrument report --format text coverage-final.jsonVitest maintains forks of the Istanbul packages in
vitest-dev/istanbuljs.
@vitest/istanbul-lib-instrument keeps the Istanbul API and replaces Babel
with the Oxc parser of rolldown and with magic-string. The forks of the
other packages are JavaScript ports of the Istanbul packages. This project
replaces all of them with Rust code behind a native or WebAssembly binding.
The instrumenter is checked against istanbul-lib-instrument on a shared
fixture corpus, by count and byte for byte. This repository was previously
named oxc-coverage-instrument; that name now belongs to the npm package, the
instrumenter crate, and the CLI binary.
Each part measured on its own, against the package it replaces. Times on the
five pinned library builds of scripts/real-world-corpus.json, in one Node.js
process, as the median of 7 runs after 2 warm-up runs, from
scripts/replacement-benchmarks.mjs. Measured on 2026-10-24 on an Apple M5
(macOS, Darwin 25.5), Node.js 24.20.0, with the release build of the native
binding. The time is the sum over the five files, in milliseconds. A ratio
above 1 means that oxc-coverage-instrument is faster.
| Stage | Package | Package (ms) | oxc-coverage-instrument (ms) |
Ratio |
|---|---|---|---|---|
| Instrument | istanbul-lib-instrument 6.0.3 |
1898 | 64 | 29.7x |
| Instrument | @vitest/istanbul-lib-instrument 2.0.0 |
411 | 64 | 6.4x |
| V8 to Istanbul | ast-v8-to-istanbul 1.0.7, with oxc-parser |
341 | 88 | 3.9x |
| V8 to Istanbul | v8-to-istanbul 9.3.0 |
33 | 88 | 0.4x |
| Source-map remap | istanbul-lib-source-maps 5.0.6 |
180 | 139 | 1.3x |
| Source-map remap | @vitest/istanbul-lib-source-maps 2.0.0 |
168 | 139 | 1.2x |
| Summary | istanbul-lib-coverage 3.2.2 |
39 | 44 | 0.9x |
text report |
istanbul-lib-report 3.0.1 + istanbul-reports 3.2.0 |
44 | 41 | 1.1x |
text-summary report |
same | 33 | 35 | 0.9x |
json-summary report |
same | 32 | 36 | 0.9x |
lcov report |
same, as lcovonly |
131 | 45 | 2.9x |
cobertura report |
same | 157 | 61 | 2.6x |
html report |
same | 128 | 4112 | 0.03x |
How to read the slower rows:
v8-to-istanbuldoes less work. It gives one statement per source line and no branch locations from the AST: 51876 statements and 123 branches for three.js, whereoxc-coverage-instrumentandast-v8-to-istanbulgive the 20247 statements and 4018 branches that the instrumenter records.- The summary and the
text,text-summary, andjson-summaryreports spend most of their time in parsing the coverage JSON, on both sides. - The
htmlreport highlights each source file in Rust when it writes the report. The Istanbulhtmlreport highlights in the browser.
The Vitest fork took the same time as the Istanbul packages for the summary and the reports, within about 3%, so the table leaves it out for these rows.
scripts/benchmark-comparison.sh compares the instrumenter with
babel-plugin-istanbul and swc-plugin-coverage-instrument. It runs each tool
and file in its own Node.js process, as the median of 5 runs, and installs the
current release of each tool. Measured on the same machine and day:
| Package | Package (ms) | oxc-coverage-instrument (ms) |
Ratio |
|---|---|---|---|
babel-plugin-istanbul 8.0.0 |
571 | 67 | 8.6x |
swc-plugin-coverage-instrument 0.0.33 |
880 | 67 | 13.2x |
swc-plugin-coverage-instrument runs as WebAssembly inside SWC's plugin
sandbox, so its time includes that overhead.
These numbers are for the instrumenter alone. The Babel plugin
oxc-coverage-instrument/babel does not make a Babel run much faster. Babel
must still parse the instrumented code and print it with its source map, and
that work is most of the time. On the 191 source files of a real Jest project,
with @babel/preset-env, the plugin added 308 ms to the Babel run and
babel-plugin-istanbul added 470 ms. Without a preset, and on two large files
(1.2 MB and 0.8 MB), the two plugins took the same time within 10%.
Per-file times
Instrument (ms). The last column is the statement, function, and branch count of
Istanbul, of the Vitest fork, and of oxc-coverage-instrument in the strict profile. They are equal
for every file.
| File | KB | istanbul-lib-instrument |
@vitest/istanbul-lib-instrument |
oxc-coverage-instrument |
s/f/b |
|---|---|---|---|---|---|
| React 18.2.0 | 107 | 45 | 7.8 | 1.6 | 1194/176/332 |
| lodash 4.17.21 | 531 | 160 | 35 | 6.5 | 3714/691/1303 |
| Vue 3.3.4 | 463 | 331 | 67 | 11 | 7799/1157/3117 |
| D3 7.8.5 | 573 | 523 | 116 | 19 | 11935/2518/2971 |
| three.js 0.155.0 | 1237 | 839 | 185 | 26 | 20247/2210/4018 |
V8 to Istanbul (ms), on the precise coverage of one run of each build.
| File | V8 functions | v8-to-istanbul |
ast-v8-to-istanbul |
oxc-coverage-instrument |
|---|---|---|---|---|
| React 18.2.0 | 133 | 0.8 | 9.3 | 1.3 |
| lodash 4.17.21 | 611 | 5.1 | 39 | 6.7 |
| Vue 3.3.4 | 755 | 4.8 | 58 | 13 |
| D3 7.8.5 | 1620 | 6.0 | 92 | 26 |
| three.js 0.155.0 | 1775 | 16 | 143 | 41 |
Source-map remap (ms), on the coverage of a @babel/core build of each file,
mapped back to the file. All three give the same entry counts.
| File | istanbul-lib-source-maps |
@vitest/istanbul-lib-source-maps |
oxc-coverage-instrument |
|---|---|---|---|
| React 18.2.0 | 4.0 | 4.2 | 3.1 |
| lodash 4.17.21 | 17 | 16 | 13 |
| Vue 3.3.4 | 33 | 34 | 23 |
| D3 7.8.5 | 51 | 46 | 42 |
| three.js 0.155.0 | 75 | 68 | 58 |
Regenerate these numbers with:
node scripts/prepare-real-world-corpus.mjs
npm --prefix crates/oxc_coverage_instrument_napi run build
node scripts/replacement-benchmarks.mjs
./scripts/benchmark-comparison.sh- ARCHITECTURE.md for the pipeline, the crate map, and the cross-cutting concerns, including how to run the benchmark comparison.
oxc_coverage_instrumentreplacesistanbul-lib-instrument, and documents the deliberate divergences from it.oxc_coverage_instrument_napiis theoxc-coverage-instrumentnpm package, including the runtime matrix.oxc_coverage_instrument_cliis theoxc-coverage-instrumentbinary.oxc_coverage_typesreplacesistanbul-lib-coverage.oxc_coverage_source_mapsreplacesistanbul-lib-source-maps.oxc_coverage_v8replacesv8-to-istanbul.oxc_coverage_reportreplacesistanbul-lib-report, andoxc_coverage_reportsprovides a selected set ofistanbul-reports-compatible formats.examples/carries runnable projects for Vitest with TypeScript, Node with WebAssembly, and Cloudflare Workers.
- Rust 1.97 or newer, 2024 edition
- Oxc 0.155.x
- Istanbul
coverage-final.jsonv3 or newer - Node.js 18 or newer, through napi-rs
| Project | AST |
|---|---|
istanbul-lib-instrument |
Babel |
babel-plugin-istanbul |
Babel |
swc-plugin-coverage-instrument |
SWC |
See CONTRIBUTING.md, and AGENTS.md for the workspace layout and the check profiles.
MIT