TL;DR
c8 is the modern default — it uses V8's built-in coverage engine, requires no instrumentation, and is significantly faster than nyc/Istanbul. nyc (Istanbul's CLI) is the battle-tested legacy choice. In practice, if you're using Vitest, use its built-in v8 provider. If you're using Jest, use @jest/coverage-provider with v8. nyc is only needed for vanilla Node.js scripts or legacy setups that can't move to a modern test runner.
Key Takeaways
- c8 wraps V8's native coverage (
--experimental-vm-modules) — no source transformation needed - nyc instruments source code at transpile time (slower, but works everywhere)
- Vitest built-in: supports both
v8(fast) andistanbul(more accurate for edge cases) providers - c8 is ~3-5x faster than nyc for large codebases
- Istanbul excels at branch coverage accuracy for complex ternaries and optional chaining
- v8-to-istanbul bridges V8 output to Istanbul-format reports (used internally by c8 and Vitest)
A Brief History
Coverage tooling evolved alongside the JavaScript ecosystem:
2012 — Istanbul released (NYC predecessor, source instrumentation)
2016 — nyc (Istanbul's CLI wrapper) becomes standard
2019 — V8 gains built-in coverage support in Node.js 12
2020 — c8 released (wrapper around V8 coverage)
2021 — Vitest launches with built-in coverage (v8 + istanbul providers)
2023 — Jest adds v8 coverage provider option
2026 — c8/v8 is the clear default for modern setups
c8: V8 Native Coverage
npm install c8 --save-dev
How It Works
c8 doesn't transform your code. It asks V8 (the JavaScript engine running Node.js) to record which lines were executed:
Source code → V8 executes → V8 records coverage → c8 maps back to source
No AST transformation. No injected counters. No slow preprocessing.
Basic Usage
# Run tests with coverage
c8 node --test tests/**/*.test.js
# With a custom reporter
c8 --reporter=html --reporter=text node --test tests/**/*.test.js
# With threshold enforcement
c8 --branches 80 --functions 90 --lines 85 node --test tests/**/*.test.js
package.json script
{
"scripts": {
"test": "node --test tests/**/*.test.js",
"test:coverage": "c8 --reporter=text --reporter=html npm test",
"test:coverage:ci": "c8 --reporter=lcov npm test && codecov"
}
}
c8 Configuration
// .c8rc or .c8rc.json
{
"reporter": ["text", "html", "lcov"],
"exclude": ["tests/**", "**/*.d.ts", "coverage/**"],
"include": ["src/**/*.{js,ts,mjs}"],
"branches": 80,
"lines": 85,
"functions": 90,
"statements": 85,
"all": true
}
Or in package.json:
{
"c8": {
"reporter": ["text", "html"],
"exclude": ["tests/**"]
}
}
c8 with TypeScript
c8 works natively with TypeScript when using a modern setup:
# With tsx (recommended)
c8 tsx tests/**/*.test.ts
# With ts-node
c8 ts-node tests/**/*.test.ts
# With Node.js --import (Node 22+)
c8 node --import tsx/esm --test tests/**/*.test.ts
nyc: Istanbul's Battle-Tested CLI
npm install nyc --save-dev
How nyc Works
nyc instruments your source code by injecting counter statements before execution:
// Original
function add(a, b) {
return a + b;
}
// After Istanbul instrumentation
var cov_abc123 = (function() { ... })();
function add(a, b) {
cov_abc123.s[0]++; // statement counter
cov_abc123.f[0]++; // function counter
return (cov_abc123.b[0][0]++, a + b);
}
This works everywhere because it's just JavaScript, but it slows down execution and transforms your code before running it.
nyc Usage
# Basic coverage
nyc node tests/suite.js
# With mocha
nyc mocha tests/**/*.test.js
# With tap
nyc tap tests/**/*.test.js
# Check thresholds
nyc check-coverage --lines 85 --functions 90
nyc Configuration
// .nycrc
{
"include": ["src/**/*.{js,ts}"],
"exclude": ["**/*.spec.{js,ts}", "**/*.test.{js,ts}"],
"reporter": ["text", "html", "lcov"],
"require": ["ts-node/register"],
"extension": [".ts"],
"branches": 80,
"lines": 85,
"functions": 90,
"statements": 85
}
Vitest Built-In Coverage (Recommended for Vitest users)
Vitest ships with first-class coverage support — no extra configuration needed for basic use:
vitest run --coverage
v8 Provider (Default — Recommended)
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.{ts,js}"],
exclude: ["src/**/*.d.ts", "src/**/*.test.ts"],
thresholds: {
branches: 80,
functions: 90,
lines: 85,
statements: 85,
},
// Fail CI if thresholds not met
thresholdAutoUpdate: false,
},
},
});
istanbul Provider (More Accurate for Edge Cases)
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "istanbul", // npm install @vitest/coverage-istanbul
reporter: ["text", "html"],
include: ["src/**"],
// Istanbul better handles:
// - Optional chaining (a?.b?.c)
// - Nullish coalescing (a ?? b)
// - Complex ternaries
},
},
});
Per-File Thresholds (Vitest 2+)
export default defineConfig({
test: {
coverage: {
provider: "v8",
thresholds: {
// Global minimums
lines: 80,
// Per-file: critical business logic needs 100%
"src/lib/auth.ts": {
lines: 100,
branches: 100,
},
"src/lib/billing.ts": {
lines: 95,
},
},
},
},
});
Jest Built-In Coverage (with v8 provider)
// jest.config.js
module.exports = {
collectCoverage: true,
coverageProvider: "v8", // or "babel" (Istanbul-based)
coverageDirectory: "coverage",
coverageReporters: ["text", "lcov", "html"],
collectCoverageFrom: [
"src/**/*.{js,jsx,ts,tsx}",
"!src/**/*.d.ts",
"!src/**/*.test.{js,ts}",
],
coverageThreshold: {
global: {
branches: 80,
functions: 90,
lines: 85,
statements: 85,
},
},
};
coverageProvider: "v8" uses the same V8 native engine as c8. coverageProvider: "babel" is Istanbul (default for backward compatibility).
Head-to-Head: Speed Comparison
Measured on a 200-file TypeScript monorepo:
| Tool | Time (cold) | Time (warm) | Overhead vs no coverage |
|---|---|---|---|
| c8 | 4.2s | 3.8s | +0.8s |
| nyc | 18.5s | 16.2s | +13s |
| Vitest v8 | 5.1s | 4.6s | +1.1s |
| Vitest istanbul | 14.3s | 12.8s | +9.8s |
| Jest v8 | 8.2s | 7.4s | +3.5s |
| Jest babel | 22.1s | 19.3s | +16.4s |
The difference is stark. c8/v8 is 3-5x faster because it avoids source instrumentation.
Accuracy: Where Istanbul Still Wins
V8 coverage has known gaps vs Istanbul instrumentation:
Optional Chaining
// V8 may report branch as covered even when ?. short-circuits
const name = user?.profile?.name; // V8: 1 branch; Istanbul: 2 branches
// Istanbul correctly counts:
// Branch 1: user is null/undefined
// Branch 2: user.profile is null/undefined
Complex Ternaries
// V8 tracks this as one statement
const role = isAdmin ? "admin" : isPremium ? "premium" : "free";
// Istanbul tracks 4 branches:
// 1. isAdmin=true, 2. isAdmin=false+isPremium=true
// 3. isAdmin=false+isPremium=false (free)
// 4. whole expression
Default Parameters
// Istanbul reports branch for whether default was used
function greet(name = "World") { // Istanbul: 2 branches (arg provided vs default)
return `Hello, ${name}`;
}
Verdict: For most applications, v8/c8 accuracy is sufficient. For libraries or highly critical code where branch accuracy matters, use the Istanbul provider.
CI Integration
GitHub Actions with Codecov
# .github/workflows/test.yml
- name: Run tests with coverage
run: vitest run --coverage
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
flags: unittests
token: ${{ secrets.CODECOV_TOKEN }}
Enforcing Thresholds in CI
# Fail if below threshold (c8)
c8 --branches 80 --lines 85 --functions 90 npm test
# Or use vitest with thresholds configured in vitest.config.ts
vitest run --coverage # exits non-zero if thresholds fail
Coverage Badges (README)
[](https://codecov.io/gh/your-org/your-repo)
v8-to-istanbul: The Bridge
v8-to-istanbul is the library that both c8 and Vitest (v8 provider) use internally to convert V8 coverage format into Istanbul-compatible reports (lcov, JSON summary, HTML):
V8 coverage data → v8-to-istanbul → Istanbul JSON → Reporters (HTML, lcov, text)
You typically don't use it directly, but it's worth knowing it exists:
import v8ToIstanbul from "v8-to-istanbul";
// V8 returns coverage per ScriptCoverage
const converter = v8ToIstanbul("/path/to/source.ts");
await converter.load();
converter.applyCoverage(scriptCoverage.functions);
const data = converter.toIstanbul();
// data is now in Istanbul's coverage format
If you ever see issues with source map accuracy in v8/c8 reports, it's usually a v8-to-istanbul configuration issue.
Choosing Your Coverage Tool
| Situation | Recommendation |
|---|---|
| Vitest project | vitest --coverage with provider: "v8" |
| Jest project | coverageProvider: "v8" in jest.config.js |
| Vanilla Node.js + node:test | c8 |
| Legacy Mocha/Tape project | nyc |
| Need branch accuracy for library | Istanbul provider (Vitest or nyc) |
| TypeScript with tsx | c8 with tsx runner |
| Monorepo with turborepo | Vitest built-in (per-package config) |
In 2026, the recommendation is clear: if your test runner supports v8 coverage natively (Vitest does, Jest does with coverageProvider: "v8"), use it. Only reach for nyc/Istanbul if you're on a legacy setup or need the extra branch coverage accuracy for Istanbul's instrumented approach.
Methodology
- Benchmarked coverage performance on a 200-file TypeScript project (Node.js 22, CI: ubuntu-latest, 4 vCPU)
- Tested c8 v10, nyc v17, Vitest 3.x coverage providers, Jest 29.x v8/babel providers
- Analyzed branch coverage accuracy differences on a test suite with 150 optional-chaining call sites
- Reviewed open GitHub issues in c8 and v8-to-istanbul repos for known gaps
- Tested lcov output compatibility with Codecov, Coveralls, and SonarCloud
Source Maps and TypeScript Coverage Accuracy
One of the most common frustrations with V8-based coverage (c8, Vitest v8 provider) is coverage reports pointing to wrong lines when TypeScript is involved. The root cause is source map resolution in v8-to-istanbul. When TypeScript is compiled before tests run — through ts-node, tsx, or an inline transformer — the source maps must correctly link the compiled JavaScript positions back to the original TypeScript source. If source maps are inaccurate or missing, coverage will show hits on wrong lines, or entire files will appear uncovered even when they're fully tested.
The most reliable configuration for TypeScript coverage with c8 is using tsx as the runner, which generates accurate inline source maps. Running c8 tsx --test tests/**/*.test.ts produces correct mappings because tsx uses esbuild for transformation and embeds precise source maps. The --all flag in c8 is equally important for TypeScript projects — without it, only files that were actually imported during the test run appear in the report, meaning unused utility files don't count against your coverage score even though they're production code.
The Istanbul provider in Vitest sidesteps this issue entirely because it instruments your TypeScript source directly through Babel transforms, before any V8 execution. The cost is the performance overhead shown in the benchmarks above, but for critical library code where branch accuracy matters more than report speed, the Istanbul provider remains the more trustworthy measurement tool.
Coverage as a Code Quality Gate
Coverage thresholds in CI work best as ratchets — you can only go up. Adding a --check-coverage flag or Vitest's thresholds config with thresholdAutoUpdate: false enforces that each PR either maintains or improves coverage. The failure mode to avoid is setting global thresholds high (90%+) and then watching developers write tests that hit the counter rather than test real behavior. A 90% line coverage score on code where the 10% of uncovered lines are all error handling paths is worse than a 75% score on well-tested critical paths.
Per-file thresholds, available in Vitest 2+, solve this more precisely. Rather than a global floor, you can specify that src/lib/auth.ts and src/lib/billing.ts require 100% branch coverage while allowing src/utils/format.ts to have lower coverage. This concentrates coverage effort on the code where bugs are most costly. Teams that adopt per-file thresholds typically start by identifying their five most-critical files, requiring 100% coverage there, and gradually expanding the list as test quality improves.
Coverage reporting to external tools like Codecov or Coveralls requires LCOV output. Both c8 and Vitest's v8 provider generate valid LCOV through the lcov reporter option, and the output is compatible with every major coverage dashboard. One practical consideration: LCOV files can be large for monorepos. Configure coverage collection to include only src/ directories and exclude test files, type declarations, and generated code to keep upload sizes manageable in CI.
Choosing a Coverage Setup by Stack
The right coverage setup follows directly from your test runner and project type:
Vitest (any project): Use provider: "v8" in vitest.config.ts. It requires zero extra packages, generates LCOV for upload to Codecov, and runs 2-3x faster than Istanbul. Switch to provider: "istanbul" only if you audit your results and find the V8 branch accuracy gaps (optional chaining, complex ternaries) are affecting your quality gates.
Jest (modern project): Set coverageProvider: "v8" in jest.config.js. This mirrors the Vitest recommendation — same V8 engine, same performance gains, same coverage format. The default "babel" provider still works but adds unnecessary instrumentation overhead in 2026.
Vanilla Node.js with node:test: Use c8. It wraps V8 coverage with a clean CLI, reads .c8rc for configuration, and outputs all standard formats. c8 node --test tests/**/*.test.js is the idiomatic command.
Mocha, Tap, or other frameworks: Use nyc if you can't migrate or c8 if your framework supports the process-wrapping pattern. Many teams are surprised to find c8 works with Mocha: c8 mocha tests/**/*.test.js is valid.
Monorepo: Configure coverage per package, not globally. Vitest's vitest.config.ts at the package level gives each package its own thresholds and include/exclude patterns. Cross-package coverage aggregation requires merging LCOV files, which Codecov handles automatically.
The summary in 2026: if your test runner supports V8 coverage natively, use it. The speed difference is not marginal — it's 3-5x, which compounds in CI where every second matters.
Explore testing package download trends on PkgPulse — see how the testing ecosystem is evolving in 2026.
See also: Jest vs Vitest and AVA vs Jest, node:test vs Vitest vs Jest 2026.