The fastest way to speed up a Node.js build in GitHub Actions is to run it on a GitHub Actions runner managed by Depot, enable lockfile-based dependency caching, and stop repeating build and test work that can be reused or run in parallel.
Start by changing the runner label:
jobs:
build:
- runs-on: ubuntu-24.04
+ runs-on: depot-ubuntu-24.04The runners are up to 3x faster than standard GitHub-hosted runners. They also include an in-memory disk
accelerator and automatically route actions/setup-node and actions/cache through Depot Cache, with transfer speeds
up to 1000 MiB/s. That matters for Node.js because installing and extracting thousands of small files into
node_modules can saturate both the cache connection and the runner disk.
In our Next.js runner benchmark, the build step fell from 105.6 seconds on a GitHub-hosted runner to 61.7 seconds on an equivalent 2 vCPU Depot GitHub Actions runner. Switching the runner didn't require changing the application or build command.
A fast Node.js workflow
This is a practical starting point for a pnpm project:
name: Node CI
on:
pull_request:
push:
branches: [main]
jobs:
build:
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
cache-dependency-path: 'pnpm-lock.yaml'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm test
- run: pnpm buildThe same pattern works with npm or Yarn. Set cache to the package manager you use, commit its lockfile, and use the
immutable install command for that package manager:
- npm:
npm ci - pnpm:
pnpm install --frozen-lockfile - Yarn:
yarn install --immutable
actions/setup-node caches the package manager's download store. It doesn't cache node_modules. That's usually the
right tradeoff. Restoring a large node_modules archive can spend more time downloading and extracting files than a
clean install from a warm package store, and a stale archive is harder to reason about.
For a monorepo with more than one lockfile, include every lockfile that should invalidate the cache:
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: |
package-lock.json
apps/*/package-lock.jsonOn Depot GitHub Actions runners, this standard configuration automatically uses Depot Cache for GitHub Actions. No custom cache action is required.
Reuse build output, not just dependencies
Dependency caching avoids downloading the same packages. It doesn't stop TypeScript, tests, bundlers, or framework builds from doing the same work again.
For a monorepo, use the remote cache built into the task runner. The runners are preconfigured to connect Turborepo to Depot Cache, so a normal command can reuse outputs produced by an earlier CI run or a developer workstation:
- run: pnpm turbo run lint test buildTurborepo hashes the inputs for each task. When those inputs haven't changed, it restores the previous result instead of rerunning the task. This is much more precise than caching an entire working directory.
The same principle applies without Turborepo. Cache outputs only when the cache key captures every input that can change the result. At a minimum, that normally includes the lockfile, source files, relevant configuration, Node.js version, and operating system.
Run independent work in parallel
A lot of Node.js workflows make linting, type checking, unit tests, and builds wait in one long job even when they don't depend on one another.
Split independent work into jobs after the dependency cache is warm:
jobs:
checks:
name: ${{ matrix.command }}
runs-on: depot-ubuntu-24.04
strategy:
fail-fast: false
matrix:
command: [lint, typecheck, test, build]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm ${{ matrix.command }}Depot has no runner concurrency limit, so these jobs can start together instead of waiting for GitHub-hosted runner capacity. Parallelism reduces wall-clock time, but it can increase total compute. Use it for work on the critical path, not every command by default.
For a large test suite, split the tests rather than creating identical jobs that all run the complete suite. Depot can split tests using historical timing data so each shard receives a similar amount of work. That avoids one slow shard deciding the duration of the entire workflow.
Match the runner to the bottleneck
More CPU only helps when the build tool can use it. Before moving every job to a larger machine, check the CPU, memory, and step-level timing data in Depot. Use step timing and logs to investigate disk or network bottlenecks.
- Sustained CPU saturation during TypeScript compilation, tests, or bundling is a reason to try a larger runner.
- High memory usage and garbage collection pauses are a reason to increase memory or reduce worker count.
- Slow dependency restores with low CPU usage usually point to cache, network, or disk I/O.
- A long test step with idle resources usually needs test sharding or different test-runner concurrency.
- Repeated work across commits usually needs a task-level remote cache.
Depot Linux runners range from 2 to 64 vCPUs, so you can change runner size per job. Compare the elapsed time and cost of the real workflow. A 4 vCPU runner that finishes in half the time can be a better choice than a smaller runner, but only the workload can prove it.
The order that usually works
- Switch the job to a Depot GitHub Actions runner and record the new baseline.
- Enable
actions/setup-nodecaching with the correct lockfile path. - Use immutable installs so CI always matches the committed dependency graph.
- Add task-level remote caching for builds, tests, and linting when the tool supports it.
- Split independent checks and long test suites across parallel jobs.
- Use Depot's resource metrics to right-size each job.
Don't start by rewriting the entire workflow. Change one bottleneck at a time and compare queue time, dependency installation, build execution, test execution, and total cost before keeping the change.