An application works correctly on a developer's machine but fails during a continuous integration (CI) build. A package behaves differently, a dependency cannot be resolved, or a test passes locally but fails in the build pipeline.

These problems often occur because the development environment and CI environment install different dependency versions.

JavaScript projects rely on npm packages for frameworks, testing tools, build systems, and other functionality. Even when a project uses the same package.json file, differences in lockfiles, npm versions, installation commands, or environment configuration can produce inconsistent results.

The solution is to make dependency installation reproducible. Developers and CI systems should use the same dependency definitions, follow a consistent installation process, and detect unexpected dependency changes before they reach production.

This article explains how package.json and package-lock.json work together, how to use npm ci correctly, and how to prevent dependency drift across development machines and CI pipelines.

Understand Why npm Dependencies Become Inconsistent

A typical Node.js project contains a package.json file that declares its dependencies and project configuration.

For example:

JSON
{
  "name": "order-service",
  "version": "1.0.0",
  "scripts": {
    "test": "node --test",
    "build": "tsc"
  },
  "dependencies": {
    "express": "^5.0.0"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  }
}

The version range ^5.0.0 allows npm to select compatible releases within that range, subject to the package's versioning rules. It does not mean every installation will necessarily select the same exact version when the dependency tree is resolved independently.

The project may also depend on packages that are several levels deep in the dependency tree. Those transitive dependencies can change as package metadata and available versions change.

Several factors can cause inconsistency:

  • Developers use different installation commands.
  • The package-lock.json file is missing or not committed.
  • The lockfile is changed but not reviewed.
  • Different npm versions handle dependency resolution or lockfile formats differently.
  • A CI pipeline installs dependencies using npm install instead of the intended reproducible installation process.
  • Optional dependencies or platform-specific packages differ between operating systems.
  • Environment variables or package configuration change installation behavior.
  • The project uses local packages, Git dependencies, or external package sources that change over time.

A consistent process must control more than just the direct dependencies listed in package.json.

Understand the Role of package.json and package-lock.json

The package.json file describes the project's declared dependencies, scripts, and other metadata.

The package-lock.json file records the resolved dependency tree, including specific package versions and other information npm uses to reproduce the installation.

For example, package.json may declare:

JSON
{
  "dependencies": {
    "express": "^5.0.0"
  }
}

The lockfile records the specific version selected for the project and the resolved dependencies needed to install it.

This distinction is important. The version range expresses which releases are acceptable, while the lockfile records the resolution chosen for the current dependency tree.

Commit the Lockfile to Source Control

For an application, commit package-lock.json alongside package.json.

A typical project structure looks like this:

Code
order-service/
├── src/
├── tests/
├── package.json
├── package-lock.json
├── .npmrc
└── .gitignore

When a developer changes a dependency, the corresponding lockfile changes should be reviewed and committed with the package.json changes.

If the lockfile is missing from source control, each environment may resolve dependency ranges independently. That makes it harder to reproduce the same installation across machines.

A lockfile is not a guarantee that every environment will have identical behavior. Operating systems, CPU architectures, Node.js versions, native dependencies, environment configuration, and external resources can still differ. However, it is a key part of making dependency installation reproducible.

Use npm ci in CI Pipelines

The npm ci command is designed for clean, automated dependency installation.

Unlike npm install, npm ci expects a lockfile that matches package.json. It fails when the dependency definitions and lockfile are inconsistent instead of silently updating the lockfile to reconcile the mismatch.

It also removes an existing node_modules directory before installing the locked dependency tree.

Use the following command in CI:

Bash
npm ci

For example, a basic build pipeline can run:

Bash
npm ci
npm test
npm run build

This process installs the dependencies recorded in the lockfile, runs the tests, and builds the application.

Why npm ci Is Better for CI

Consider two situations.

A developer changes package.json but forgets to update package-lock.json. A CI job using npm ci will report the mismatch rather than automatically resolving it.

This failure makes the dependency change visible and encourages the developer to update and commit both files.

In contrast, npm install may update the lockfile when it resolves the dependency tree, depending on the state of the project and the command's options. That behavior can make a build less predictable if the pipeline is not designed to manage lockfile changes.

For application repositories, a common practice is:

  • Use npm install when intentionally adding, removing, or updating dependencies.
  • Review and commit the resulting package-lock.json changes.
  • Use npm ci for clean CI installations and reproducible deployment builds.

Developers can also use npm ci when they want a clean installation matching the committed lockfile.

Keep Node.js and npm Versions Consistent

A committed lockfile is important, but the runtime environment matters too.

Different Node.js and npm versions can affect installation behavior, supported features, native module compatibility, and build results.

Define the supported Node.js version in the project documentation and, where appropriate, in package.json.

For example:

JSON
{
  "engines": {
    "node": ">=22 <25",
    "npm": ">=10"
  }
}

These version ranges are illustrative. Use a range that matches the versions your application and tooling actually support.

The engines field communicates the expected runtime versions, but it does not automatically install the specified versions. Your local development setup and CI pipeline must still select compatible versions.

Select the Node.js Version in CI

A GitHub Actions workflow can select a specific Node.js major version:

SQL
name: Node.js CI

on:
  push:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Build application
        run: npm run build

This workflow selects Node.js 22, enables npm caching, installs dependencies using the lockfile, runs tests, and builds the project.

For production pipelines, pin third-party GitHub Actions to reviewed full commit SHAs when your security policy requires immutable action references. The version tags in this example are for readability and are not immutable references.

The npm version bundled with a Node.js release can vary. If exact npm-version consistency is required, explicitly manage that version and verify it in both local development and CI.

You can check the installed versions with:

Bash
node --version
npm --version

Include these commands in troubleshooting steps when a dependency installation behaves differently between environments.

Configure npm Settings Consistently

An npm configuration file can define installation settings that should be shared across the project.

For example, a project-level .npmrc file might contain:

Code
save-exact=false

This example configures npm to avoid automatically saving exact versions when adding dependencies. It is illustrative and is not required for lockfile-based reproducibility.

Other projects may use .npmrc to configure registry settings, peer dependency behavior, or authentication-related options.

Be Careful with Dependency Resolution Settings

Options such as legacy peer dependency handling can affect whether a dependency tree installs successfully.

If a lockfile was created using installation options that change dependency resolution, npm ci may need compatible settings to reproduce the installation.

Document the required configuration and keep it consistent between development and CI.

Do not copy private registry credentials or authentication tokens into a committed .npmrc file. Use environment variables or your CI platform's secret-management facilities for sensitive values.

Also avoid adding unnecessary configuration options simply to make an installation pass. First understand why the dependency tree requires the setting.

Detect Unexpected Lockfile Changes

Dependency updates should be deliberate and reviewable.

When a developer adds a package, npm may update package.json and package-lock.json. Review both files before merging the change.

You can inspect the changes with Git:

Bash
git diff -- package.json package-lock.json

This helps reviewers identify newly introduced packages, version changes, and modifications to the resolved dependency tree.

Validate the Lockfile in CI

Because npm ci requires the lockfile to match the dependency definitions, it is a useful validation step in automated builds.

A basic validation job might contain:

Code
- name: Install dependencies from lockfile
  run: npm ci

If the lockfile is missing or inconsistent with package.json, the step fails and the pipeline stops.

Avoid running npm install in CI merely to repair the lockfile automatically. That can conceal a dependency change that should have been reviewed.

When a lockfile mismatch occurs, reproduce the intended dependency update locally, review the resulting changes, and commit them.

Handle Native and Platform-Specific Dependencies

Not all dependency differences come from lockfile problems.

Some packages include native code or select optional packages based on the operating system, CPU architecture, or runtime environment.

For example, a package may use a prebuilt binary on one platform and require a different installation path on another. A project that works on macOS may fail in a Linux-based CI environment if a required system library or build tool is missing.

When investigating these differences, compare:

  • Node.js and npm versions.
  • Operating system and CPU architecture.
  • Native library availability.
  • Environment variables.
  • Optional dependency installation settings.
  • Build tools required by native modules.

If the production application runs in a Linux container, testing the build in a compatible Linux environment can reveal issues that are not visible on a developer's machine.

A lockfile controls the dependency resolution information, but it does not make different operating systems identical.

Use Dependency Caching Without Sacrificing Reproducibility

Installing dependencies from scratch can take time, particularly in large projects.

CI caching can improve build performance, but the cache must not become the source of truth for dependency versions.

For GitHub Actions, the setup-node action supports npm caching based on the project's lockfile.

For example:

Code
- name: Set up Node.js
  uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: npm
    cache-dependency-path: package-lock.json

The cache stores npm's download data rather than requiring the pipeline to trust an existing node_modules directory as the complete dependency installation.

You can then run:

Bash
npm ci

This preserves the clean-install process while allowing npm to reuse cached package downloads where possible.

A cache miss should affect build speed, not dependency correctness. The build should still succeed using the lockfile and the configured package registry.

For projects with multiple packages or workspaces, configure the cache dependency path to cover the relevant lockfile location.

Troubleshoot Common Dependency Problems

Symptom

Possible cause

Recommended action

npm ci fails because package.json and the lockfile disagree

A dependency change was not committed with its lockfile update

Run npm install intentionally, review both files, and commit the changes

A package works locally but fails in CI

Different Node.js versions, operating systems, or environment settings

Compare runtime versions and platform requirements

CI installs different packages after a toolchain update

npm version or dependency-resolution behavior changed

Standardize npm versions and review the lockfile

Native module installation fails in Linux CI

Missing system libraries or build tools

Install the required build dependencies or use a compatible package

Builds are slow despite using a lockfile

Dependencies are repeatedly downloaded or build steps are inefficient

Configure npm caching and inspect build duration

A package update introduces unexpected behavior

A transitive dependency changed or compatibility assumptions were incorrect

Review lockfile changes and run regression tests

npm ci fails after changing peer dependency settings

The lockfile and installation configuration are incompatible

Recreate the intended dependency tree with documented settings

Best Practices for Consistent npm Dependencies

Follow these practices to make dependency installations more predictable:

  1. Commit package.json and package-lock.json together for application projects.
  2. Use npm ci for CI builds and clean installations.
  3. Standardize supported Node.js and npm versions.
  4. Review dependency updates before merging them.
  5. Keep installation configuration consistent across development and CI.
  6. Use CI caching to improve speed without replacing lockfile validation.
  7. Test native dependencies in an environment similar to production.
  8. Avoid automatic lockfile updates inside CI unless the workflow explicitly manages and reviews those changes.
  9. Use automated tests to validate behavior after dependency changes.
  10. Keep registry credentials and authentication tokens out of source control.

Advantages and Limitations of Lockfile-Based Installation

Lockfile-based installation provides a repeatable dependency resolution process. It helps reduce unexpected package changes, improves build consistency, and makes dependency updates easier to review.

It also gives developers a clearer way to reproduce installation problems because the dependency tree is recorded in source control.

However, a lockfile does not guarantee that every build will be identical. Operating system differences, native modules, external services, environment variables, runtime versions, and nondeterministic build steps can still affect the result.

Reproducibility therefore depends on a combination of a committed lockfile, a consistent toolchain, controlled configuration, and automated validation.

Summary

Keeping npm dependencies consistent across developer machines and CI starts with committing package-lock.json and using npm ci for automated builds. The lockfile records the resolved dependency tree, while a standardized Node.js and npm environment reduces differences in installation behavior.

Review dependency changes, keep npm configuration consistent, and use caching to improve build speed without bypassing clean installation. When problems occur, compare runtime versions, operating systems, native dependencies, and installation settings before changing the dependency tree.

A reliable dependency workflow makes builds easier to reproduce, reduces unexpected failures, and gives teams greater confidence when updating or deploying Node.js applications.