Concept
Why package managers exist
JavaScript modules have external dependencies. A package manager:
- Resolves which version of each package to install (respecting semver ranges)
- Downloads packages from a registry (npm, JSR, or private)
- Links them into
node_modulessorequire()/importcan find them - Writes a lockfile so the exact resolved versions are reproducible across machines
Semantic Versioning (semver)
Every package follows MAJOR.MINOR.PATCH:
- MAJOR (1.x.x → 2.x.x): breaking changes
- MINOR (1.1.x → 1.2.x): new features, backwards-compatible
- PATCH (1.1.1 → 1.1.2): bug fixes, backwards-compatible
Version ranges in package.json:
"react": "^18.2.0" // >=18.2.0 <19.0.0 (caret: minor+patch updates)
"react": "~18.2.0" // >=18.2.0 <18.3.0 (tilde: patch updates only)
"react": "18.2.0" // exactly 18.2.0
"react": ">=18.0.0" // any 18+
"react": "*" // any version (dangerous)The lockfile (package-lock.json, yarn.lock, pnpm-lock.yaml) pins every resolved version exactly. Without a lockfile in CI, ^18.2.0 today might resolve to 18.3.0 tomorrow after a new release, breaking your build.
npm (Node Package Manager)
The default. Ships with Node.js. Registry: registry.npmjs.org.
npm install # install all deps from package.json
npm install react # install + add to dependencies
npm install -D typescript # install + add to devDependencies
npm install -g nodemon # install globally
npm ci # clean install from lockfile (for CI)
npm update # update to latest within semver ranges
npm audit # check for known vulnerabilities
npm run build # run the "build" script from package.jsonKey files:
package.json, manifest (name, version, scripts, deps)package-lock.json, exact resolved versions (v2 format: npm 7+; v3: npm 10+).npmrc, config (registry URL, auth tokens, cache path)
npm ci vs npm install: Always use npm ci in CI pipelines. It:
- Fails if
package-lock.jsonis missing or mismatched - Deletes
node_modulesfirst for a clean install - Never updates the lockfile
- Is faster than
npm installin CI
yarn (Classic/Berry)
Yarn Classic (v1) was created by Facebook in 2016 to address npm v3's non-deterministic installs and lack of a lockfile. It introduced yarn.lock and parallel downloads.
yarn # install all deps
yarn add react # add dependency
yarn add -D typescript # add devDependency
yarn remove lodash # remove
yarn upgrade # update depsYarn Berry (v2/v3/v4) introduced Plug'n'Play (PnP):
- No
node_modulesfolder - Packages stored in
.yarn/cacheas zip files require()intercepted by a.pnp.cjsresolver- Zero-installs: commit
.yarn/cacheto git,yarn installbecomes instant in CI
PnP is powerful but requires all tools (IDEs, bundlers) to support it. Many teams stay on Yarn Classic for compatibility.
pnpm
pnpm's key innovation: a content-addressable store on disk, shared across all projects.
pnpm install # install all deps
pnpm add react # add dependency
pnpm add -D vitest # add devDependency
pnpm remove lodash
pnpm dlx create-vite # equivalent of npxpnpm's node_modules structure
Instead of copying packages into node_modules, pnpm creates hard links from a central store (~/.pnpm-store). If 10 projects use React 18.2.0, there's only one copy on disk. Each project's node_modules has hard links pointing to the store.
This has two major effects:
- Disk space: installing the same package across multiple projects is cheap
- Phantom dependency prevention: packages are not hoisted by default. Your code can only import packages you've explicitly declared as dependencies
Phantom dependencies, the pnpm killer feature
With npm/yarn, node_modules is flat, packages your dependencies depend on are hoisted to the top level. This means you can accidentally import a package you didn't declare:
// You didn't add "lodash" to package.json
// But "some-lib" depends on it, so it's in node_modules
import _ from 'lodash'; // works in npm/yarn, breaks randomly when some-lib updatespnpm prevents this: only your declared dependencies are in the direct node_modules.
pnpm workspaces
pnpm has excellent monorepo support:
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'pnpm --filter @myapp/ui build # run build only in @myapp/ui
pnpm -r build # run build in all packagesComparison
| Feature | npm | yarn classic | yarn berry | pnpm |
|---|---|---|---|---|
| Lockfile | package-lock.json | yarn.lock | yarn.lock | pnpm-lock.yaml |
| Speed | Medium | Fast | Fastest (PnP) | Fast |
| Disk usage | High | High | Low (cache) | Low (shared store) |
Common Mistakes
1. Not committing the lockfile
Lockfiles ensure reproducible installs. Never .gitignore your lockfile. Every engineer and CI machine gets the exact same package versions.
2. Mixing package managers
Each manager has its own lockfile format. Running npm install in a project with yarn.lock creates a package-lock.json that may conflict. Pick one and enforce it with a .npmrc or engine check.
// package.json, prevent accidental use of wrong manager
{
"engines": { "node": ">=20", "pnpm": ">=8" },
"packageManager": "pnpm@8.15.0"
}3. Using * or very wide version ranges
"lodash": "*" will install any version, including a major breaking change. Use ^ or pin exactly in critical dependencies.
4. Global installs for project tools
npm install -g prettier means the version is machine-specific. Anyone else on the project might run a different Prettier version and produce different formatting. Install dev dependencies locally and run via npx or package.json scripts.
5. Installing production deps as devDependencies (or vice versa)
dependencies are bundled into the production build. devDependencies are not (for some tools/environments). TypeScript, ESLint, testing libraries → devDependencies. Runtime libs like React, axios → dependencies.
Best Practices
- Always commit the lockfile. Run
npm ci/pnpm install --frozen-lockfilein CI. - Run
npm audit/pnpm auditin CI. Fail the build on high-severity vulnerabilities. - Use
pnpmfor new projects, better disk efficiency and phantom-dependency protection. - Pin major versions: use
18.x.xranges, not*. Update majors deliberately. - Keep
node_modulesout of docker layers: copy only + lockfile first, install, then copy source. Docker caches the install layer unless deps change.
Performance Tips
npm ci>npm installin CI: 2, 3x faster because it skips resolution.- Restore
node_modulesfrom cache between CI runs using the lockfile hash as the cache key. GitHub Actions example:cache: 'npm'in thesetup-nodeaction. - pnpm's store is ideal for monorepos, one store for all packages, deduplicated on disk.
- Use
--prefer-offlinein CI if your registry is unreliable, falls back to local cache.
