48.3 Caching node_modules and tsbuildinfo in CI
Right, let’s talk about one of the most soul-crushing experiences in modern software development: watching your CI runner install the same node_modules directory for the 10,000th time. It’s a special kind of agony, watching those dependency trees resolve at a glacial pace, burning through your precious CI minutes and your team’s will to live. We’re not here to suffer; we’re here to automate. And a key part of automation is not doing work you’ve already done. That’s where caching comes in.
Think of your CI job as a petulant, amnesiac genius. It can do incredible things, but every time it starts, it’s a blank slate. It has no memory of the brilliant npm ci command you ran two minutes ago. Our job is to hand it a sticky note reminding it where it left its node_modules folder and its TypeScript build cache.
The Core Concept: Cache Keys and Paths
Every caching system, whether it’s GitHub Actions, GitLab CI, or CircleCI, operates on two fundamental concepts: a key and a path. The path is simple: it’s the directory or file you want to save and restore. For us, that’s primarily node_modules/ and any .tsbuildinfo files.
The key is the clever part. It’s a unique identifier for this specific version of the thing you’re caching. You don’t want to restore a cache from typescript@4.9 when you’re now on 5.3; that way lies madness and incomprehensible type errors. The most robust key is usually a hash of the file that defines your dependencies: your package-lock.json or yarn.lock file. If the lockfile changes, you get a new cache. If it doesn’t, you get the old, pristine one.
Here’s the basic pattern for a GitHub Actions workflow:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # This is a nice built-in for node_modules!
- name: Restore TS Build Cache
uses: actions/cache/restore@v3
id: ts-cache
with:
path: |
**/.tsbuildinfo
# Also cache any other TS-incremental specific paths
key: ts-build-${{ hashFiles('**/tsconfig.json') }}-${{ hashFiles('**/*.ts') }}
- name: Install Dependencies
run: npm ci
- name: Build Project
run: npm run build
- name: Save TS Build Cache
uses: actions/cache/save@v3
with:
path: |
**/.tsbuildinfo
key: ts-build-${{ hashFiles('**/tsconfig.json') }}-${{ hashFiles('**/*.ts') }}
Notice the built-in actions/setup-node cache? It’s fantastic for node_modules and handles the lockfile hash keying for you. Use it. For the TypeScript cache, we have to be a bit more manual. The save/restore pattern is explicit.
Why tsbuildinfo is a Separate Game
Caching node_modules is about speed. Caching .tsbuildinfo is about correctness as much as speed. That file is TypeScript’s personal diary; it remembers what it already compiled so it can skip work on the next run. If you restore an outdated .tsbuildinfo file from a cache—say, one generated from an older commit—TypeScript will trust that outdated information and potentially skip compiling files it absolutely should compile. The result? Your CI build might be fast but completely wrong, missing new type errors or code changes.
This is why the key for the TS cache is more aggressive. I often key it not just on the tsconfig.json but also on a hash of the actual source files (**/*.ts). This is a more expensive key to compute, but it guarantees that if any source file changes, you get a new cache and a full rebuild. It’s the only way to be safe. You can get clever and use a two-level key (a “restore” key) to fall back to a cache that only matches your tsconfig.json if no exact match is found, but tread carefully.
The Pitfalls and “Well, Actually…” Moments
Here’s where I get direct about the rough edges. First, cache size. If you have a massive monorepo, your cache can become gigabytes large. Restoring a 5GB cache from remote storage isn’t free; it can sometimes be slower than a fresh npm ci. Profile this. Sometimes, a well-tuned npm ci with a warm npm cache is faster than restoring a massive archive.
Second, the package-lock.json checksum is your gospel. If your team has a bad habit of manually editing the lockfile (they shouldn’t) or you have a bug in your install process that generates different trees, your cache will be poisoned. The build will be fast and consistently wrong.
Finally, be paranoid. Your build should be hermetic. Never rely on the cache being there. The system can evict it at any time. The npm ci command must be able to run successfully from a clean slate. The cache is an optimization, not a dependency. This is why you see the npm ci step run after the cache restore—it verifies the integrity of the node_modules you just restored. If the cache is present, it’s lightning fast. If not, it does its job and creates a new one for next time.