Repository navigation
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
Description
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 questionWhat 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.tsentry usingimportsyntax fails to load ("Failed to load the ES module… set"type": "module"").--test-isolation=none— my suites monkey-patchfsandconsoleglobally, so files interfere; also slower, because concurrency becomes 1.--experimental-test-coverage(native, no c8) — no--allequivalent before v26.7, so it printedall files | 100.00for a run where c8 with--allsaid 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