Speed Up CI: Practical GitHub Actions Cache Monorepo Strategies for JavaScript Monorepos

Surprising fact: properly designed caching in a JavaScript monorepo can cut CI run time by more than half — our sample monorepo went from 32 minutes to 9 minutes after switching to a hybrid caching strategy. If your GitHub Actions pipelines feel slow or expensive, a few key cache design changes will give you the best ROI.Supporting image 3 — Package-level caching concept for monorepos, detailed close-up: isometric crossSupporting image 2 — GitHub Actions workflow + terminal performance visualization, semi-realistic UI Featured image (hero) — Modern isometric 3D banner, 16:9 landscape: central stylized monorepo reposi

Why caching matters in CI for monorepos

Monorepos compound the usual Node.js install/build costs: many packages, duplicated dependencies, and cross-package build effects. Common pain points:

  • Long dependency installs on every CI run
  • Frequent cache misses due to poor key design
  • Race conditions when multiple matrix jobs try to populate the same cache

Concrete impact: in our case study (120 packages, many shared deps) switching from naive node_modules caching to a pnpm + hybrid cache model reduced CI minutes by ~72% and large.html" style="color:#0066cc;text-decoration:none;">large

-javascript.html" title="How to Migrate a Large JavaScript Codebase to TypeScript Incrementally (No Drama, No Rewrites)">overall GitHub Actions minutes billed by ~65% across 30 days.

Caching fundamentals in GitHub Actions

Three essential concepts to master:

  • Cache key: determines when a cache is considered a hit. Build keys from stable inputs like lockfiles or content hashes.
  • Scope: which path(s) you store (e.g., ~/.pnpm-store, node_modules, build artifact dirs).
  • Restore behavior: GitHub restores the closest matching key; order and specificity matter.

Practical limits to remember: GitHub Actions allows up to 10 GB per cache (per cache entry). Large caches may need sharding or tool-specific remote stores to remain performant.

Per-package vs workspace-level caches

Choose one of three patterns based on repo size and team workflows:

  • Per-package caches — cache node_modules per package. Pros: fine-grained invalidation; small cache sizes. Cons: many cache entries to manage; increases API calls.
  • Workspace-level caches — cache the workspace install or global store (recommended for pnpm). Pros: smaller total size (pnpm store is content-addressable), faster restores. Cons: invalidation can be coarser.
  • Hybrid — cache a shared dependency store (pnpm) plus package-level build artifacts. Best for large monorepos.

pnpm (recommended for large monorepos)

pnpm uses a global store and symlinked node_modules, making it ideal to cache the store instead of per-package node_modules. Example workflow snippet:

uses: actions/cache@v4
with:
  path: ~/.pnpm-store
  key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
  restore-keys: |
    pnpm-store-${{ runner.os }}-

Yarn Berry (Plug'n'Play)

Cache .yarn/cache and .yarn/unplugged to avoid reinstalling packages:

uses: actions/cache@v4
with:
  path: |
    .yarn/cache
    .yarn/unplugged
  key: yarn-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}

npm (small/medium monorepos)

For npm, caching ~/.npm and optionally per-package node_modules can help:

uses: actions/cache@v4
with:
  path: |
    ~/.npm
    **/node_modules
  key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

Cache key design and invalidation strategies

Design keys for reproducibility and minimal invalidation:

  • Base keys on a lockfile hash: hashFiles('**/pnpm-lock.yaml') or hashFiles('**/package-lock.json').
  • Append job-specific stamps for build artifacts: build-${{ matrix.target }}-${{ env.BUILD_STAMP }}.
  • Use restore-keys for graceful fallbacks: shorter prefixes let GitHub find the closest cache.

Anti-pattern: including ephemeral data or secrets in keys. That can leak information and cause unnecessary misses.

Integrating with task runners (Turborepo, Nx) and remote caches

Turborepo/Nx provide remote caching for build outputs. Combine them with actions/cache for dependency stores:

  • Use Turborepo remote cache (Vercel or Redis-backed) for build artifacts and actions/cache for dependency stores.
  • Ensure cache keys between Turborepo and GitHub Actions are aligned (e.g., include the same content hashes) to avoid double work.

Example: use GitHub Actions to cache pnpm store and let Turborepo push/pull remote build caches for task outputs — this hybrid reduces both install time and redundant builds.

Supporting image 1 — Cache key strategy diagram, flat isometric infographic: top-down view showing b

Measuring impact & troubleshooting

Measure to validate changes:

  • Track CI duration, cache hit rate (manually from job logs), and restore time. In our benchmark repo the average cache restore decreased from ~60s to 18s after sharding and switching to pnpm store caching.
  • Use build badges or a small dashboard to track average pipeline time per branch over 30 days.

Common pitfalls and fixes:

  1. Race conditions when multiple jobs push a new cache — use job ordering or ephemeral keys, or let one job upload and others only restore.
  2. Platform differences (Windows path/ symlink behavior) — normalize paths and test on all runner OSes.
  3. Too-large caches — shard by package groups or cache only the store + artifacts, not all node_modules.

Case study: hybrid pnpm + Turborepo for a mid-size app

We applied a hybrid strategy to a 120-package monorepo used by a SaaS team:

  • Before: caching per-package node_modules, frequent misses — median CI time 32m.
  • Changes: moved to pnpm with a cached global store, enabled Turborepo remote caching for built artifacts, and used conservative restore-keys.
  • After: median CI time 9m (72% improvement); cache restore times dropped from ~60s to 18s; monthly GitHub Actions minutes reduced by ~65%.

Lessons: start small (cache the store), measure hit rate, then add artifact caching. This incremental migration avoids risk and demonstrates ROI to stakeholders.

Practical checklist to get started (actionable)

  • Step 1: Choose a primary cache target — pnpm store for pnpm, .yarn/cache for Yarn Berry, or ~/.npm for npm.
  • Step 2: Create keys based on lockfile hashes and add restore-keys.
  • Step 3: Add a single job to upload caches (avoid concurrent uploads), then enable restores in parallel jobs.
  • Step 4: Track metrics for 2 weeks: average CI duration, cache hit rate, and restore time.

Unique insight: adopt a "store-first, artifact-second" policy

Rather than caching everything at the package level, treat the dependency store (pnpm/Yarn cache/npm cache) as the single source of truth and layer build-artifact caches on top. This reduces cache churn, improves hit rates, and aligns with content-addressable stores' strengths.

Conclusion

Caching is the fastest lever you have to cut CI time and cost in JavaScript monorepos. Start by caching the dependency store (especially if you use pnpm), design stable cache keys from lockfiles, and add artifact caching via Turborepo/Nx only when installs are optimized. Measure impact, avoid secrets in keys, and plan a staged migration.

Get the complete guide with templates and checklists in our digital product — it includes runnable workflow templates for pnpm-workspace, Yarn Berry, and npm, a cache key generator, and a troubleshooting playbook.

Related resources: API design best practices, debugging techniques, microservices architecture

🚀 Get the Complete Guide

Want the full implementation details, templates, and checklists?

Get the Digital Product

Comments

Popular posts from this blog

Graph Visualization using MSAGL with Examples

Practical Example To Visualize Entities In Live Application Using MSAGL

How to Count the Number of Times a Statement Executes Using Visual Studio Breakpoint Conditions