Skip to content

What Is npm? The Package Manager Behind JavaScript Projects

npm is the standard package manager for Node.js, providing a CLI and registry to manage over 2.1 million JavaScript packages with exact version locking.

Tuan Tran Van
15 min read
Contents (10 sections)
  1. What is npm?
  2. package.json: the file that declares your project
  3. Semver: what ^ and ~ actually allow
  4. What npm install does to node_modules
  5. package-lock.json and npm ci: why installs stay identical
  6. npm scripts: running project tasks with one command
  7. Install scripts and supply chain risk: what npm v12 changed
  8. npm, Yarn or pnpm: how they differ
  9. Where to start with npm
  10. References

npm is the standard package manager for Node.js, providing the infrastructure required to share, version, and install code dependencies across the JavaScript ecosystem.

It operates as a three-tier system: a public registry of versioned software, a command-line interface (CLI) for local package management, and a website for discovery and organization management.

By establishing a centralized protocol for dependency distribution, npm lets you manage complex JavaScript codebases, ensuring that specific library versions are retrieved and integrated into the application runtime. This infrastructure supports both open-source collaboration and private organizational workflows through a permission-based registry.

For any engineer working with the Node.js runtime, npm is the default mechanism for retrieving, updating, and auditing code.

Its components work together to keep dependency trees navigable and reproducible across development and deployment environments.

npm, the package manager behind JavaScript projects — article cover

What is npm?

npm has three parts that work together: the website, the command-line interface (CLI), and the public registry. The website is where you find packages and manage organizations and teams. The CLI is what resolves dependencies on your machine and on CI/CD runners. Behind both sits the registry, the world's largest software database, which hosted over 2.1 million packages as of late 2022.

The three parts of npm: the website, the command-line interface, and the registry

You reach for npm to pull external packages into an application, run standalone tools via npx without installing them permanently, and control whether a module is public or private. Larger organizations use the same hooks to restrict code access to specific teams while keeping one workflow for internal and external dependencies.

Underneath, the CLI talks to the registry's CouchDB-based database to manage package metadata. When you initiate a request, the CLI queries the registry for a JSON document containing the package's version history and metadata. This metadata includes the specific URLs for compressed tarballs and their associated cryptographic hashes.

The CLI does not merely download files; it performs a multi-step resolution process. It first reconciles the requested version range against available versions in the registry, then fetches the manifest to identify transitive dependencies. Once the full dependency tree is resolved, the CLI downloads the required tarballs, verifies their integrity against the registry's hash, and extracts them into the local environment.

package.json: the file that declares your project

The package.json file is the manifest for a Node.js project, acting as the source of truth for dependency resolution. It tracks metadata such as the project name and version, while explicitly declaring the libraries required to execute the application. Any developer or automated environment can replicate the project's dependency tree by referencing the versions documented in the manifest. Without this file, the npm CLI cannot identify which libraries to download or how to execute project-specific scripts.

Dependency types in package.json: dependencies, devDependencies, optionalDependencies and peerDependencies

The manifest splits dependencies in two, and the split matters. Libraries the application needs at runtime go under dependencies. Conversely, devDependencies are reserved for development-time tools, such as the Jest testing framework or build utilities like Webpack, which are not needed in production. When deploying, you can use --omit=dev to exclude these development tools, reducing the final footprint and minimizing the attack surface of the production build.

Since npm 5, the CLI updates this manifest for you. Flags like --save-dev (shorthand -D) or --save-optional (shorthand -O) during installation automatically create the corresponding entry in the package.json file. Optional dependencies are unique because their failure to build or install does not cause the overall installation to fail; your application logic must account for the potential absence of these modules. peerDependencies serve a different purpose, common when building a plugin or companion library that requires the host project to already provide a specific version of a base library such as React.

The manifest also carries the Semantic Versioning ranges that determine how the CLI updates packages. By defining these ranges, you control the balance between inheriting the latest security patches and maintaining a stable, unchanging codebase. A typical project uses this manifest to manage dozens or hundreds of direct dependencies, which in turn pull in thousands of transitive dependencies, making the manifest the anchor for the entire project architecture.

json
{
  "name": "enterprise-service-mesh",
  "version": "1.0.0",
  "dependencies": {
    "express": "^4.18.2",
    "helmet": "^7.0.0"
  },
  "devDependencies": {
    "jest": "^29.5.0",
    "typescript": "^5.0.0"
  },
  "optionalDependencies": {
    "fsevents": "^2.3.2"
  }
}

Semver: what ^ and ~ actually allow

Semantic Versioning (Semver) is the protocol used to signal API stability and breaking changes via a MAJOR.MINOR.PATCH format. MAJOR versions indicate breaking API changes, MINOR versions add backward-compatible functionality, and PATCH versions provide backward-compatible bug fixes. npm relies on these signals to decide whether to fetch a newer version from the registry or stay with the current local installation.

The caret allows minor and patch updates while the tilde allows only patch updates

The tilde (~) is a conservative specifier that allows only patch updates: ~1.2.3 allows anything below 1.3.0. It assumes the maintainer will only include non-breaking bug fixes in patch releases, which gives your project stability while still inheriting security patches automatically. The caret (^) is the default specifier, allowing minor and patch updates: ^1.2.3 allows anything below 2.0.0. It became the default save prefix in npm 1.4.22 because it lets projects safely inherit un-backported bug fixes.

The "Magic Zero" (0.x.y) behavior is a critical nuance. Semver treats major version zero as initial development, where the API is considered unstable, so the caret becomes more cautious. For 0.0.z releases, the caret allows zero flexibility and pins to the exact version. For 0.y.z releases, the caret behaves exactly like a tilde, allowing only patch updates. This logic exists because, in the pre-1.0.0 ecosystem, the community treats the minor version as the indicator for breaking changes.

This "1.0.0 anxiety" is prevalent throughout the registry: approximately 82% of all packages remain at major version zero. Authors often stay in this state to avoid the perceived commitment of a stable API, essentially reinterpreting the Semver spec to use the second segment as the primary breaking-change indicator. To counter the trend, modern versions of npm init (starting from v1.4.22) changed the default starting version from 0.0.0 to 1.0.0, pushing developers through the imaginary 1.0.0 barrier toward more rigorous major version bumps.

What npm install does to node_modules

When you execute npm install, the CLI initiates a resolution process to reconcile the dependency requirements defined in your project manifest. The tool parses the package.json file, fetches the necessary tarballs from the registry, and populates a local node_modules folder. This folder stores all libraries and their nested dependencies, making them available to the Node.js runtime. The process verifies that every dependency's internal requirements are also met, often leading to a large expansion of the local file count.

npm v2 nested dependencies deeply while npm v3 hoists them into a flat structure

The shape of node_modules changed to save space and dodge a system limit. In npm v2, the dependency tree was strictly nested, producing deep folder structures that often exceeded path length limits on Windows. Since npm v3, the tool uses a flat directory structure, hoisting as many dependencies as possible to the top level of node_modules. This flattening reduces duplication and lets different packages requiring the same version share a single physical installation on disk.

Version conflicts are where the installation algorithm earns its keep. If two dependencies require different, incompatible versions of the same sub-dependency, npm cannot hoist both to the top level. It nests the conflicting version inside the specific package that requires it, while keeping the primary version at the root. This dual-model resolution ensures the Node.js require mechanism finds the correct version of every library based on the filesystem hierarchy.

Since npm 5, the CLI also updates the manifest during installation. If you install a package using npm install <package-name>, the tool automatically adds the entry to your package.json, removing the historical requirement for the --save flag. Running the install command without arguments syncs the node_modules folder with the current package.json and package-lock.json definitions.

package-lock.json and npm ci: why installs stay identical

The package-lock.json file is an automatically generated snapshot of the entire dependency tree as it existed during the last installation. While package.json allows flexible version ranges using caret or tilde, the lockfile records the exact version of every single package installed, including every nested transitive dependency. Every engineer on a team and every build server installs an identical set of files, which eliminates the "it works on my machine" failures caused by one environment pulling a slightly newer minor version.

npm install may update the lockfile while npm ci wipes node_modules and installs exactly from it

The lockfile also verifies what you download, through an integrity field on every entry. This field contains a Subresource Integrity string, typically using a SHA-512 hash, to verify that the downloaded tarball has not been corrupted or tampered with. When an install is triggered, npm compares the hash of the downloaded file against the hash stored in the lockfile. On a mismatch, the CLI aborts, protecting the environment from code that differs from the originally audited version.

The npm ci command is designed for automated environments where deterministic builds are mandatory. Unlike npm install, which is permitted to modify the lockfile if it finds newer compatible versions, npm ci strictly adheres to package-lock.json. It throws an error and fails the build if the lockfile does not match the manifest. It also deletes the existing node_modules folder before starting, so no stale artifacts remain — making it both faster and more reliable for production deployments.

Always commit package-lock.json to version control so the exact state of your dependencies is preserved. The lockfile also speeds up installation by providing a pre-computed dependency tree, reducing the I/O and CPU overhead of resolving complex version constraints. Using npm ci in your deployment pipeline ensures the code running in production is an exact match of the code that passed your tests.

npm scripts: running project tasks with one command

The scripts property in package.json lets you define aliases for complex or frequently used command-line tasks. This provides a standardized way to execute project-specific workflows, such as starting a local development server, running a test suite, or executing a production build. Scripts hide the complexity of underlying tool configurations — long strings of arguments for Webpack, Vite, or TypeScript — behind simple, memorable commands.

Instead of requiring every developer to remember long strings of CLI arguments, you provide a unified interface via npm run <task-name>. A developer only needs to type npm run build to execute a process that might involve multiple environment variables and configuration flags. Standard scripts like start and test are common enough that they can be run without the run keyword, though npm run remains the universal invocation for custom aliases.

These tasks execute within a shell environment that has the local node_modules/.bin folder added to PATH. This is a critical detail because it lets you call installed binaries directly without their full file paths. If you have Jest installed as a devDependency, your test script can simply be jest rather than ./node_modules/.bin/jest. That isolation ensures the project uses the specific version of the tool installed in its own node_modules, rather than a different version installed globally.

Common script configurations include tools for testing, linting, and server execution, and you can chain scripts together using standard shell operators. These tasks are the primary way CI/CD pipelines interact with your project, providing a predictable entry point for building and testing. Centralizing these commands in the manifest means the logic for how the application is built and run is versioned alongside the source code.

json
{
  "scripts": {
    "start": "node server.js",
    "test": "jest",
    "build": "webpack --config webpack.config.js"
  }
}

Install scripts and supply chain risk: what npm v12 changed

The npm ecosystem faces substantial supply chain risk from lifecycle hooks such as preinstall and postinstall, which let package authors execute arbitrary shell code on your machine during installation. Threat actors have weaponized these scripts in campaigns such as the Shai-Hulud worm to steal sensitive credentials, specifically NPM_TOKEN and GITHUB_TOKEN values. Those tokens are then used to infect and republish legitimate packages automatically, creating a wormable propagation vector that compromises the registry at scale.

npm v12 turns three install behaviours into opt-in settings

Attacks like the Miasma RAT have demonstrated increasing technical depth. These payloads target continuous integration runners, reading /proc/<Runner.Worker>/mem to extract OpenID Connect tokens minted lazily in runner memory, then posting them directly to the registry. Others poison GitHub Actions caches, overwriting stored pnpm or npm artifacts so malicious code enters a maintainer's later builds.

Adversaries now use resilient, decentralized command-and-control infrastructure. Recent variants deliver secondary payloads over IPFS and monitor Ethereum smart contracts as a decentralized registry of active nodes, with Nostr relays and BitTorrent DHT routines as fallbacks when primary addresses are blocked. One payload installs a background service that polls the GitHub API with the stolen token every 60 seconds; if the token is revoked, it executes rm -rf ~/ and destroys the user's home directory.

npm v12 answers this by changing the defaults themselves. Three install behaviors that used to run automatically are now opt-in: allowScripts defaults to off, so dependency lifecycle scripts (preinstall, install, postinstall) and implicit node-gyp builds no longer run; --allow-git defaults to none, so Git dependencies are no longer resolved; and --allow-remote defaults to none, so dependencies from remote URLs are no longer resolved. To review and approve the scripts you trust, run npm approve-scripts --allow-scripts-pending and commit the resulting allowlist in package.json. All three were available behind warnings in npm 11.16.0+, so you can prepare before upgrading.

npm, Yarn or pnpm: how they differ

While npm is the industry standard, Yarn and pnpm take different architectural approaches. npm historically used a file-copying model to build node_modules, which consumes significant disk space when multiple projects share dependencies. pnpm instead uses content-addressable storage, with all packages kept in a single global store on disk and linked into project-specific node_modules via hard links and symlinks. That reduces I/O overhead and prevents phantom dependencies, where code can access packages not explicitly listed in its manifest.

Three dependency layouts: the hoisted flat tree of npm and Yarn Classic, Yarn PnP, and pnpm's content-addressable store

Yarn was originally developed to address performance and consistency issues in older versions of npm, introducing its own lockfile format. Each manager has distinct performance characteristics; pnpm is generally faster at installation thanks to its linking strategy and avoidance of redundant network requests. Yarn pioneered many of the caching and parallelization techniques that were eventually adopted into the npm CLI itself.

The internal structure of node_modules varies between these tools, which affects how the Node.js runtime resolves modules. npm and Yarn typically create a flatter folder structure to manage deep dependency trees, whereas pnpm's symlink-heavy approach creates a nested structure that more accurately reflects the real dependency graph. That forces you to be explicit about the libraries you use, since sub-dependencies are not hoisted to the root by default. Each tool uses its own lockfile — package-lock.json, yarn.lock, and pnpm-lock.yaml respectively — and all three talk to the same public registry using the same package.json format.

Choosing between them is a trade-off between standard compatibility and performance. npm is the most compatible and ships with Node.js, which makes it the safest default. pnpm is preferred for monorepos or machines with limited disk space. Whichever you pick, never mix two managers in one project: their lockfiles conflict and the resulting environment stops being reproducible.

Where to start with npm

Start a new project with npm init to generate the manifest, then lean on the two commands that keep an environment predictable: npm ci for every automated pipeline, and npm audit on a regular cadence to surface known vulnerabilities in your dependency tree. Commit package-lock.json to version control so the whole team resolves to the same tree.

npm remains the right default for most projects, provided you understand three things: how version ranges are locked, how integrity hashes protect what you download, and what npm install is allowed to execute on your machine. If you run dozens of projects on one machine, evaluating pnpm for its disk efficiency is worth the afternoon — see DevOps practice for where these commands sit in a delivery pipeline.

References

Share this article