Skip to main content

c8 vs nyc vs Istanbul: JavaScript Code Coverage in 2026

·PkgPulse Team

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) and istanbul (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 ships with first-class coverage support — no extra configuration needed for basic use:

vitest run --coverage
// 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:

ToolTime (cold)Time (warm)Overhead vs no coverage
c84.2s3.8s+0.8s
nyc18.5s16.2s+13s
Vitest v85.1s4.6s+1.1s
Vitest istanbul14.3s12.8s+9.8s
Jest v88.2s7.4s+3.5s
Jest babel22.1s19.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)

[![codecov](https://codecov.io/gh/your-org/your-repo/branch/main/graph/badge.svg)](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

SituationRecommendation
Vitest projectvitest --coverage with provider: "v8"
Jest projectcoverageProvider: "v8" in jest.config.js
Vanilla Node.js + node:testc8
Legacy Mocha/Tape projectnyc
Need branch accuracy for libraryIstanbul provider (Vitest or nyc)
TypeScript with tsxc8 with tsx runner
Monorepo with turborepoVitest 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.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.