How can I speed up Node.js builds in GitHub Actions?

Run the workflow on faster GitHub Actions runners managed by Depot, cache package manager downloads, reuse build output, and parallelize independent tests. Most Node.js workflows can start with a one-line runner change.

Last updated 2026-09-09

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.04

The 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 build

The 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.json

On 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 build

Turborepo 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

  1. Switch the job to a Depot GitHub Actions runner and record the new baseline.
  2. Enable actions/setup-node caching with the correct lockfile path.
  3. Use immutable installs so CI always matches the committed dependency graph.
  4. Add task-level remote caching for builds, tests, and linting when the tool supports it.
  5. Split independent checks and long test suites across parallel jobs.
  6. 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.

Start building with Depot
in minutes