Ctrl + K
JavaScript24 min read

How npm Dependencies Work

A practical guide to npm dependencies, covering direct and transitive packages, package.json, package-lock.json, node_modules, semantic versioning, dependency resolution, peerDependencies and reproducible installations.

Published: 2026-10-05

Modern JavaScript applications rarely consist entirely of code written by the application team. A typical project depends on libraries for routing, validation, HTTP requests, testing, linting, UI components, database access and many other tasks. npm provides the dependency management system that makes these packages available to the project.

At first, npm dependencies can look simple: add a package with npm install and import it in your code. Behind that command, however, npm has to resolve version ranges, download packages, process transitive dependencies, build a dependency tree, maintain a lockfile and install the resulting packages into node_modules.

Understanding this process makes many common JavaScript problems easier to diagnose. It explains why package-lock.json matters, why installing one package can bring dozens of other packages into node_modules, why two projects can have different versions of the same dependency, and why changing a version range does not always immediately change the installed version.

What Is an npm Dependency?

An npm dependency is a package that another JavaScript project requires. The dependency can provide functionality directly used by the application or support another package that the application depends on.

{
  "dependencies": {
    "axios": "^1.8.0"
  }
}

In this example, axios is a direct dependency of the project. The project explicitly declares that it requires a compatible version of axios according to the specified semantic-version range.

Direct vs Transitive Dependencies

One of the most important distinctions in npm is between direct dependencies and transitive dependencies.

TypeMeaning
Direct dependencyA package explicitly declared by your project's package.json.
Transitive dependencyA package required by one of your direct or other transitive dependencies.

For example, your project may directly depend on a framework. That framework may depend on a utility library, which may depend on another package. Your project therefore receives several packages even though only the framework was explicitly added to package.json.

This is why a project with only a few direct dependencies can have hundreds of packages inside node_modules.

A Simple Dependency Chain

Suppose an application directly depends on package-a. package-a depends on package-b, and package-b depends on package-c. The application does not need to declare package-b or package-c as direct dependencies just because they are installed.

PackageRelationship
ApplicationDirectly depends on package-a
package-aDirectly required by the application
package-bTransitive dependency of package-a
package-cTransitive dependency of package-b

The exact installed structure can be more complicated because npm may need to install different versions of the same package when dependency requirements are incompatible.

The dependencies Field

The dependencies field in package.json declares packages needed by the project. It normally contains packages required by the application when it runs.

{
  "dependencies": {
    "next": "^15.0.0",
    "react": "^19.0.0",
    "zod": "^4.0.0"
  }
}

The version strings are usually ranges rather than exact versions. npm uses these ranges when resolving the dependency tree.

devDependencies

devDependencies contains packages used for development, testing, linting, formatting or building when those packages are not required by the application at runtime.

{
  "devDependencies": {
    "typescript": "^5.9.0",
    "eslint": "^9.0.0",
    "vitest": "^3.2.0"
  }
}

The distinction is based on how the project is deployed and executed. A package used by production application code generally belongs in dependencies, while a package used only to compile, test or lint the project generally belongs in devDependencies.

💡 Ask whether the production environment needs the package to execute the application. If the answer is yes, it generally belongs in dependencies.

peerDependencies

peerDependencies describe packages that the consuming project is expected to provide. They are particularly common in libraries, plugins and integrations that work with a host framework.

{
  "peerDependencies": {
    "react": "^19.0.0"
  }
}

A component library can use a peer dependency for React because the application consuming the library is expected to have its own React installation.

Peer dependencies express compatibility requirements rather than simply asking npm to install another ordinary runtime dependency for the package.

optionalDependencies

optionalDependencies are packages that provide functionality when available but are not treated in exactly the same way as required dependencies during installation.

{
  "optionalDependencies": {
    "optional-package": "^2.0.0"
  }
}

Code that relies on an optional dependency should account for the possibility that the package is unavailable. Marking a dependency as optional does not automatically handle that situation in application code.

What Happens When You Run npm install?

When you run npm install, npm reads the project's package.json and determines which packages are required. It then resolves the requested dependency ranges, obtains the packages and their dependencies, creates or updates the dependency tree and installs the result.

npm install

For a new project, npm may create package-lock.json during this process. For an existing project, npm can use the lockfile to determine the previously resolved versions while still checking that the lockfile and package.json are consistent.

What npm Actually Has to Resolve

npm does not simply select one version for every package name. It has to consider the requirements of the entire dependency tree.

  • Which direct dependencies the project requests.
  • Which versions satisfy each declared range.
  • Which transitive dependencies those packages require.
  • Whether multiple packages require different versions of the same dependency.
  • Whether peer dependency requirements are compatible.
  • Which resolved package versions should be recorded in the lockfile.
  • How the resulting dependency tree can be represented in node_modules.

Semantic Versioning and Dependency Ranges

Most npm dependencies use Semantic Versioning, commonly abbreviated as SemVer. A typical version has three components: major, minor and patch.

2.7.4
ComponentValueTypical purpose
Major2Potentially breaking changes.
Minor7Backward-compatible functionality.
Patch4Backward-compatible fixes.

npm uses semantic-version ranges to decide which package versions can satisfy a dependency declaration. Understanding these ranges is essential when diagnosing why npm installed one version instead of another.

Exact Versions

An exact version requests one specific release.

{
  "dependencies": {
    "example-package": "2.7.4"
  }
}

This declaration does not allow npm to select a different version within the package.json range. The lockfile still records the concrete dependency tree, including the dependencies of that package.

Caret Ranges

The caret operator allows a range of compatible versions according to npm's SemVer rules.

{
  "dependencies": {
    "example-package": "^2.7.4"
  }
}

For a normal major version such as 2.7.4, the range can permit later compatible releases in the 2.x series while excluding 3.0.0. Caret behavior has additional rules for versions beginning with zero, so it should not be reduced to a simplistic 'minor updates only' rule.

Tilde Ranges

The tilde operator generally permits patch-level updates within the corresponding minor release.

{
  "dependencies": {
    "example-package": "~2.7.4"
  }
}

For this example, later 2.7.x versions can satisfy the range while 2.8.0 is outside it.

Why Version Ranges Matter

A dependency declaration such as ^2.7.4 does not necessarily mean that version 2.7.4 will be installed forever. The declared range describes what is acceptable, while the lockfile can record the specific version currently selected.

This distinction between a requested range and a resolved version is fundamental to understanding npm.

What Is package-lock.json?

package-lock.json is npm's lockfile. It records the resolved dependency tree for the project, including specific package versions and information used to reproduce the installation.

While package.json might say that a project accepts ^2.7.4, package-lock.json can record that version 2.9.1 was selected during dependency resolution.

{
  "packages": {
    "node_modules/example-package": {
      "version": "2.9.1",
      "resolved": "https://registry.npmjs.org/example-package/-/example-package-2.9.1.tgz"
    }
  }
}

The exact structure of package-lock.json depends on the npm version and lockfile format. It contains much more information than this simplified example.

package.json vs package-lock.json

FileWhat it describes
package.jsonProject metadata and dependency requirements, usually expressed as version ranges.
package-lock.jsonThe concrete dependency resolution selected for the project.

A useful mental model is that package.json describes what the project is willing to accept, while package-lock.json records what was actually resolved for a particular installation state.

Why package-lock.json Matters

Without a lockfile, two installations performed at different times can potentially resolve different versions that all satisfy the ranges in package.json. A lockfile helps keep installations consistent.

  • Makes dependency installations more reproducible.
  • Records resolved versions of direct and transitive packages.
  • Helps CI environments install the same dependency tree.
  • Makes dependency changes visible in code review.
  • Provides package integrity information.
  • Helps diagnose unexpected version changes.
💡 For npm applications, package-lock.json should generally be committed to version control. Deleting it casually can change the resolved dependency tree the next time dependencies are installed.

How npm ci Uses the Lockfile

npm ci is intended for clean, reproducible installations. It relies on the existing lockfile rather than treating package.json as a request to freely recalculate the dependency tree.

npm ci

npm ci expects package.json and package-lock.json to be synchronized. If their dependency declarations are incompatible, the command can fail rather than silently changing the lockfile.

This behavior makes npm ci especially useful in continuous integration and deployment environments.

What Is node_modules?

node_modules is the directory where npm installs packages for the project. It contains the actual files that JavaScript tooling and the application can import.

node_modules/
package.json
package-lock.json

node_modules is generated from the project's dependency metadata and is normally excluded from Git repositories.

Why node_modules Can Become Huge

A project may have only a few direct dependencies but still install hundreds of packages because those dependencies have their own dependencies.

Development tooling can make this especially noticeable. Frameworks, compilers, linters, testing libraries and bundlers often have large dependency trees of their own.

The number of packages in node_modules therefore does not directly tell you how many dependencies the application explicitly chose.

How npm Can Install Multiple Versions of One Package

Different packages can require incompatible versions of the same dependency. npm can install multiple versions when necessary to satisfy those requirements.

PackageRequired version
package-autility-lib ^2.0.0
package-butility-lib ^3.0.0

If no single utility-lib version satisfies both ranges, npm may install separate versions in the dependency tree. This is one reason node_modules can contain multiple versions of a package with the same name.

Dependency Hoisting

npm can place compatible dependencies higher in node_modules instead of keeping every package nested inside its immediate parent. This process is commonly described as hoisting.

Hoisting can reduce duplication and make dependency resolution more efficient, but the exact physical structure of node_modules should not be treated as the dependency contract. Package resolution rules determine which modules a package can access.

Why You Should Not Depend on Transitive Packages Directly

Suppose your application uses package-a and package-a happens to install utility-lib. Your code should not normally import utility-lib unless utility-lib is also declared as a direct dependency of your project.

The reason is simple: package-a can remove or replace utility-lib in a future release without considering your undocumented dependency on it.

⚠️ If your application imports a package directly, declare that package directly in package.json. Do not rely on another dependency happening to install it for you.

npm Dependency Resolution

Dependency resolution is the process of selecting package versions that satisfy the requirements of the dependency graph.

Consider an application that requires package-a at ^1.0.0. package-a requires package-b at ^2.0.0. npm must select a package-a version that satisfies the application and then resolve package-b according to package-a's requirements.

  • Read direct dependency declarations.
  • Interpret their version ranges.
  • Read each selected package's dependency requirements.
  • Expand the dependency graph.
  • Resolve compatible versions.
  • Handle conflicting requirements where necessary.
  • Resolve peer dependencies.
  • Write the resulting state to the lockfile.
  • Install the resolved packages.

What Happens When Two Packages Need Different Versions?

Suppose one dependency requires library-x ^1.5.0 while another requires library-x ^2.0.0. Those ranges cannot be satisfied by the same major version.

npm can install different versions in different parts of the dependency tree. This allows both packages to continue working with the versions they declared compatible.

Multiple versions are not automatically a problem. They become important when they increase bundle size, introduce security concerns, or create compatibility issues.

Peer Dependency Conflicts

Peer dependencies work differently from ordinary nested dependencies because they express a relationship between a package and the environment that consumes it.

Library A requires React ^18
Project requires React ^19

Depending on the declared ranges and npm's resolution rules, this can produce a peer dependency conflict. The correct solution is not always to force npm to install anyway. The project may need a compatible library version, a different dependency version or an updated package.

⚠️ Do not treat --force or --legacy-peer-deps as a universal fix for dependency conflicts. These options can bypass useful compatibility checks and may leave the project with a dependency tree that has runtime problems.

Understanding npm install package@version

You can request a specific package version when installing a dependency.

npm install [email protected]

npm updates package.json according to the requested version and resolves the dependency tree again. The lockfile is also updated to reflect the new resolution.

If you want a different version range, the package.json declaration itself can be changed through npm commands or manually, followed by dependency installation.

npm update vs npm install

npm update attempts to update installed packages within the version ranges declared by the project. npm install is broader and is also used when adding packages, installing an existing project and reconciling package metadata.

Neither command should be interpreted as simply 'install the newest version of everything'. The declared ranges, lockfile and npm's dependency resolution all influence the result.

What Happens When You Delete package-lock.json?

Deleting package-lock.json removes the project's record of its previous npm dependency resolution. Running npm install afterward can resolve dependencies again according to package.json.

The resulting dependency tree may therefore differ from the previous one, especially when package.json uses ranges and compatible package versions have changed since the lockfile was generated.

⚠️ Do not delete package-lock.json as a routine troubleshooting step. It can hide the original dependency-resolution problem and introduce unrelated version changes.

What Happens When You Delete node_modules?

Deleting node_modules removes the locally installed packages but does not change package.json or package-lock.json.

rm -rf node_modules
npm install

On Windows, the equivalent removal can be performed through the command shell, PowerShell or the file system. Afterward, npm can recreate node_modules from the project dependency metadata.

This is fundamentally different from deleting package-lock.json. Removing node_modules removes installed output; removing the lockfile removes the recorded dependency resolution.

npm Dependency Tree

npm provides commands for inspecting the installed dependency tree. The basic npm ls command displays packages installed in the current project.

npm ls

You can also inspect a specific package.

npm ls react

This can help determine which version is installed and how it appears in the dependency tree.

Checking Why a Package Is Installed

When a package appears in node_modules and you do not remember adding it, it is often a transitive dependency. Dependency-tree inspection can help identify which package requires it.

npm ls package-name

This is useful when investigating packages that appear after installing a framework or library.

Dependency Overrides

npm supports the overrides field for controlling certain transitive dependency versions from the root project.

{
  "overrides": {
    "some-package": "2.4.1"
  }
}

Overrides can be useful when a transitive dependency needs a particular version, for example when addressing a known issue before the direct dependency has released an update.

⚠️ Overrides should be used carefully. Forcing a transitive dependency to a version outside the range expected by its parent can introduce compatibility problems.

Dependency Deduplication

When several parts of the dependency tree can use the same compatible version of a package, npm can reduce duplication by reusing that version.

npm dedupe

The goal of deduplication is not simply to make the number of packages smaller. It reorganizes compatible dependencies where possible while preserving the required version constraints.

Why npm Installations Can Differ

Two developers can sometimes see different dependency behavior even when they start with apparently similar projects. Possible causes include different package.json ranges, different lockfiles, different npm versions, different Node.js versions, platform-specific optional dependencies or changes in the package registry.

  • package.json was changed.
  • package-lock.json was missing or regenerated.
  • Different npm versions were used.
  • Different Node.js versions were used.
  • Platform-specific optional dependencies were involved.
  • The project was installed with different npm commands.
  • The dependency tree contains packages with broad version ranges.

A committed lockfile and a consistent package-manager setup greatly reduce this variability for applications.

Reproducible Dependency Installation

Reproducibility means that developers, CI systems and deployment environments can recreate a dependency tree predictably.

  • Commit package.json.
  • Commit package-lock.json for npm applications.
  • Use npm ci in CI and clean deployment environments where appropriate.
  • Keep the Node.js version consistent across environments.
  • Use the same package manager and lockfile format across the project.
  • Review dependency changes instead of modifying lockfiles manually.

Why You Should Not Edit package-lock.json Manually

package-lock.json is generated dependency-resolution data. Although it is technically a JSON file, manually editing it can easily create inconsistencies or an invalid dependency state.

When dependency requirements change, prefer npm commands such as npm install, npm uninstall and npm update. Let npm regenerate the relevant lockfile information.

💡 Treat package-lock.json as generated project state. Review its changes in Git, but normally let npm produce those changes instead of hand-editing the file.

Security and npm Dependencies

Every dependency adds third-party code to the project. Transitive dependencies matter too, because your application executes or packages code that may come from several levels of the dependency tree.

  • Review new dependencies before installing them.
  • Remove packages that are no longer needed.
  • Monitor security advisories.
  • Keep important dependencies reasonably current.
  • Review lockfile changes during dependency updates.
  • Be cautious with installation scripts from unfamiliar packages.
  • Avoid depending on undocumented transitive packages.

The npm audit command can help identify known vulnerabilities in installed dependencies.

npm audit

A vulnerability report does not automatically mean that an application is exploitable, and blindly applying every suggested update can create compatibility problems. Security findings should be reviewed in the context of the application.

Dependency Updates and Breaking Changes

Updating dependencies is not simply a matter of getting newer code. A new major version can introduce breaking API changes, while even compatible updates can alter behavior, performance or transitive dependencies.

  • Review the package's release notes.
  • Check whether the version range allows the proposed update.
  • Review package-lock.json changes.
  • Run tests and linting.
  • Build the application.
  • Check important application flows.

Unused Dependencies

Over time, projects often accumulate packages that are no longer imported or required. Unused dependencies increase maintenance work and can add unnecessary security and installation overhead.

Before removing a dependency, verify that it is not used indirectly by scripts, configuration files, build tools or runtime code. A package that appears unused from JavaScript imports may still be required by a build or command-line workflow.

Dependency Size and Performance

A dependency can affect application size even when the package itself appears small. Its transitive dependencies, browser bundle behavior and imported modules can all influence the final output.

For frontend applications, the fact that a package is installed in node_modules does not mean that its entire contents are sent to the browser. Bundlers can often remove unused code through tree shaking, but the result depends on the package format, imports and build configuration.

💡 When evaluating a frontend dependency, consider both installation cost and production bundle impact. A package can have a large npm dependency tree without necessarily adding all of that code to the browser bundle.

Dependencies in Production

Production installations sometimes omit devDependencies because build and development tooling is not required after the application has been built. Whether this is possible depends on the framework and deployment model.

For a server-side Node.js application, runtime packages must remain available after deployment. For a frontend application where assets are completely built before deployment, many development dependencies are only needed during the build step.

⚠️ Do not remove a package from dependencies simply because you do not import it from browser code. Server-side frameworks and build or runtime infrastructure may still require it after deployment.

A Typical Dependency Workflow

  • Choose a package that provides functionality the project needs.
  • Install it with npm.
  • npm adds the package to package.json.
  • npm resolves the package and its dependency tree.
  • npm updates package-lock.json.
  • The packages are installed into node_modules.
  • Application code imports the package.
  • Future installations use the project metadata and lockfile to reproduce the dependency tree.

Example: Adding a New Dependency

Suppose a project needs Zod for runtime data validation. The package can be added with npm install.

npm install zod

After the command completes, package.json contains a dependency declaration and package-lock.json contains the resolved package information. The package is also installed into node_modules.

{
  "dependencies": {
    "zod": "^4.0.0"
  }
}

The exact version written depends on the version available and the command used. The lockfile then records the concrete version that npm resolved.

Example: Removing a Dependency

If the project no longer needs Zod, remove it through npm.

npm uninstall zod

npm removes the direct dependency declaration, updates the lockfile and removes the package when it is no longer needed by another part of the dependency tree.

How to Investigate a Dependency Problem

When npm reports a dependency error, avoid immediately deleting node_modules and package-lock.json. First identify what npm is actually complaining about.

  • Read the package name and requested version range in the error.
  • Check package.json for direct dependency declarations.
  • Inspect the dependency tree with npm ls.
  • Check peerDependencies involved in the conflict.
  • Inspect package-lock.json for the resolved version.
  • Determine whether a package update introduced the conflict.
  • Check the package's compatibility requirements.
  • Only then choose whether to update, downgrade, replace or remove a dependency.

Common npm Dependency Mistakes

  • Relying on a transitive dependency without declaring it directly.
  • Putting runtime packages in devDependencies.
  • Putting every package in dependencies without considering its actual role.
  • Deleting package-lock.json whenever npm reports an error.
  • Manually editing package-lock.json.
  • Ignoring peer dependency warnings.
  • Using --force as a general solution to dependency conflicts.
  • Committing node_modules to Git.
  • Updating many major dependencies without reviewing breaking changes.
  • Ignoring security advisories because the vulnerable package is transitive.
  • Assuming a dependency's version in package.json is always the exact installed version.

package.json, package-lock.json and node_modules Together

These three files and directories have complementary roles.

Project componentRole
package.jsonDeclares direct dependency requirements and project configuration.
package-lock.jsonRecords the resolved dependency tree and exact package information.
node_modulesContains the packages installed for the current environment.

Thinking about them as declaration, resolution and installation helps explain many npm behaviors. package.json is the project manifest, package-lock.json is the reproducible resolution, and node_modules is the installed result.

Frequently Asked Questions

What is an npm dependency?

An npm dependency is a package required by a JavaScript project. It can be a direct dependency declared by the project or a transitive dependency required by another package.

What is the difference between dependencies and devDependencies?

dependencies normally contain packages required at runtime, while devDependencies contain packages used for development, testing, linting or building when they are not required at runtime.

Why does node_modules contain so many packages?

Because direct dependencies can have their own dependencies, which can have further dependencies. These transitive dependencies form a potentially large dependency tree.

Why is package-lock.json important?

It records the concrete dependency resolution used by the project, helping developers and CI environments reproduce a consistent dependency tree.

Can npm install multiple versions of the same package?

Yes. If different parts of the dependency tree require incompatible versions, npm can install multiple versions so each package receives a compatible dependency.

Should I delete package-lock.json when npm shows an error?

Usually not. Deleting the lockfile can change the dependency resolution and make the original problem harder to diagnose. Inspect the dependency conflict first.

What is the difference between npm install and npm ci?

npm install is used for installing and changing project dependencies and can update the lockfile. npm ci is intended for clean, reproducible installations based on an existing lockfile.

Helpful npm Dependency Tools

An npm Dependency Checker can help inspect dependency versions, relationships and potential issues in a project. A Package-lock Inspector is useful when you need to examine the concrete versions recorded by package-lock.json rather than only the version ranges declared in package.json.

A Package.json Validator can catch malformed JSON and configuration problems, while a Package.json Formatter can keep package metadata consistently formatted. An npm Version Calculator can help compare semantic-version ranges and determine which package versions satisfy a particular requirement.

Conclusion

npm dependency management is the process of declaring, resolving and installing the packages a JavaScript project needs. The visible command may be as simple as npm install, but npm has to solve a dependency graph containing direct packages, transitive dependencies, version ranges and peer requirements.

The most important files to understand are package.json and package-lock.json. package.json declares the project's dependency requirements, while package-lock.json records the concrete dependency tree that npm resolved. node_modules then contains the installed packages for the current environment.

Once you understand the difference between a dependency declaration and a resolved version, many npm behaviors become easier to explain. A package version in package.json is not necessarily the exact version installed, a package in node_modules may be transitive rather than direct, and multiple versions of the same package can coexist when dependency requirements conflict.

Good dependency management is therefore less about keeping the smallest possible package.json and more about maintaining a predictable, understandable and compatible dependency tree. Keep direct dependencies explicit, commit the lockfile for applications, review updates and treat third-party packages as an important part of the project's runtime and security surface.

Found an issue?

Found an error, outdated information, or something missing from this article? Let me know through the Contact page.

Your feedback helps improve our articles and keep them accurate and useful.