Waiting for npm install or npm ci to finish is one of the biggest bottlenecks in modern web development. In a typical CI/CD pipeline, your environment starts from a clean slate every time you push code. This means fetching hundreds of megabytes of dependencies from the npm registry for every single build. If your project has a large dependency tree, this process can easily eat up three to five minutes of your total execution time.
You can eliminate this redundancy by using the GitHub Actions cache. By storing your node_modules directory (or your global npm cache) between runs, you ensure that your workflow only downloads packages when your package-lock.json actually changes. This guide provides the exact configuration needed to implement high-performance caching for Node.js projects.
TL;DR — Use the actions/cache plugin with a key based on runner.os and a hash of your package-lock.json. This allows your workflow to skip the download phase if dependencies haven't changed, reducing build times by up to 80%.
Understanding GitHub Actions Cache Concepts
The GitHub Actions cache is a key-value store specifically designed to persist files between workflow runs. In the context of Node.js, the most valuable thing to store is the node_modules folder or the ~/.npm global cache. Because GitHub runners are ephemeral, they are destroyed after each job. Without a caching strategy, every job starts with an empty disk, forcing a fresh network request for every package in your package.json.
The cache works by using a "cache key." If a key matches a previously stored entry, the runner downloads that compressed archive and extracts it to the specified path. If no match is found (a "cache miss"), the workflow proceeds as normal, and the runner saves a new cache entry at the end of the job for future use. GitHub provides up to 10GB of cache storage per repository for free, making this one of the most cost-effective ways to optimize DevOps costs.
When to Implement node_modules Caching
You should implement caching for any project where npm install takes more than 30 seconds. While small projects might not see a massive difference, professional-grade applications with frameworks like Next.js, Angular, or large monorepos (using Lerna or Turborepo) benefit immensely. I recently optimized a React project where the install step dropped from 2 minutes 45 seconds to just 18 seconds after implementing actions/cache@v4.
Another critical scenario is when you have high-frequency commits. If your team pushes code 50 times a day and each build runs for 10 minutes, you are consuming 500 minutes of GitHub Actions runner time. Reducing that by 3 minutes per run saves 150 minutes daily. This helps you stay within the free tier or reduces your monthly billing if you use self-hosted runners or paid minutes on private repositories.
How to Cache node_modules in GitHub Actions
Step 1: Use the actions/cache Step
The most reliable way to handle caching is the actions/cache action. You must place this step before your npm install command. The action looks for a match based on the key you provide. If it finds one, it restores the files to the path. If it doesn't, it allows the workflow to continue and then saves the path at the end of the job.
Step 2: Define your Workflow YAML
Create or edit your .github/workflows/main.yml file. In this example, we cache the global npm cache directory rather than the node_modules folder itself. This is often safer because it avoids issues with OS-specific binaries while still significantly speeding up the npm ci command.
name: Node.js CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache npm dependencies
uses: actions/cache@v4
id: npm-cache
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
Step 3: Analyze the Cache Key Components
The key is the most important part of this configuration. We use ${{ runner.os }} to ensure that caches created on Linux aren't used on Windows runners, which would cause binary compatibility errors. The hashFiles('**/package-lock.json') function generates a unique string based on the contents of your lockfile. If you add a new package, the hash changes, the key changes, and GitHub performs a fresh install to create a new cache. The restore-keys section acts as a fallback; if an exact match isn't found, it pulls the most recent cache starting with that prefix to speed up the partial download.
Common Pitfalls and Troubleshooting
node_modules folder directly when using native C++ modules (like node-sass or sharp). If you cache node_modules on one OS version and try to restore it on a different runner image, your app will crash with "Invalid ELF Header" or "Module not found" errors.
To fix OS-specific binary issues, always prefer caching the npm global cache (~/.npm) instead of the local node_modules folder. When you run npm ci, it will still need to extract the files into node_modules, but it will pull them from the local disk cache rather than the internet. This is the "best of both worlds" approach for stability and speed.
Another issue occurs when the cache grows too large. GitHub automatically evicts caches that haven't been accessed in 7 days, or if the total repository cache exceeds 10GB. If you notice your cache is frequently missing, check the "Actions" -> "Caches" tab in your GitHub UI. You might see hundreds of stale caches from old feature branches. GitHub manages this automatically, but manually deleting old branch caches can sometimes resolve specific "Cache limit reached" edge cases.
Performance Optimization Tips
If you want even faster results, consider using actions/setup-node's built-in caching. Since version 2, this action has a simplified syntax that handles the actions/cache logic under the hood. It is less customizable but much cleaner for standard projects. You simply add cache: 'npm' to your setup step.
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
Using npm ci instead of npm install is also a requirement for reliable CI. npm ci stands for "Clean Install." It requires a package-lock.json to exist and will delete your node_modules before starting to ensure a consistent environment. When combined with caching, npm ci becomes incredibly fast because it finds all required versions already sitting in the ~/.npm folder on the runner.
- Always use
package-lock.jsonas part of your cache key. - Prefer caching
~/.npmovernode_modulesfor better cross-platform compatibility. - Use
actions/setup-node@v4with thecache: 'npm'flag for the simplest implementation. - Check the "Caches" management page in GitHub Settings to monitor storage usage.
Frequently Asked Questions
Q. Why is my GitHub Actions cache not working?
A. This usually happens because of a key mismatch. Ensure you are using the exact path for your lockfile, especially in monorepos. If your package-lock.json is in a subfolder, use hashFiles('**/package-lock.json') or the specific path. Also, remember that caches are scoped to branches; a cache created on a feature branch is available to that branch and the base branch (like main), but not to sibling branches.
Q. How do I clear the GitHub Actions cache?
A. You can clear the cache via the GitHub Web UI by navigating to Actions > Caches. There, you can see a list of all active caches and delete them individually or in bulk. Alternatively, you can use the GitHub CLI (gh) with the command gh cache delete --all to wipe the storage for a specific repository.
Q. Is actions/setup-node's built-in caching better than actions/cache?
A. For most users, yes. It is easier to maintain and covers the 90% use case of caching the npm global directory. However, actions/cache is superior if you need to cache multiple directories (like node_modules and a .next/cache folder) or if you need to use complex logic to determine when the cache should be invalidated.
Fo
Post a Comment