c8 vs nyc vs Istanbul: JavaScript Code Coverage in 2026
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
Explore testing package download trends on PkgPulse — see how the testing ecosystem is evolving in 2026.
See the live comparison
View vitest vs. jest on PkgPulse →