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:
{
"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:
{
"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:
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:
npm ci
For example, a basic build pipeline can run:
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:
{
"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:
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:
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:
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:
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:
- 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:
- 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:
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:
- Commit package.json and package-lock.json together for application projects.
- Use npm ci for CI builds and clean installations.
- Standardize supported Node.js and npm versions.
- Review dependency updates before merging them.
- Keep installation configuration consistent across development and CI.
- Use CI caching to improve speed without replacing lockfile validation.
- Test native dependencies in an environment similar to production.
- Avoid automatic lockfile updates inside CI unless the workflow explicitly manages and reviews those changes.
- Use automated tests to validate behavior after dependency changes.
- 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.
Join the conversation! Your thoughts help the community grow.