Skip to content

Why does NODE_V8_COVERAGE write ~5x larger profiles and cost ~9x more time when a child is run through tsx instead of compiled JS? #5177

Open

Description

@Kuldeep2822k

Node.js Version

v24.19.0

NPM Version

11.17.0

Operating System

windows

Subsystem

child_process, Other

Description

I have a Node CLI test suite that spawns the CLI itself as a child process (~880 spawns per run — argv parsing and exit codes are part of what is under test). Coverage comes from NODE_V8_COVERAGE, which propagates to every child. The whole test phase is 76 s without coverage and 232 s with it, and I have narrowed the cause to the cost per child: recording coverage is dramatically more expensive when the child executes loader-transformed TypeScript than when it executes the same program compiled by tsc.

Measured cost of one entry --version child process — median of 25 runs after 3 warmups, Windows 11 x64, 16 logical cores, 16 GB RAM, Node 24.19, tsx 4.23:

how the child runs ms / spawn ms added by coverage profile bytes
node -e "" (process startup only) 23.1 — —
node --import tsx -e "" (loader attached, no source) 23.3 — —
node dist/entry.js 182.5 — 0
node --import tsx src/entry.ts 527.1 — 0
node dist/entry.js + NODE_V8_COVERAGE 201.4 +18.9 0.38 MB
node --import tsx src/entry.ts + NODE_V8_COVERAGE 695.6 +168.5 2.16 MB

The loader attachment itself is free (rows 1–2), and coverage over compiled output is nearly free (+10%). Over transformed output it costs +84% and writes 5.7× the bytes. A full run therefore writes ~1000 profiles totalling ~2.1 GB, and merging them afterwards takes ~25 s.

Example. gen.js writes a small module graph — 200 files, ~5 lines each — behind one entry point. The shape that matters is that the entry has to load many sibling modules, the way a real CLI does.

// gen.js — writes src/entry.ts plus 200 modules it imports
const fs = require("fs");
const path = require("path");

const N = 200;
fs.rmSync("src", { recursive: true, force: true });
fs.mkdirSync("src");

let imp = "";
let call = "";
for (let i = 0; i < N; i++) {
  const name = "m" + String(i).padStart(3, "0");
  fs.writeFileSync(path.join("src", name + ".ts"),
    `export function h${i}(x: string): string {\n` +
    `  const y = x.trim().toLowerCase();\n` +
    `  if (!y) return "${name}";\n` +
    `  return "${name}:" + y;\n}\n`);
  imp += `import { h${i} } from "./${name}";\n`;
  call += `out.push(h${i}(arg));\n`;
}

fs.writeFileSync(path.join("src", "entry.ts"),
  imp +
  `\nconst arg = process.argv[2] || "--version";\n` +
  `const out: string[] = [];\n` +
  call +
  `\nconsole.log(out.length, "modules loaded");\n`);
// tsconfig.json
{
  "compilerOptions": {
    "target": "es2022", "module": "commonjs",
    "rootDir": "src", "outDir": "dist", "esModuleInterop": true
  },
  "include": ["src/**/*"]
}
# package.json must be { "type": "commonjs" } (or omitted) for this to load at all
npm i -D tsx typescript
node gen.js && npx tsc

rm -rf cov-js cov-tsx
NODE_V8_COVERAGE="$PWD/cov-js"  node dist/entry.js            --version
NODE_V8_COVERAGE="$PWD/cov-tsx" node --import tsx src/entry.ts --version
du -sh cov-js cov-tsx          # <-- the gap in question

What I am asking*

Is the size and the precision of V8 block coverage inherently a function of how much transformed source a process loads, or is there a supported way to reduce what NODE_V8_COVERAGE records per process — a flag, a granularity setting, a way to scope it? I can find nothing in the Node docs about profile size or cost, and c8's options (--temp-directory, --exclude-after-remap, --merge-async) all act after the bytes are on disk.

Context on why "just precompile it" is not a free answer for me: if the parent process loads source through tsx while children run the tsc build, the merged report combines two transform lineages for the same file and function coverage collapses (94% → 87%, below my gate). Compiling both sides into one tree removes the cost and lifts function coverage to ~98%, so I may have found the answer already and be asking whether the runtime offers something direct. Say the word and I will add a minimal repo.

Searched and ruled out (so the usual answers can be skipped):

  • NODE_COMPILE_CACHE / module.enableCompileCache() — measured slower on both paths (tsx child 527 → 624 ms, plain CJS child 190 → 301 ms), and the docs warn coverage is less precise for functions deserialized from the cache and recommend disabling it while testing.
  • node --experimental-strip-types — my package is "type": "commonjs", so a .ts entry using import syntax fails to load ("Failed to load the ES module… set "type": "module"").
  • --test-isolation=none — my suites monkey-patch fs and console globally, so files interfere; also slower, because concurrency becomes 1.
  • --experimental-test-coverage (native, no c8) — no --all equivalent before v26.7, so it printed all files | 100.00 for a run where c8 with --all said 72.14%. Same profile volume, same speed. (Posting separately, since it is a different question.)
  • Raising --test-concurrency — pool efficiency falls from ~85% at 4 workers to ~52% at 8; the children are latency-bound, not CPU-bound.

Minimal Reproduction

No response

Output

No response

Before You Submit

  • I have looked for issues that already exist before submitting this
  • My issue follows the guidelines in the README file, and follows the 'How to ask a good question' guide at https://stackoverflow.com/help/how-to-ask

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions