Node.js

node_compile_cacheとは?Node.js起動を約45%短縮するコンパイルキャッシュの設定と実測

node_compile_cacheは、Node.jsがモジュールをコンパイルした結果であるV8コードキャッシュをディスクに残し、次の起動でコンパイルをやり直さないようにする仕組みです。環境変数NODE_COMPILE_CACHEを1つ設定するだけで、アプリケーションのコードを1行も変えずに有効化できます。この記事では、Node.js v26.5.0で実際に測定した起動時間、指定してもエラーにならず黙って無視されるオプション名、プロジェクトを移動するとキャッシュが作り直しになる条件を、公式ドキュメントと実行結果の両方で確認します。

まとめ:node_compile_cacheの要点

先に結論をまとめます。有効化は環境変数1つで済み、効果は起動のたびに効きます。

  • 有効化はNODE_COMPILE_CACHE=/path/to/cacheを設定するだけ。アプリ側のコード変更は不要。
  • TypeScript 5.9.3(8.7MBの単一ファイル)をrequireする検証では、起動が平均893msから平均487msへ短縮した(Node.js v26.5.0・macOS・x64・12回の中央値を3セット、44〜48%短縮)。
  • 環境変数はv22.1.0、JavaScript APIはv22.8.0で追加され、v25.4.0で実験的機能の表示が外れた。
  • キャッシュキーには対象モジュールの絶対パスが使われるため、既定ではプロジェクトを移動した時点でキャッシュが無効になる。portableを有効にすると、キャッシュディレクトリから見た相対配置が同じである限り再利用できる。
  • テストのコードカバレッジが不正確になる副作用があり、テスト実行時はNODE_DISABLE_COMPILE_CACHE=1で切る。

以下、2通りの有効化方法から、実測値とキャッシュが無効になる条件までを順に見ていきます。

コンパイルキャッシュの仕組みと対応バージョン

V8コードキャッシュをディスクへ持ち越す仕組み

Node.jsはモジュールを読み込むたびに、そのソースをV8でコンパイルします。コンパイルキャッシュを有効にすると、V8が生成したコードキャッシュを指定ディレクトリへ保存し、次回以降は同じ内容のモジュールに対してコンパイルを省略します。Node.js公式ドキュメントのModule compile cache節は対象をCommonJS・ECMAScriptモジュール・TypeScriptモジュールと明記しており、形式による使い分けは不要です。

初回はキャッシュの生成分だけわずかに遅くなり、2回目以降の同じモジュールグラフの読み込みが速くなる、という非対称な効果になります。したがって1回しか起動しないスクリプトには向きません。効くのは繰り返し起動されるCLI・開発サーバー・CI上のツール・サーバーレスのコールドスタートです。V8そのものの実行単位はV8 Isolateとは?仕組み・Contextとの違い・サーバーレスでの活用を徹底解説で扱っています。

v22.1.0からv25.4.0までの追加・変更の系譜

この機能は一度に完成したものではなく、版ごとに部品が足されてきました。解説記事によって「v22.1.0の機能」とするものと「v22.8.0の新機能」とするものがあるのは、環境変数とJavaScript APIで追加時期が違うためです。

追加・変更
v22.1.0 環境変数NODE_COMPILE_CACHEを追加
v22.8.0 JavaScript APIとNODE_DISABLE_COMPILE_CACHEを追加
v22.10.0 / v23.0.0 flushCompileCache()を追加
v24.12.0 / v25.0.0 portableオプションを追加
v25.4.0 実験的(experimental)の表示を解除

安定性を重視するならv25.4.0以降を前提にするのが安全ですが、v22系のLTSでも環境変数による有効化自体は問題なく動きます。版ごとの全体像はNode.js 26の新機能・変更点とLTSスケジュール総まとめにまとめています。

環境変数NODE_COMPILE_CACHEでの有効化とディレクトリ構造

コードを変えずに有効化する手順

もっとも導入コストが低い方法が環境変数です。キャッシュを置くディレクトリを指定して実行するだけで、アプリケーション側には手を入れません。

export NODE_COMPILE_CACHE=/tmp/node-cache
node app.js

有効になったかどうかは、実行中のNode.jsが使っているキャッシュディレクトリを返すmodule.getCompileCacheDir()で判定できます。無効ならundefinedが返ります。

$ NODE_COMPILE_CACHE=/tmp/node-cache node -e "console.log(require('node:module').getCompileCacheDir())"
/tmp/node-cache/v26.5.0-x64-8d7ad2ee-501

重要なのは、module.enableCompileCache()を呼んでいないのに値が返っている点です。環境変数を設定した時点で有効になるため、API呼び出しは必須ではありません。

バージョンごとに分離されるキャッシュディレクトリ

指定したディレクトリの直下にはv26.5.0-x64-8d7ad2ee-501のような名前のサブディレクトリが作られ、その中にモジュール1件につき1ファイルが置かれます。名前にNode.jsのバージョンとアーキテクチャが含まれているとおり、公式ドキュメントは「あるバージョンのNode.jsが生成したコンパイルキャッシュは別のバージョンでは再利用できない」「同じベースディレクトリを使えば別々に保存されるので共存できる」と記述しています。なお公式はディスク上のレイアウトを実装詳細と位置づけ、依存すべきでないと明記しているため、ここで示す構造は挙動の理解のための観察結果として扱ってください。

つまりNode.jsを上げてもキャッシュが壊れて誤動作することはなく、新しいディレクトリが増えるだけです。ただし古い版のキャッシュは自動では消えません。ディレクトリをos.tmpdir()配下に置くことがドキュメントで推奨されているのはこのためです。版を頻繁に切り替える環境についてはNodebrewとは?Node.jsのバージョン管理・インストール・切り替えを入門解説で扱っています。

module.enableCompileCache()の書き方と戻り値statusの読み分け

変数名moduleを使うとSyntaxErrorで落ちる

コードから制御したい場合はnode:moduleenableCompileCache()を呼びます。このとき、CommonJSで次のように書くと実行できません。

const module = require('module');
module.enableCompileCache();

CommonJSのモジュールはmoduleという名前の引数を持つ関数でラップされて実行されるため、同じ名前をconstで宣言すると衝突します。Node.js v26.5.0で実行すると次のエラーで停止します。

SyntaxError: Identifier 'module' has already been declared

受け側の名前を変えるか、必要な関数だけを取り出せば解決します。呼ぶ位置はエントリポイントの先頭が原則です。あとから呼んでも、既に読み込み済みのモジュールはキャッシュ対象になりません。

const { enableCompileCache } = require('node:module');
console.log(enableCompileCache());
// { status: 1, directory: '/var/folders/.../T/node-compile-cache' }

statusの4値とALREADY_ENABLEDの意味

戻り値のstatusmodule.constants.compileCacheStatusの定数で、有効化に成功したかどうかを数値で返します。例外を投げないため、確認するにはこの値を読む必要があります。

status 定数 意味
0 FAILED 権限不足やファイルシステムのエラーで有効化できない
1 ENABLED 有効化に成功
2 ALREADY_ENABLED 環境変数や先行する呼び出しで既に有効
3 DISABLED NODE_DISABLE_COMPILE_CACHE=1で無効化されている

0のときはmessageに理由が、1と2のときはdirectoryに保存先が入ります。環境変数を設定した状態でAPIを呼ぶと2が返り、環境変数側の指定が優先されるため、両方を設定しても衝突しません。起動ログにstatusdirectoryを出しておくと、キャッシュが効いていない状態に気づけます。

存在しないオプション名はエラーにならず無視される

引数には文字列かオブジェクトを渡せます。オブジェクトで保存先を指定するキーはdirectoryです。cacheDirpathという名前は受け付けません。問題は、間違った名前を渡してもエラーにならないことです。次の2つは別々のプロセスで実行した結果です(同一プロセス内で2回呼ぶと、2回目は先の呼び出しによってALREADY_ENABLEDになり保存先も変わりません)。

$ node -e "console.log(require('node:module').enableCompileCache({ cacheDir: '/tmp/mycache' }))"
{ status: 1, directory: '/var/folders/.../T/node-compile-cache' }   ← 指定は無視され既定の場所

$ node -e "console.log(require('node:module').enableCompileCache({ directory: '/tmp/mycache' }))"
{ status: 1, directory: '/tmp/mycache' }                            ← 反映される

statusは成功を示す1が返るため、ログを見ても失敗に見えません。キャッシュは既定のos.tmpdir()配下へ書かれ続けます。指定したディレクトリをDockerイメージやCIのキャッシュ対象にしている場合、保存先が想定と違うのでキャッシュは毎回空になります。オプションで指定したときは、戻り値のdirectoryが指定どおりかを必ず確認してください。なお引数を文字列で渡した場合はoptions.directoryとして扱われるので、enableCompileCache('/tmp/mycache')でも同じ結果になります。なお上の出力は、実際にはパスが長く折り返されるものを1行に整形しています。

起動時間の実測(平均893ms→平均487ms)

TypeScript 5.9.3を12回起動した測定条件と中央値

効果の大きさはモジュールの量に依存します。8.7MBの単一ファイルであるTypeScript 5.9.3のtypescript.jsrequireするだけのプロセスを子プロセスとして12回起動し、その中央値を比較しました。環境はNode.js v26.5.0、macOS(darwin 25.6.0)、x64です。

条件 1セット目 2セット目 3セット目 平均
キャッシュ無効 879.4ms 903.7ms 896.2ms 893.1ms
キャッシュ有効 457.5ms 503.9ms 500.0ms 487.1ms

3セットとも44〜48%の短縮で、生成されたキャッシュは2.4MBでした。この測定に使ったスクリプトは次のとおりです。手元の依存関係に置き換えれば、同じ手順で自分のプロジェクトの効果を測れます。

// bench.mjs として保存して node bench.mjs で実行
import { spawnSync } from 'node:child_process';
const N = 12;
function run(env) {
  const t = [];
  for (let i = 0; i < N; i++) {
    const s = process.hrtime.bigint();
    spawnSync(process.execPath, ['-e', "require('typescript')"],
      { env: { ...process.env, ...env }, stdio: 'ignore' });
    t.push(Number(process.hrtime.bigint() - s) / 1e6);
  }
  t.sort((a, b) => a - b);
  return t[N >> 1].toFixed(1);
}
console.log('disabled:', run({ NODE_DISABLE_COMPILE_CACHE: '1' }));
console.log('enabled :', run({ NODE_COMPILE_CACHE: '/tmp/bench-cache' }));

効果が出る構成と出ない構成

短縮されるのはコンパイルにかかる時間だけです。導入前に、依存を読み込むだけで数百ミリ秒かかっているかを測ってください。そこが軽ければ入れても変わりません。

構成 効果 理由
依存の重いCLIツール 実行のたびに全依存をコンパイルするため
CIで繰り返す型チェック・Lint 同じ依存を毎ジョブ読み込むため
サーバーレスのコールドスタート キャッシュを永続化できる構成なら効く
常駐するWebサーバー 起動は1回で、その後は実行時間が支配的
バンドル済みの単一ファイル コンパイル対象がすでに減っている

また、モジュールの内容が変わればキャッシュは作り直しになります。編集のたびに再起動する開発中のアプリで効くのは、変更しない依存パッケージの分だけです。ビルド側のキャッシュについてはNext.js 16.3とは?Turbopack永続キャッシュとInstant Navigationsなど新機能を実装者目線で解説が参考になります。

portableオプションとキャッシュが無効になる3つの条件

既定では絶対パスがキーになる

ここが実運用でもっとも見落とされる点です。公式ドキュメントは「既定では、キャッシュされるモジュールの絶対パスが変わるとキャッシュは無効になる」と明記しています。NODE_DEBUG_NATIVE=COMPILE_CACHEを付けると、モジュールごとにどのキャッシュファイルを読もうとしたかが出力されるため、この挙動を直接確認できます。

同じ内容のプロジェクトを/tmp/qAから/tmp/qBへ移して同じキャッシュディレクトリを使うと、参照するファイル名が変わってキャッシュミスになります(以下、パスとハッシュの一部を省略して表示しています)。

[compile cache] reading cache from .../9f195bd9 for CommonJS /private/tmp/qA/node_modules/typescript/lib/typescript.js...[...]... success, size=2552928
[compile cache] reading cache from .../562d40d4 for CommonJS /private/tmp/qB/node_modules/typescript/lib/typescript.js... no such file or directory

キャッシュを保存しているのに毎回遅い場合は、まずこのパス不一致を疑ってください。

portableが効く条件はキャッシュディレクトリからの相対配置

この対策がportableです。ただし「どこへ移しても効く」わけではありません。ドキュメントの条件は「キャッシュディレクトリから見たレイアウトが同じである限り」であり、あくまでベストエフォートと明記されています。Node.jsがキャッシュディレクトリからの相対位置を計算できないモジュールは、キャッシュされません。

実際に、キャッシュをプロジェクト内(./.ncc)に置いてプロジェクトごと移動した場合と、キャッシュをプロジェクト外に置いてプロジェクトだけ移動した場合では結果が分かれます。Node.js v26.5.0で確認した結果が次のとおりです。

キャッシュの置き場所 portable プロジェクト移動後
プロジェクト内(.ncc) なし ミス(再コンパイル)
プロジェクト内(.ncc) あり ヒット
プロジェクト外(/tmp配下) あり ミス(再コンパイル)

移動に耐えさせたいなら、キャッシュディレクトリをプロジェクトツリーの内側に置いたうえでportableを有効にする、という組み合わせが必要です。

export NODE_COMPILE_CACHE=./.ncc
export NODE_COMPILE_CACHE_PORTABLE=1
node app.js

V8のフラグを変えるとキャッシュが分裂する

3つ目の条件は、ドキュメントに明示されていないものです。キャッシュのサブディレクトリ名にはNode.jsのバージョンとアーキテクチャに加えてハッシュ値が入っており、V8の挙動を変えるフラグを付けると、この部分が変わって別のディレクトリが使われます。同じマシン・同じNode.jsでも、起動オプションが違えばキャッシュは共有されません。

$ NODE_COMPILE_CACHE=/tmp/flagcache node -e "console.log(require('node:module').getCompileCacheDir())"
/tmp/flagcache/v26.5.0-x64-8d7ad2ee-501

$ NODE_OPTIONS=--jitless NODE_COMPILE_CACHE=/tmp/flagcache node -e "console.log(require('node:module').getCompileCacheDir())"
/tmp/flagcache/v26.5.0-x64-7c91e0b7-501

すべてのフラグが影響するわけではなく、--max-old-space-size=4096のようにヒープサイズを変えるだけのオプションではディレクトリ名は変わりませんでした。実務で問題になるのは、開発時と本番、あるいはCIのジョブ間でNODE_OPTIONSの中身が違うケースです。キャッシュを共有する構成では、起動オプションも揃えてください。

CI・Dockerでの置き場所とビルド時ウォームアップ

以上を踏まえると、置き場所の判断は用途で分かれます。ローカル開発や単一マシンでの常駐プロセスなら、既定のos.tmpdir()配下のままで十分です。キャッシュをジョブ間で持ち回るCIでは、リポジトリ配下の固定ディレクトリを指定してportableを有効にし、そのディレクトリをキャッシュ対象にします。GitHub Actionsでビルド・自動テストを設定する方法|CI/CDワークフローの作り方で扱っているキャッシュ機構と組み合わせる形です。

Dockerでは、イメージのビルド時に一度モジュールを読み込ませてキャッシュを作り、それをイメージに含める方法が使えます。コンテナ内の絶対パスはビルド時と実行時で同じなので、portableを使わなくても再利用できるためです。

ENV NODE_COMPILE_CACHE=/app/.ncc
RUN node warmup.js

ここでwarmup.jsには、重い依存をrequireするだけの数行を書きます。サーバーのエントリポイントをそのまま読み込ませると、ビルド中にサーバーが起動して終了せず、イメージのビルドが止まる点に注意してください。読み込むモジュールが実行時と一致していれば、実行時の最初の起動からキャッシュが効きます。逆に、マルチステージビルドでキャッシュを作った層を最終イメージにコピーし忘れると効果はありません。Node.js以外のランタイムを検討している場合の比較はBunとは?インストール(brew・curl)から使い方・コマンドまで解説|Node.jsに挑む高速JSランタイムにまとめています。

無効化・ヒット確認・キャッシュの掃除

テスト実行時はNODE_DISABLE_COMPILE_CACHE=1で切る

公式ドキュメントは制限事項として、コンパイルキャッシュから復元された関数ではV8のコードカバレッジが不正確になる可能性を挙げ、テスト実行時は無効にすることを推奨しています。カバレッジのしきい値をCIで判定している場合、原因不明の数値変動としてここが効いてくるため、テストのジョブでは明示的に切ってください。

$ NODE_DISABLE_COMPILE_CACHE=1 node -e "console.log(require('node:module').enableCompileCache())"
{ status: 3, message: 'Disabled by NODE_DISABLE_COMPILE_CACHE' }

この環境変数はAPIによる有効化よりも強く、コード側でenableCompileCache()を呼んでいてもstatusは3になります。アプリのコードを変更せずに切れるため、切り分け作業にも使えます。なおコンパイルキャッシュ本体はv25.4.0で実験的の表示が外れましたが、この無効化用の環境変数だけはv26.5.0時点でも安定性の区分が「Active Development」のままです。将来仕様が変わる可能性は残ります。

NODE_DEBUG_NATIVEでヒットとミスを1行ずつ確認する

キャッシュが効いているかを推測で判断せずに済むのがNODE_DEBUG_NATIVE=COMPILE_CACHEです。読み込み対象のモジュールごとに、対応するキャッシュファイルを読めたか(success)読めなかったか(no such file or directory)が出力されます。

$ NODE_DEBUG_NATIVE=COMPILE_CACHE NODE_COMPILE_CACHE=./.ncc node app.cjs
[compile cache] resolved path ./.ncc + v26.5.0-x64-8d7ad2ee-501 -> /private/tmp/pub/.ncc/v26.5.0-x64-8d7ad2ee-501
[compile cache] reading cache from .../c837bdb8 for CommonJS .../typescript.js...[...]... success, size=2552952
[compile cache] V8 code cache for CommonJS .../typescript.js was accepted, keeping the in-memory entry

初回はミス、2回目以降はsuccessに変わります。2回目でもミスのままなら、原因はパスの変化かモジュール自体の更新です。ESMの場合はキーがfile://から始まるURLとして表示されるため、どの形式のモジュールがキャッシュされたかもここで判別できます。

掃除はディレクトリごと削除、共有はflushCompileCache()

キャッシュの掃除に専用のコマンドはありません。公式ドキュメントの指示は「キャッシュディレクトリを削除するだけでよい」で、次に同じディレクトリが使われたときに再作成されます。

もう1つ知っておきたいのが書き込みのタイミングです。コードキャッシュはモジュールの読み込み直後にメモリ上で作られますが、ディスクへの書き出しはNode.jsプロセスが終了する直前です(公式は現時点の実装と断ったうえで、変更の可能性があるとしています)。そのため、親プロセスが動き続けたまま子プロセスにキャッシュを共有させたい場合は、module.flushCompileCache()を呼んで先に書き出す必要があります。ビルドツールのように子プロセスを多数起動する構成では、この呼び出しの有無で効果が変わります。

よくある質問

node_compile_cacheはどのNode.jsバージョンから使えますか?

環境変数NODE_COMPILE_CACHEはv22.1.0から、module.enableCompileCache()などのJavaScript APIはv22.8.0から使えます。解説によって導入版が食い違って見えるのはこのためです。v25.4.0で実験的機能の表示が外れました。portableオプションはv24.12.0とv25.0.0で追加された新しい機能なので、それより前の版では利用できません。

環境変数とmodule.enableCompileCache()はどちらを使うべきですか?

既存アプリへ後から入れるなら環境変数が確実です。コードを変更せずに済み、実行環境ごとにオンオフを切り替えられる利点もあります。配布するCLIツールのように、利用者に環境変数を設定してもらえない場合はAPIをエントリポイントの先頭で呼んでください。両方を設定しても衝突せず、statusが2になって環境変数の指定が優先されます。

キャッシュはどこに保存されますか?

ディレクトリを指定しない場合はpath.join(os.tmpdir(), 'node-compile-cache')です。その直下にNode.jsのバージョンとアーキテクチャを含む名前のサブディレクトリが作られ、モジュール1件につき1ファイルが保存されます。実行中のプロセスが使っている場所はmodule.getCompileCacheDir()で取得できます。

Node.jsをバージョンアップすると古いキャッシュはどうなりますか?

別バージョンのキャッシュは再利用されず、新しいバージョン用のサブディレクトリが新たに作られます。古いディレクトリは自動削除されないため残り続けます。壊れて誤動作することはありませんが、ディスクを消費するのでos.tmpdir()配下に置くか、不要になった時点でディレクトリごと削除してください。

キャッシュを有効にしたのに起動が速くなりません。何を確認すべきですか?

まずNODE_DEBUG_NATIVE=COMPILE_CACHEを付けて2回実行し、2回目もミスになっていないかを見ます。ミスが続く場合の原因は、プロジェクトのパスが実行のたびに変わっている(CIの作業ディレクトリなど)、モジュール自体が更新されている、起動時のNODE_OPTIONSが実行ごとに違う、オプション名を間違えて保存先が既定の場所になっている、のいずれかが典型です。enableCompileCache()の戻り値のdirectoryが意図した場所かどうかも併せて確認してください。

関連記事

資料請求

RELATED POSTS 関連記事