Node.js Builds on GitHub Actions Without Cold Caches

Cache the npm, pnpm, or yarn store with WarpBuilds/cache keyed on the lockfile hash, then size warp- runners from 4 to 16 vCPU for installs and tests.

Last verified:

To cache node modules on GitHub Actions, save the package manager's download store between jobs, key it on the hash of the lockfile, and let npm ci, pnpm install, or yarn install rebuild node_modules from that warm store. On WarpBuild runners, the WarpBuilds/cache action is a drop-in replacement for actions/cache@v4 and stores the entries in WarpBuild's cache backend, which is enabled by default on all Linux runners.

Caching fixes the install phase. The rest of a Node.js pipeline, typecheck, bundle, and tests, is bounded by CPU and memory, which makes it a runner sizing decision. This page covers the workflow configuration for npm, pnpm, and yarn, the sizing call between 4, 8, and 16 vCPU runners for each phase, the four bottlenecks that dominate Node.js pipelines, and the cost math against GitHub-hosted runner list prices.

Overview

Every GitHub Actions job starts on a fresh virtual machine, and nothing from the previous run survives. For a Node.js repository that means node_modules is empty at the start of every job, and the install step downloads every package in the lockfile from the registry before any test or build can begin.

Two different directories can be cached, and the right one is the store, the package manager's own download cache. npm keeps compressed tarballs in ~/.npm, pnpm keeps a content-addressed store you can locate with pnpm store path, and yarn keeps its cache where yarn cache dir points. With a warm store, npm ci still rebuilds node_modules from scratch, but it reads tarballs from local disk instead of the registry, and pnpm install links files out of the store without copying them at all.

Caching node_modules itself is the tempting shortcut, and it fails in practice. npm ci deletes the directory before installing, so the restored bytes are thrown away. A restored node_modules is also welded to the Node version and platform that produced it, so a toolchain bump silently breaks native modules. The store has neither problem: it is content addressed, and the install command decides what to link out of it on each run.

For Node.js work the Linux x64 sizes from 4 to 16 vCPU cover almost every repository, and moving a job onto them is a one-line change to runs-on. One caveat from the caching documentation: WarpBuild Cache is not supported on Windows runners, so keep Node.js cache steps on Linux jobs.

One boundary before the configuration. This page covers single-package Node.js repositories: one lockfile, one install, one test suite. If your repository is a monorepo driven by a task graph, remote caching of task outputs is a different problem with different tools, covered on the Turborepo on GitHub Actions and Nx on GitHub Actions pages.

Configuration

The explicit form uses WarpBuilds/cache to save and restore the store directly. This workflow installs, typechecks, builds, and tests a pnpm project on an 8 vCPU WarpBuild runner:

name: node-ci
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: warp-ubuntu-latest-x64-8x
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Resolve pnpm store path
        id: pnpm-store
        run: echo "path=$(pnpm store path)" >> "$GITHUB_OUTPUT"

      - name: Restore pnpm store
        uses: WarpBuilds/cache@v1
        with:
          path: ${{ steps.pnpm-store.outputs.path }}
          key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-pnpm-

      - run: pnpm install --frozen-lockfile
      - run: pnpm run typecheck
      - run: pnpm run build
      - run: pnpm run test

The key hashes pnpm-lock.yaml, so the cache rolls exactly when the dependency set changes. The restore-keys prefix is what keeps a dependency bump cheap: when the exact key misses, the action restores the most recent cache with the same prefix, and the install only fetches the packages that actually changed. --frozen-lockfile makes the install fail instead of silently rewriting the lockfile, which keeps the key honest.

For npm the shorter form is WarpBuilds/setup-node, a drop-in replacement for actions/setup-node that routes its dependency caching through WarpBuild Cache. From v6, npm caching turns on automatically when the packageManager field in package.json is set to npm:

- uses: WarpBuilds/setup-node@v6
  with:
    node-version: 22
    cache: npm

The cache input accepts npm, yarn, or pnpm, and cache-dependency-path points the key at a lockfile outside the repository root. The full input list is in the setup actions documentation.

Two behaviors from the caching documentation matter before you rely on either form. The cache is scoped to key, version, and branch, so a cache saved on a feature branch is separate from the one on main; seed the cache from main and let branch builds restore it through restore-keys. Entries expire 7 days after last use, so an active repository stays warm and an abandoned branch stops costing storage on its own.

Cache usage is metered, and the rates are small enough to state exactly: storage is $0.20 per GB-month and each write or restore operation is $0.0001. A Node.js repository with a 1.5GB store and 4,000 cache operations a month adds about $0.70 to the bill.

Sizing

WarpBuild Linux x64 runners scale from 2 to 32 vCPU with 4GB of memory per core. The three sizes that matter for Node.js are 4x, 8x, and 16x:

Runner labelvCPUMemoryStoragePrice per minute
warp-ubuntu-latest-x64-4x416GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664GB150GB SSD$0.032

Size each phase for what it can actually use:

Install is network and disk bound. With a warm store it is mostly disk work, and extra cores change little beyond 4 vCPU. If install dominates your pipeline, the fix is the cache configuration above, and a bigger runner is the wrong lever.

Typecheck runs tsc on a single core, so more vCPUs do nothing for it. Memory is the constraint that bites: the type graph of a large program can push the process past the default heap. The 16GB on 4x covers most projects; move to 8x for 32GB when tsc dies with heap errors rather than reaching for NODE_OPTIONS=--max-old-space-size on a machine that cannot back it.

Bundle depends on the tool. esbuild and swc parallelize across every core they see, so they reward 8x and 16x on large inputs. webpack and Rollup run the module graph on one thread and only parallelize minification, so their ceiling is closer to 4 or 8 vCPU. If the bundle step is the long pole, check whether minification is the slow part before buying cores for a single-threaded graph walk.

Jest and Vitest scale with cores. Jest defaults --maxWorkers to the core count minus one, and Vitest sizes its worker pool from available cores, so a suite with many independent test files keeps 8 or 16 cores busy. This is the phase where 16x earns its rate on a single job, and where sharding across several 4x runners is the alternative, covered under bottlenecks below.

A useful calibration step: run the workflow once on 8x and watch utilization per step. If only the test phase holds all 8 cores, split the pipeline so install and typecheck run on 4x at $0.008 per minute and only the test job pays for 8x.

Worked cost model

GitHub publishes per-minute list prices for its hosted runners at github.com/pricing and on the GitHub Actions minute multipliers reference. Checked on 2026-08-13, the standard ubuntu-latest runner for private repositories (2 vCPU) is $0.006 per minute, and larger Linux runners are $0.012 for 4 vCPU, $0.022 for 8 vCPU, and $0.042 for 16 vCPU.

MachinevCPUPer-minute rateSource
GitHub-hosted standard Linux2$0.006GitHub list price, checked 2026-08-13
GitHub-hosted Linux larger runner4$0.012GitHub list price, checked 2026-08-13
warp-ubuntu-latest-x64-2x2$0.004WarpBuild pricing
warp-ubuntu-latest-x64-4x4$0.008WarpBuild pricing

Take a concrete team: a TypeScript service running 2,000 jobs a month, averaging 5 minutes per job on a 4 vCPU machine with a warm store. That is 10,000 runner minutes a month.

ScenarioRateMonthly minutesMonthly cost
GitHub-hosted Linux larger runner, 4 vCPU$0.012/min10,000$120.00
warp-ubuntu-latest-x64-4x, 4 vCPU$0.008/min10,000$80.00
Cache storage and operations (1.5GB, 4,000 ops)see aboven/a$0.70

Stated as list-price arithmetic: warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13.

Full rates for every size and platform are on the pricing page.

Bottlenecks

Four bottlenecks account for most slow Node.js pipelines on GitHub Actions.

Cold node_modules. Without a restored store, every job downloads the full dependency tree from the registry before the first test runs, and a lockfile with a few thousand entries turns that into minutes of network time per job. The fix is the cache configuration above. Verify it worked from the install log: a warm pnpm install reports most packages as reused from the store, and a warm npm ci finishes without a long download phase.

Lockfile-hash key churn. Keying on hashFiles('**/pnpm-lock.yaml') means every dependency bump produces a key that has never been seen. Without restore-keys, each automated dependency update pull request starts from a completely cold store, which is precisely the moment the cache matters most. The prefix fallback restores the previous store so the install fetches only the changed packages. Watch for the same churn from keys that hash package.json instead of the lockfile, since scripts and metadata edits then invalidate a perfectly good cache. If cache entries keep disappearing on GitHub-hosted infrastructure instead, the repository is likely hitting eviction, which the GitHub Actions cache size limit guide covers.

Native module rebuilds. Packages such as sharp, better-sqlite3, and node-gyp based addons ship prebuilt binaries per platform and Node ABI. When a prebuilt binary exists, install just downloads it; when it does not, node-gyp compiles C++ at install time, and that compile repeats on every cold install. Pin the Node version in the workflow so the ABI stays stable and the prebuilt path keeps hitting. When a compile is unavoidable, it parallelizes well, which is one more reason install-heavy jobs with native dependencies belong on 8x rather than the smallest runner.

Single-shard test suites. A suite that runs as one serial job has a wall time equal to its total test time, no matter how large the runner. Jest and Vitest both accept a --shard flag, so the suite splits across a matrix and each shard restores the same store cache:

  test:
    runs-on: warp-ubuntu-latest-x64-4x
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: WarpBuilds/setup-node@v6
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx vitest run --shard=${{ matrix.shard }}/4

Four shards on 4x spend roughly the same runner minutes as one job doing all the work, while the wall time drops to the slowest shard. Total minutes rise slightly because each shard repeats checkout and install, which is another reason the warm store matters.

When a job is slow and the cause is unclear, WarpBuild's CI observability shows OpenTelemetry-based system metrics from the runner agent correlated with GitHub Actions job logs, which separates a CPU-bound test phase from an install stuck on the network. For interactive debugging, the Action Debugger pauses a workflow and opens an SSH session on the runner, so you can run pnpm store path and inspect the restored directories on the machine itself. Broader workflow-level tactics are collected in the guide on speeding up GitHub Actions.

Proof

Public OSS repositories running warp- labels are citable evidence: open a repository's workflow file and read the runs-on line yourself. The Trigger.dev CLI end-to-end suite runs its matrix on warp-ubuntu-latest-x64-4x and warp-windows-latest-x64-8x, which you can read in triggerdotdev/trigger.dev's e2e.yml (checked on 2026-08-13).

The same store-plus-sizing approach extends to monorepos, where task-graph output caching takes over; the Turborepo on GitHub Actions page walks those decisions.

FAQ

Should I cache node_modules or the package manager store?

Cache the store. npm ci deletes node_modules before every install, so caching that directory buys nothing, and a cached node_modules breaks when the Node version or platform changes. The store is content addressed and survives both.

What should the cache key hash for npm, pnpm, and yarn?

The lockfile: package-lock.json for npm, pnpm-lock.yaml for pnpm, yarn.lock for yarn. Add a restore-keys prefix so a dependency bump restores the previous store instead of starting cold.

What runner size do Jest or Vitest shards need?

One warp-ubuntu-latest-x64-4x per shard covers most suites, since both runners scale workers with the vCPU count. Move a shard to 8x when tests spawn browsers or databases that need the extra memory.

What changes when a Node.js workflow moves to WarpBuild?

One line per job: the runs-on label.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.