Developer Tools

Speed Up Node.js CI: Cache Dependencies Without Shipping node_modules

Learn how to speed up Node.js CI and template deployments with reproducible installs, lockfile-aware dependency caching, and zero-build runtime patterns.

Table of Contents11 sections
Papers and a pen on a desk representing build inputs and deployment notes.
Text-free hero visual supporting Speed Up Node.js CI: Cache Dependencies Without Shipping node_modules.

Purging Generated Build Artifacts In Automated speed matters only when reproducibility survives the optimization.

A slow Node.js pipeline often creates the wrong optimization instinct: save node_modules somewhere and reuse it everywhere. For the repository boundary around deployment, see configuring automated repository access.

That can make one build look faster while quietly coupling future builds to an operating system, Node version, native binary, package-manager behavior, or stale dependency tree. The better goal is not to eliminate dependency installation at any cost. It is to make installation deterministic, cache-assisted, and proportional to what actually changed.

For most CI pipelines, the useful baseline is simple:

  1. commit the lockfile,
  2. use a clean install such as npm ci,
  3. cache the package manager’s download cache using a key derived from the lockfile and relevant runtime boundaries,
  4. still run the build and tests from a reproducible dependency graph.

For template-driven products, there is a second optimization that can be even larger: do not rebuild the application for every data-only variation. Build the template once when the code changes, then inject runtime configuration for each instance.

Those are two different problems. Treating them separately is what keeps the optimization safe.

First Decide What Is Actually Slow

Before adding a cache, split pipeline time into stages:

Stage Typical cost Best first question
Runner startup VM/container provisioning Can jobs be consolidated without hiding failures?
Dependency install Registry downloads + extraction Is the package-manager cache warm?
Build Bundling, transpilation, static generation Does this output really change for every deployment?
Test Unit/integration/E2E work Can independent suites run in parallel?
Upload/deploy Artifact transfer Are you shipping unnecessary files?

This matters because caching dependencies cannot fix a five-minute application build, and caching a build cannot fix a test suite that dominates the job.

Measure first. Optimize the dominant stage second.

Use npm ci as the Reproducible Baseline

In CI, a lockfile is not documentation. It is part of the build contract.

npm ci is designed for clean, Configuring Automated Repository Access installs: it requires an existing lockfile, removes an existing node_modules directory before installation, and does not rewrite the lockfile. That makes it a stronger baseline for reproducible CI than allowing the dependency graph to drift during the build.

A minimal workflow looks like this:

steps:
  - uses: actions/checkout@v6

  - uses: actions/setup-node@v7
    with:
      node-version: 20
      cache: npm

  - run: npm ci
  - run: npm test
  - run: npm run build

The important distinction is easy to miss: the cache should make the clean install cheaper, not replace the clean-install contract.

Cache Downloads Before You Cache node_modules

For npm, a safer default is caching the package manager’s cache rather than treating node_modules as a portable artifact.

Why?

node_modules can contain native packages and installation results that depend on runtime conditions. A directory produced on one runner is not automatically trustworthy on another. Even when it works today, an overly broad cache key can create a failure that disappears as soon as someone clears the cache,the worst kind of CI optimization.

A dependency cache should answer a precise question:

Can this runner avoid downloading package data it has already validated for this dependency definition?

The cache key therefore needs to change when the dependency contract changes. A lockfile hash is a strong input. Operating system and, when relevant to the installed artifacts, runtime/toolchain boundaries should also be considered.

GitHub Actions can handle npm caching through setup-node, while lower-level cache actions are useful when you need explicit keys and restore behavior.

Cache Invalidation Is More Important Than Cache Hits

A high cache-hit rate looks impressive on a dashboard. It is not the goal.

The goal is a cache hit only when reuse is valid.

Think of a cache key as an executable compatibility statement. If your key effectively says npm-cache, you are claiming that every dependency state is interchangeable. It is not.

A better mental model is:

cache identity = dependency definition + relevant execution boundary

For example:

Linux + package-lock hash

may be sufficient for a package-manager download cache, while a compiled artifact can require stricter boundaries such as Node version, architecture, build flags, or framework version.

The more derived the cached artifact is, the more carefully you should define its invalidation rules.

Do Not Confuse Dependency Caching With Build Artifact Reuse

There are three different things teams often call “the cache”:

They have different trust boundaries and lifetimes.

If your application produces a static bundle, the strongest deployment optimization may be to build that artifact once in CI and deploy the verified artifact,not to rerun dependency installation on the production host.

That turns deployment into artifact promotion rather than source reconstruction.

Template Products Have an Even Bigger Optimization Available

Now consider a system that generates many sites from one codebase: client microsites, landing pages, event pages, or invitation templates.

A naive pipeline might do this for every new instance:

new order
→ copy template
→ npm install
→ npm run build
→ upload site

But if only names, dates, colors, copy, and image URLs changed, the application code did not change. Rebuilding the JavaScript bundle is unnecessary work.

A more efficient architecture is:

template code changes
→ install dependencies
→ test
→ build once
→ freeze/version the template artifact

new order
→ validate JSON/config
→ write instance data
→ publish data

This is a fundamentally different optimization from caching node_modules. It removes a build from the per-instance path entirely.

The idea is especially valuable when a product creates many instances from a small set of stable templates. Instead of asking “How do we make npm install faster for every order?”, ask the more powerful question:

Why does an order need a Node.js build at all?

That architectural question can save more time than another layer of caching.

Define the Boundary Between Template and Instance Data

Zero-build instance creation only works when the boundary is disciplined.

Keep code and runtime data separate:

template artifact
├── HTML/CSS/JS
├── fonts and shared assets
└── runtime data loader

instance data
├── title
├── content
├── theme values
├── image URLs
└── feature flags allowed by the schema

Do not allow arbitrary instance configuration to become executable code. Validate it against a schema and keep secrets outside both the public template and public instance payload.

The template version should also be explicit. If an old instance must continue rendering against template v3, silently switching it to v4 can turn an infrastructure optimization into a product regression.

When Prebuilt Templates Are the Wrong Choice

Build-once is not universally better.

Rebuild per instance when the generated output genuinely depends on compile-time behavior,for example:

In those cases, keep the build but optimize it honestly: deterministic installs, package-manager caching, framework build caches where supported, and immutable deployment artifacts.

A Practical Decision Matrix

Situation Recommended strategy
Normal Node.js CI Lockfile + npm ci + package-manager cache
Slow registry downloads Improve dependency cache first
Expensive deterministic build Cache framework/build output with strict invalidation
Static deployable output Build once, deploy immutable artifact
Many sites differ only by data Prebuild/version template, inject validated runtime data
Native dependencies across runners Avoid assuming node_modules is portable
Cache-related flaky failures Tighten keys or remove the unsafe cache

The fastest pipeline is not the one with the most caches. It is the one that does not repeat work whose inputs have not changed.

Verify the Optimization From a Cold Runner

Every performance change needs two tests: warm and cold.

A warm run tells you whether the cache helps. A cold run tells you whether the pipeline is still real.

At minimum, verify:

cold runner + empty cache → install → test → build → success
warm runner + valid cache → install → test → build → faster success
lockfile changed → cache invalidated/restored safely → success
runtime boundary changed → incompatible artifacts not reused

Also measure the actual gain. If restoring and extracting a large cache takes almost as long as downloading dependencies, you have added complexity without buying meaningful feedback time.

RayLabs uses the same reproducibility principle when discussing configuration and repeatable development workflows: optimization is useful only when another machine can still reproduce the result.

The Better Optimization Question

When Node.js deployment feels slow, avoid jumping directly to “How can I reuse node_modules?”

Ask these in order:

  1. Which stage dominates the pipeline?
  2. Can downloads be cached without weakening clean installation?
  3. Can a verified build artifact be promoted instead of rebuilt?
  4. If only data changed, can the build disappear from that path entirely?

That sequence moves the discussion from a local speed hack to architecture.

Use caching to avoid redundant transfer. Use lockfiles and clean installs to preserve reproducibility. Use immutable artifacts to avoid rebuilding during deployment. And when a template’s code does not change per instance, stop paying a build cost for data that could have been validated and injected directly.

Continue Exploring

You Might Also Like

View all articles
Configuring Automated Repository Access
5 min read

Configuring Automated Repository Access

Learn how to establish automated repository access for remote assistants and continuous integration pipelines while managing security boundaries.