VSCode で TypeScript のファイルを保存すると遅いと思ったら犯人は tsserver

目次

保存するたびに一瞬固まる

VSCode で TypeScript のファイルを保存すると、フォーマットが走るまでに妙な間がある。

毎回ではない。ファイルによっては一瞬で終わるのに、ときどき数秒待たされる。

同時に、変数にホバーしても「読み込み中…」のまま型が出てこないことがある。しばらく待つと表示される。

犯人は tsserver、TypeScript の言語サーバーがメモリを食い切って落ちていた。

問題は「ファイル数」ではなく「1ファイルあたりのコスト」にある。

CLI 実行 対象: 全ファイル (数百〜数千) ボトルネック: ファイル数 –cache が効く 保存時フォーマット 対象: 1ファイルのみ ボトルネック: 1件あたりの重さ –cache は無関係 1ファイルあたりを重くする要因 ・プラグインの読み込み (tailwindcss / organize-imports など) ・TypeScript の解析 = tsserver

ホバーの「読み込み中」

ホバー時のツールチップを出しているのは Prettier ではない。tsserver だ。「読み込み中…」で止まるのは、tsserver がリクエストに返事できていない状態を意味する。

つまり tsserver が重い。

tsc を叩いたら落ちた

エディタ越しに調べるより、CLI で直接測る方が早い。

npx tsc --noEmit --extendedDiagnostics

--extendedDiagnostics を付けると、読み込んだファイル数、型の生成数、チェックにかかった時間、使用メモリが出力される。

このとき、コマンドが完走せず OOMErrorHandler を含むスタックトレースを吐いて停止した。ヒープ不足だ。

Node のデフォルトのヒープ上限は環境にもよるがおおむね 2〜4GB。tsc がそれを超えたということは、同じプロジェクトを読む tsserver も常に上限ギリギリで動いていることになる。

保存時フォーマットもホバーも「たまに」遅かった理由がこれで説明できる。たまたま重い処理が重なった瞬間に GC が走り、その間すべてが止まっていた。断続的に固まるのは、慢性的なメモリ逼迫の典型的な症状だ。

ファイル数を数える、型チェックを飛ばす

OOM で落ちるなら診断もできない。そこで型チェックを行わず、ファイル解決だけを実行する。

npx tsc --noEmit --listFilesOnly > files.txt

--listFilesOnly は依存を辿ってコンパイル対象を確定させるところまでで止まる。型チェックをしないのでメモリをほとんど使わず、落ちているプロジェクトでも完走する。

結果を数える。

wc -l files.txt
grep -c node_modules files.txt

実際の値はこうだった。

項目ファイル数
合計4031
node_modules 配下3694
自前コード約 337

337 ファイル。小規模と言っていい。

この数字が切り分けを一気に進める。TypeScript がメモリを使い切る原因は大きく二つ、ファイル数の多さか、型の複雑さかだ。数万ファイル規模なら前者を疑う。337 で落ちるなら後者しかない。

pnpm を使っている場合は pnpm exec tsc ... に読み替える。モノレポでは実行するパッケージのディレクトリに cd してから叩かないと、意図しない tsconfig.json が拾われる。

ファイル数と型の複雑さは別の問題

ここは混同しやすいので分けて考えたい。

ファイル数が多い場合、症状は「起動が遅い」「初回のチェックが長い」になる。原因は include が広すぎる、exclude を書いていないので distcoverage を拾っている、types を明示していないので node_modules/@types 配下を全部読んでいる、といったところだ。

一方、型の複雑さが原因の場合、ファイル数は少ないのにメモリと時間だけが伸びる。深い条件型、再帰的な型、巨大な union、テンプレートリテラル型の組み合わせなどで、型の展開回数が指数的に増える。--extendedDiagnosticsInstantiations が数百万を超えていたら、まずこれを疑う。

tsserver が重い:原因の分岐 tsc が OOM で停止 ファイル数が多い 数千〜数万ファイル 型が複雑 ファイル数は少ない ・include が広すぎる ・exclude 未設定 (dist 等) ・types 未指定で @types 全読み ・深い条件型 / 再帰型 ・巨大な union ・Instantiations が数百万

依存パッケージ側の型定義が原因の場合もある。

読み込まれている型定義の内訳は files.txt を集計すれば見えるが、パスの構造次第で集計コマンドが空振りすることがある。深追いする前に、次の切り分けを先にやった方がいい。

まず落ちない状態を作る

原因の特定には tsc を完走させる必要がある。落ちたままでは何も測れない。順序としてヒープ上限の引き上げが先に来る。

NODE_OPTIONS=--max-old-space-size=8192 pnpm exec tsc --noEmit

これで通れば、VSCode 側にも同じ設定を入れる。settings.json に一行。

"typescript.tsserver.maxTsServerMemory": 8192

保存してウィンドウをリロードすれば、ホバーの「読み込み中」も保存時フォーマットの遅さも解消する。

maxTsServerMemory は VSCode の正規の設定項目で、大規模プロジェクトでは引き上げが推奨されている。裏技ではない。

ただし対症療法であることは自覚しておきたい。自前 337 ファイルでデフォルトを超えるのは本来おかしい。数週間後にまた落ちるなら、型が増え続けているサインだ。

犯人ファイルを特定する

完走するようになったら、改めて計測する。

npx tsc --noEmit --extendedDiagnostics

見るべき数値は次の通り。

項目意味目安
Files読み込んだファイル数数千を超えると重い
Instantiations型の展開回数数百万超なら型が犯人
Check time型チェックの所要時間ここが支配的なら型側
Memory used使用メモリ上限に近ければ危険

Check time が突出しているなら、トレースを取ってファイル単位まで絞り込める。

npx tsc --noEmit --generateTrace ./trace
npx @typescript/analyze-trace ./trace

analyze-trace は「どのファイルの、どの型のチェックに何ミリ秒かかったか」をホットスポット順に出力する。ここまで来れば修正対象は具体的なコード行として特定できる。

なお VSCode 側のログを直接読む手もある。

"typescript.tsserver.log": "verbose"

リロード後にコマンドパレットから TypeScript: Open TS Server Log を開くと、Project '/path/tsconfig.json' (Configured) の直後に Files (1234) という行が出る。ただしログは有効な間ずっとディスクに書き込まれ続け、それ自体が動作を遅くする。調査が終わったら必ず off に戻すこと。

切り分けの順序

同じ症状に出会ったときのために、辿った道筋を残しておく。

  1. エディタが遅いのか CLI が遅いのかを分ける。CLI だけなら --cache とファイル数の問題、エディタなら 1 ファイルあたりのコストの問題
  2. ホバーも同時に遅いか確認する。遅ければ Prettier ではなく tsserver
  3. tsc --noEmit --extendedDiagnostics を叩く。ここで OOM が出れば原因は確定
  4. 落ちるなら --listFilesOnly でファイル数だけ数え、ファイル数の問題か型の問題かを判定する
  5. ヒープ上限を上げて完走させ、--generateTrace で犯人を特定する

Prettier の設定をいくら見直しても改善しなかったのは、そもそも見る場所が間違っていたからだった。フォーマッタが遅いという症状は、TypeScript プロジェクトの健康状態を映す鏡でもある。

目次