npm update is a surprisingly blunt instrument, often leading to unexpected breakage because it prioritizes getting the latest version over maintaining compatibility.
Let’s see it in action. Imagine you have a simple package.json:
{
"name": "my-app",
"version": "1.0.0",
"dependencies": {
"express": "^4.17.1",
"lodash": "^4.17.21"
}
}
Running npm update here could pull in the absolute newest minor or patch versions of express and lodash. If a recent express update, say from 4.17.1 to 4.17.2, introduced a subtle change in how middleware is handled, your application might suddenly fail to start or behave erratically, even though the version number looks compatible. The ^ (caret) in package.json allows updates within the same major version, which includes minor and patch releases. While intended for bug fixes and minor improvements, these can sometimes introduce breaking changes.
The core problem npm update tries to solve is keeping your project’s dependencies current. This is crucial for security, performance, and accessing new features. However, the way it operates is by looking at your package.json, consulting the npm registry for the latest available versions that satisfy the semantic versioning (semver) ranges you’ve specified (like ^4.17.1 or ~1.2.3), and then updating your package-lock.json and installing those new versions.
The mental model here is that npm update is not a "smart" upgrade tool; it’s a "latest compatible" tool. It respects semver ranges, but semver itself doesn’t guarantee zero breaking changes within a minor version.
The levers you control are primarily the version specifiers in your package.json. Using exact versions (e.g., "express": "4.17.1") prevents npm update from touching it. Using tilde (~) allows patch updates (e.g., ~4.17.1 would allow 4.17.2 but not 4.18.0), which are generally safer but not always. Using caret (^) allows minor updates (e.g., ^4.17.1 allows 4.18.0 and 4.17.2), which is the default for npm install and offers more flexibility but also more risk.
To upgrade dependencies more safely, you should use npm outdated to see what’s available, then npm install <package-name>@<version> for specific packages you want to upgrade, and critically, run your tests after each targeted upgrade. For example, to upgrade lodash to its latest patch version: npm install lodash@^4.17.21. If your tests pass, great. If not, you can easily revert by deleting node_modules and running npm install again (which will use your package-lock.json with the old version).
The real power comes from understanding that npm update is a command that operates on your package-lock.json (or npm-shrinkwrap.json). It reads the ranges in package.json and finds the newest versions that satisfy those ranges, writing the exact versions it installed into the lock file. If you want to force a specific version that’s newer than what your lock file currently points to, you often need to use npm install <package-name>@latest or npm install <package-name>@<specific-version>.
The next step after safely upgrading is managing transitive dependencies.