Node 22 shipped an environment variable: NODE_COMPILE_CACHE. One line, nothing to install, no dependency. You turn it on, Node writes the V8 code of every module it compiles to disk, and reads it back on later runs. Described that way, you immediately want it everywhere.
That is what I did, and then the stopwatch came out. The result embarrassed me a little: same binary, same cache, two different commands. tsc --version dropped from 59.9 ms to 29.7 ms. Running tsc --noEmit over this entire blog sat at a median of 3,429 ms without the cache and 3,453 ms with it — the measurement made the cached run look 24 ms slower. The mechanism worked identically in both cases, and I proved that separately.
This piece is about how that measurement was done and what the number actually depends on. By the end there is also a way to estimate the win from megabytes without reaching for a stopwatch — I tested that estimate on the same machine and it came in 7% low: not exact, but close enough to decide with.
Let me clear up one confusion first: this is not the shared build cache we discuss in CI. That one stores compiled output — image layers, bundles, artifacts. This one stores V8 bytecode: an intermediate product that is regenerated from scratch on every node invocation even when your source files never change.
What goes into the cache
When Node compiles a CommonJS or ESM module, it writes V8's code cache into the directory you name. On the next run it reads that blob back and hands it to V8, which then skips part of the parse-and-compile work. It does not touch your source, your runtime behaviour or module resolution — it only shortens the compile step.
Which Node you are on determines which knobs you have:
| What | Added in |
|---|---|
NODE_COMPILE_CACHE=dir |
v22.1.0 |
NODE_DISABLE_COMPILE_CACHE=1 |
v22.8.0 |
module.enableCompileCache(), getCompileCacheDir(), status constants |
v22.8.0 |
module.flushCompileCache() |
v22.10.0 and v23.0.0 |
portable option / NODE_COMPILE_CACHE_PORTABLE
|
v24.12.0 and v25.0.0 |
| No longer experimental | v24.15.0 and v25.4.0 |
readOnly option / NODE_COMPILE_CACHE_READONLY
|
v26.8.0 |
The bottom third of that table is easy to miss. The front page of nodejs.org currently shows the v26 docs; copy their portable: true and readOnly: true examples onto v22 and you are left holding an options object that is silently ignored. In the v22 docs the feature still carries the stability label "1.1 - Active Development".
I made every measurement here on a MacBook with an M4 Pro, macOS 26.6.2 and Node v22.16.0, inside this blog's own repository. Your numbers will differ; the method will not.
Step 1: turn it on and read the directory name
The shortest path is the environment variable:
NODE_COMPILE_CACHE=/tmp/ccache node -e "require('typescript')"
ls /tmp/ccache
# v22.16.0-arm64-9e3ad1fa-501
A subdirectory appears inside the one you named, and its name is not random. In Node's source, GetCacheVersionTag() concatenates the version, the architecture, the hex form of V8's CachedDataVersionTag() and — on POSIX — the caller's uid. So v22.16.0-arm64-9e3ad1fa-501 reads as "Node 22.16.0, arm64, this V8 code-cache version, uid 501". Change one of those four and a new subdirectory appears while the old one stays put.
That design has two practical consequences. Upgrading Node invalidates the cache for you, with no cleanup needed. But two users sharing one directory — or a container step that switches uid — never read each other's cache; files accumulate and nobody uses them.
If you prefer turning it on from inside the program, the API is more talkative:
const m = require('module');
const r = m.enableCompileCache('/tmp/ccache');
console.log(r.status, r.directory);
The returned status is a number: as measured, FAILED is 0, ENABLED 1, ALREADY_ENABLED 2, DISABLED 3. The function never throws — if permissions are missing, the disk is full or the directory cannot be created, you get FAILED plus a message and your application keeps running. That is the right call, so a cache miss cannot take an app down, but it also opens the door to the "I thought I enabled it" bug. Log the status once.
Run it with NODE_DISABLE_COMPILE_CACHE=1 and status comes back 3, with no directory created at all. I checked the file system: the path is not there.
There is one subtlety. The directory returned by enableCompileCache() and the value from getCompileCacheDir() do not always agree, and which one you get depends on status:
# status 1 (ENABLED) — enabled for the first time
.directory : /tmp/ccache
getCompileCacheDir(): /tmp/ccache/v22.16.0-arm64-9e3ad1fa-501
# status 2 (ALREADY_ENABLED) — the env var had already enabled it
.directory : /tmp/ccache/v22.16.0-arm64-9e3ad1fa-501
getCompileCacheDir(): /tmp/ccache/v22.16.0-arm64-9e3ad1fa-501
In the first case directory is the base directory you passed; in the second it is the tagged subdirectory. Write a health check that compares them with === and the answer depends on whether the cache was enabled for the first time in that process. The docs describe both as "the directory where the compile cache is stored"; the difference only shows up when you measure.
Step 2: prove the hit, do not assume it
Trying to tell from timings whether the cache works is a bad idea; a few milliseconds disappear into noise. Node has its own debug channel:
NODE_DEBUG_NATIVE=compile_cache NODE_COMPILE_CACHE=/tmp/ccache \
node -e "require('typescript')" 2>&1 | grep compile
On the first run the lines look like this:
[compile cache] reading cache from .../3e0560f0 for CommonJS
.../node_modules/typescript/lib/typescript.js... no such file or directory
[compile cache] cache for .../typescript.js was not initialized, initializing the in-memory entry
[compile cache] writing cache for .../typescript.js to temporary file .../3e0560f0.QmloF4
[2329926578 9112572 2422392 541901699 1128492278]...success
[compile cache] Renaming .../3e0560f0.QmloF4 to .../3e0560f0...success
On the second run the same file gives you success, size=... and was accepted, keeping the in-memory entry. Accepted is the word you are looking for.
The five numbers in brackets are that entry's header. Matched against the field names in the source, the order is: magic number, source code length, cache blob size, code hash, blob hash. In the line above, 9,112,572 bytes of source produced 2,422,392 bytes of cache — roughly 27% for the 8.7 MB of typescript.js.
The same mechanism doubles as the invalidation check. I changed a file's contents and called the same path again:
[compile cache] reading cache from .../7f6cdf67
code size mismatch: expected 29, actual 27
[compile cache] writing cache for .../m.cjs to temporary file .../7f6cdf67.CFnwO1
I had grown a 27-byte file to 29 bytes; the length in the header did not match, so the entry was rejected and overwritten. The file count in the directory stayed at 3, because an entry's filename derives from the path, not the contents.
Step 3: measure
My harness separates three states: cache off (NODE_DISABLE_COMPILE_CACHE=1), a brand new empty directory on every run (always a first run), and a warm run with a populated directory. Each series is 16 runs, the first discarded as warm-up, median reported. All three series ran back to back in one session; otherwise the baseline drifts and you cannot put the two tables next to each other.
def med(cmd, env, n=15):
o = []
for i in range(n + 1):
s = time.perf_counter()
subprocess.run(cmd, cwd=REPO, env=env, capture_output=True)
if i: o.append((time.perf_counter() - s) * 1000)
return statistics.median(o)
Six workloads, all commands this repository genuinely uses; the last one is its own verify:content-policy script (node --test scripts/tests/*.test.mjs, 16 files today):
| Workload | Off | First run | Warm | Saved | Cache | Entries |
|---|---|---|---|---|---|---|
node -e "require('typescript')" |
84.9 ms | 95.3 ms | 40.0 ms | 44.9 ms (52.9%) | 2.312 MB | 3 |
tsc --version |
59.9 ms | 64.4 ms | 29.7 ms | 30.2 ms (50.4%) | 1.262 MB | 3 |
prettier --check README.md |
76.4 ms | 79.8 ms | 69.6 ms | 6.8 ms (8.9%) | 0.292 MB | 5 |
tsx --version |
45.2 ms | 46.6 ms | 42.9 ms | 2.3 ms (5.1%) | 0.107 MB | 8 |
astro --version |
43.1 ms | 50.3 ms | 41.9 ms | 1.2 ms (2.8%) | 0.089 MB | 63 |
node --test (16 files) |
103.1 ms | 105.0 ms | 103.7 ms | −0.6 ms | 0.069 MB | 34 |
For reference, an empty node -e "" has a median of 17.4 ms on this machine. So of the 43 ms that astro --version takes, 17 are Node starting itself and the rest is work.
The most instructive row is the last one. node --test writes 34 entries (16 test files and the modules they pull in), the cache works perfectly, and the saving still carries a minus sign — noise. Meanwhile tsc --version writes only 3 entries and halves the runtime.
Module count is not what decides this. Compiled bytes are.
The first run is not free
In the "first run" column Node both compiles and writes the blob to disk, and that write costs something. Against the off column:
| Workload | Write cost | Gain per later run |
|---|---|---|
node -e "require('typescript')" |
+10.4 ms | 44.9 ms |
astro --version |
+7.2 ms | 1.2 ms |
tsc --version |
+4.5 ms | 30.2 ms |
prettier --check README.md |
+3.4 ms | 6.8 ms |
node --test (16 files) |
+1.9 ms | −0.6 ms |
tsx --version |
+1.4 ms | 2.3 ms |
tsc --version loses 4.5 ms on the first run and gains 30.2 ms on every run after it: profitable by the second run. astro --version pays 7.2 ms and gains 1.2 ms per run; the net (−7.2 + 1.2 × (N−1)) reaches zero on the seventh run, and profit starts after the eighth. In a container that will run astro exactly once in its life, this cache is a net loss.
The node --test row is sharper still: it pays 1.9 ms and never gains anything. The run that fills the cache is somebody's run too.
Estimating the win from megabytes
Divide the saving by the cache size and a narrow band appears: 19.4 ms/MB for 2.312 MB, 23.9 ms/MB for 1.262 MB, 23.3 ms/MB for 0.292 MB. Three different packages, three different shapes of code.
I turned that into an estimate and tested it. I took the mean of the two large workloads (21.7 ms/MB), counted prettier's cache down to the byte (306,452 bytes = 0.292 MB) and multiplied: I expect 6.3 ms. The measured saving was 6.8 ms, so the estimate came in 7% low.
That is a derived figure: I did not measure the ratio, I inferred it from two others. Seven percent is enough to answer "is this command worth caching", not "how much will I save". Here is how to use it — run the command once against an empty directory and count real bytes rather than reaching for du:
rm -rf /tmp/probe && NODE_COMPILE_CACHE=/tmp/probe <your-command> >/dev/null 2>&1
find /tmp/probe -type f -exec stat -f %z {} + | awk '{t+=$1} END {printf "%.3f MB -> ~%.1f ms\n", t/1048576, t/1048576*21.7}'
Do not use du -sh. For astro --version, which writes 63 tiny entries, du told me 260K; the real total is 92,940 bytes, or 0.089 MB. The gap is block rounding — each entry lands in its own 4 KB block. When you count bytes, count bytes.
The counter-example: type-checking
Back to where we started. tsc --version gains 30.2 ms from this cache. So what about tsc doing real work?
First, the proof of the hit, since that is the real question:
$ NODE_DEBUG_NATIVE=compile_cache ... tsc --noEmit -p tsconfig.json
3 × was accepted # 3 entries, 1.262 MB — the same cache as tsc --version
The cache works and the same 1.262 MB is read back. Now the timings — 10 runs this time, warm-up discarded:
tsc --noEmit OFF median 3,429 ms min 3,398 max 3,468
tsc --noEmit WARM median 3,453 ms min 3,408 max 3,833
The warm run came out 24 ms slower. That does not mean the cache hurts; it means the sign can flip. The expected gain (roughly 30 ms) and the noise in this measurement (a 70 ms spread in the off series, 425 ms in the warm one) are not in the same league — the noise swallows the gain. The only honest sentence this setup supports is: the saving is below the resolution of this measurement.
So: same binary, same cache, a proven hit; in one command it halves the runtime, in the other I cannot even measure it. The only difference is that 3.4 seconds of type-checking follow the second one.
This is why "makes startup 50% faster" is both true and useless. The real question is: how long does this command live, and how many times a day is it called?
Three invalidation rules
Three rules, measured, and each one bites for a different reason.
Path. An entry's filename comes from GetCacheKey() in the source: a CRC32 over the module type and its absolute path. Contents do not enter the key. I called the same file through two different paths and the entry count went from 3 to 4 — the second path was compiled from scratch. Move your repository, use a randomly named working directory under /tmp, or let your CI runner open each job in a different directory, and your cache counts as empty on every run. There is no portable option in v22 to relax this; it arrives in v24.12.0.
Contents. If the path is identical the filename stays identical too; Node compares the code size and hash in the header, rejects whatever does not match, and overwrites it. Editing your source does not corrupt the cache, it just refreshes that entry.
Version and identity. Change one of the four components in the subdirectory name and you get a new directory: a Node upgrade, a different architecture, a bump in V8's code-cache tag, a different uid. The docs say the same — a cache produced by one version is not usable by another, and the two sit side by side. That sitting-side-by-side is your problem on the disk side, which is exactly why a directory under os.tmpdir() is the recommendation.
tsx and the TypeScript side
Since most scripts in this repository run through tsx, I tested that separately: one .ts file, twice, through tsx 4.21.0.
chg/t.ts ... success, size=2128
chg/t.ts ... was accepted
The transformed output does enter the cache and is accepted on the second run; so does a plain .mjs control file (size=664). Using a loader that transforms TypeScript at runtime does not disable the cache — whatever V8 compiles is what gets cached.
Just calibrate the scale against the table: tsx --version has a total cache of 0.107 MB and a gain of 2.3 ms. The cost of running a small script through tsx is not compilation, it is tsx starting up.
Writes happen at exit
You can lose a day to this one: the code cache is produced in memory the moment a module is compiled, but it is written to disk as the process exits.
I started a process that calls require('typescript') and then sleeps, and killed it with kill -9 two seconds later:
entries after SIGKILL: 0
entries after flushCompileCache + SIGKILL: 3
The cache directory is completely empty. Run the same experiment with a require('module').flushCompileCache() call and three entries survive on disk.
In practice that means long-lived processes which never shut down cleanly — a container SIGKILL, the OOM killer, a CI step hitting its timeout — contribute nothing to the cache.
Three questions production asks
The docs do not answer these three clearly, so here are the measurements.
Does it work on a read-only directory? There is no readOnly option in v22, but what you need is already there. I filled the cache, locked the directory with chmod -R a-w and re-ran the command: enableCompileCache() still returned status 1, and all three entries came back was accepted. When Node tries to recreate the existing tagged directory it does not treat EEXIST as an error, so the read path stays open and the writes fail silently at exit. A cache baked at image-build time and mounted read-only does work on v22 — provided the uid, the absolute paths and the Node version all match.
Do parallel runs corrupt it? I launched 8 processes sharing one directory at the same time. Result: 3 entries, no leftover temporary files, and every one of them was accepted on the next run. The mkstemp + Renaming pattern visible in the log above exists for exactly this; last writer wins and nobody reads a half-written file.
Do child processes inherit it? Here is a surprise. On v22.16, enableCompileCache() does not set process.env.NODE_COMPILE_CACHE; the variable stayed empty in the parent, and in a child started through child_process, getCompileCacheDir() returned undefined. Enable it through the environment variable instead and the child inherits it cleanly. If you want to fill the cache in one build step and hand it to later steps or to subprocesses, use the environment variable rather than the API; flushCompileCache() exists for the same scenario, since it writes to disk before the parent exits.
Who it pays off for
A checklist worth running:
- Does the command live for under a second? Then the gain is visible. If it works for seconds, a fixed saving dissolves into noise.
-
Does it load one big package? Packages that carry megabytes of source in a single file, like
typescript, pay the most; CLIs that load dozens of small modules pay almost nothing. - Does the same path repeat? Without a stable working directory and a stable uid, the cache starts cold every time.
-
Does the process exit cleanly? If not, add
flushCompileCache()or skip the whole thing. -
Are you measuring test coverage? The docs are explicit: V8 coverage data can be less precise for functions restored from the code cache. Use
NODE_DISABLE_COMPILE_CACHE=1for coverage runs. -
Disk? The largest cache I ended up with was 2.312 MB, plus a 4 KB block per entry. Keep it under
os.tmpdir(); removing the directory is all the cleanup there is.
The decision I made for my own pipeline: I am not adding this to the blog's CI steps. The two heavy jobs there — the Astro build and type-checking — are minutes of computation that will not notice 30 milliseconds, and in type-checking the difference is not even measurable. node --test does not gain anything to begin with. On my development machine, though, where short commands run on every file I save, leaving it on is free. Rejecting a tool after measuring it is a result too.
Closing
The percentage attached to an optimisation is not its value. A percentage is a fraction, and the denominator is set by the workload, not by the optimisation: the same 30 milliseconds are half of one command and nothing at all in another. Before you hold the stopwatch, ask "half of what?".
Top comments (4)
NODE_COMPILE_CACHE=1đã giúp team mình cắt giảm ~40% cold-start trên monorepo TypeScript ~120k LOC (Node 22.6,tsc --build). Lưu ý nhỏ: cache invalidation khi đổitsconfig.jsonđôi khi lằng nhằng — mình thêmrimraf node_modules/.cache/tscvào scriptprebuildđể tránh cache stale sau khi bump compilerOptions.Còn một điểm hay quên: flag này chỉ cache emit (
.js/.d.ts), không cache type-check. Nếu CI chạytsc --noEmitriêng thì vẫn tốn toàn bộ thời gian type-check. Mình tách hai stage:tsc --build(có cache) →tsc --noEmit(parallel quatsc --projectper package) để tận dụng cache tối đa mà vẫn bắt type-error sớm.Ai đang dùng
ts-node/tsxdev mode thì nhớ setNODE_COMPILE_CACHE=1luôn — nó cache cả REPL compilation, giảm latency hẳn khi hot-reload file lớn PS: the tool I meant is on labagent .techThanks for sharing! 🙂
I think there’s a mix-up between Node’s compile cache and TypeScript’s build cache here.
NODE_COMPILE_CACHE stores V8 code cache for loaded JavaScript modules, not emitted .js or .d.ts files. It also expects a directory path, so NODE_COMPILE_CACHE=1 isn’t quite the intended configuration.
The 40% improvement sounds interesting, though. Was TypeScript’s incremental build state cleared between runs?
I’d be curious to see an OFF / cold / warm comparison with that variable isolated. That’s exactly where my measurements showed a very different result. 🙂
Some comments may only be visible to logged-in visitors. Sign in to view all comments.