Some context

NodeJS ecosystem is built on top of countless packages that sometimes feel all dependent on each other. Usually you start up a project with all up-to-date dependencies, everything works well.

Then you get security alerts, vulnerability issues and all sorts of incentive making you update the packages whenever you get prompted to do so by some dependabot 🤖. Until a point where the newer version introduces a breaking change, or it is incompatible with another package you were using.

Then starts the decay! 💩 Soon most of your dependencies start to get outdated, and npm starts to sprawl endless warnings at each installation. You are at the back of the wall, and now is time to up your sleeves and start digging, because you’ve end up… in the dependency hell.

Path to resolution

This dependency situation can happen for any reason, so don’t mind me and skip ahead to the section that is most the appropriate. And as always feel free to share any tips that I haven’t mentioned along the way.

1. The Audit trap

Npm has an audit functionality that can be used to identify which packages are responsible for the vulnerabilities. The easy fix is to use the npm audit fix which will look for updates that can be updated to fix those automatically. But it only changes dependencies in the package-lock.json and you might be better off updating the parent dependency in the package.json instead to keep it consistent.

npm audit       
# npm audit report

@babel/traverse  <7.23.2
Severity: critical
Babel vulnerable to arbitrary code execution when compiling specifically crafted malicious code - https://github.com/advisories/GHSA-67hx-6x53-jw92
fix available via `npm audit fix`
node_modules/@babel/traverse

# Other vulnerabilities...

You don’t have to blindly update everything at once just to realize that everything is now broken, take it step-by-step one vulnerability at a time using:

npm update {dependency}

This way you’ll be able to update the dependency to the latest version that is not a breaking change, run the tests, build and compile if you are using typescript, to make sure everything is still ok.

But that’s to avoid the problems, 😈 so let’s see how to identify the problems when things start breaking.

2. Check your package.json and package-lock.json

The package.json is used to add the direct dependencies of your project. Then the package-lock.json is used to mark the dependencies of your dependencies, usually called the dependency tree. This tree is used for the dependency resolution.

Here is a schema to describe it:

flowchart LR subgraph your package direction TB P[package.json] --> |" A, B, C "| PL[package-lock.json] end subgraph deps[Dependencies] direction TB subgraph DA[Dependency A] PA[package.json] end subgraph DB[Dependency B] PB[package.json] end subgraph DC[Dependency C] PC[package.json] end end subgraph DS[Sub Dependency S] PS[package.json] %% --> PSL[package-lock.json] end R{npm \n resolves} PA --> |v2.5.3| R PB --> |v3.2.0| R --> DS DC --> | npm i depC | P DS --> |" S (v2.5.3) "| PL deps --> | npm i depA depB | P

You can see that the sub dependency got resolved to a specific version which is then saved in the package-lock.json.

3. Check what’s installed

Sometimes the package-lock.json doesn’t reflect what’s actually installed in the node_modules/.. That can happen when installing some packages, and discarding the changes on the lock files. Or any other reason after some manual changes.

To see what’s directly installed, use the listing command of npm:

# List all packages locally installed
npm list
npm list {package} 
# Show latest registry version
npm view {package}

This will show you precisely what has been installed. It will also warn you if something is missing or invalid. Even better, you can also easily target which package may have trigger a vulnerability alert so you can update the main repo instead of fixing the package-lock.json file:

npm list @babel/traverse 
example@0.0.0 /Users/me/projects/example
├─┬ jest-circus@27.5.1
│ └─┬ jest-snapshot@27.5.1
│   └── @babel/traverse@7.12.13
└─┬ ts-jest@27.1.5
  └─┬ @babel/core@7.12.13
    ├─┬ @babel/helper-module-transforms@7.12.13
    │ ├─┬ @babel/helper-replace-supers@7.12.13
    │ │ └── @babel/traverse@7.12.13 deduped
    │ └── @babel/traverse@7.12.13 deduped
    ├─┬ @babel/helpers@7.12.13
    │ └── @babel/traverse@7.12.13 deduped
    └── @babel/traverse@7.12.13 deduped

It seems like it depends on both jest-circus and ts-jest in this case, better update those to a later version to get rid of the vulnerability with @babel/traverse@7.12.13.

4. Update a package

Usually between major versions of a package (like from v3.x.x to v4.x.x), they may very likely be some breaking change. Meaning your project won’t build or some tests will fail In those case you should check:

  • Which version of the package has been installed?
    • Update/Set the wanted version in your package
    • Update dependencies breaking to a version where their dependency matches the installed package’s version
  • What are the breaking changes coming with it?
    • Implement the recommended update from the package’s documentation

When using yarn, the npm update equivalent is yarn upgrade, different name, same behaviour. But don’t mix yarn and npm when updating your packages, stick with one.

Also, if you have two dependencies using two different major versions, you might be able to resolve on a version, but your project may not behave like expected.

For example, some tests may start failing for weird reason, saying that some unknown method “is not a function”.

Then try to update the version of your dependencies, so they depend on the same sub dependencies’ version. You can check directly in the node_modules/* or using npm list package.

5. Refresh your package-lock.json

After updating your dependencies, installing and removing some modules, you may realize that your package-lock did not necessarily follow everything. It may still contain unused packages, and you would like to refresh it without bothering with re-installing the node modules.

For that use the –package-lock-only arguments when installing:

npm i --package-lock-only

Bear in mind that it is not synced with the already installed node modules. You can also use dedupe command which attempts to simplify the package structure by moving dependencies further up the tree when possible:

npm dedupe

Keep in mind that when you delete and re-install the package-lock, it may change as dependencies are resolved differently if it has been some time since you last updated, which leads us to the next section.

6. Working for everyone but you

You are cursed!! Don’t go into desperation Why only me!?, let’s try something. Now that the project have been updated, and it works for everybody but you 🥲. You might just have messed up everything on your PC, don’t worry though, it won’t stay like that forever.

Using git, if you have modified the package* files on your local machine, just discard the changes and fetch the latest working version:

git checkout package-lock.json
git checkout package.json

Without git 💀 …ahem, here copy over the matching package.json and package-lock.json from a trusted friend 🌞 from whom it is working and run:

npm ci

This will do a clean installation of the modules, removing the previous node modules and won’t try to update the dependencies (which can happen with npm install).

7. Make a plugin? Add its peer

Another thing you can do for the future to avoid dependency problems when you work with your own packages and plugins. Make sure you start using the peerDependencies. You can use the peer dependency in your package.json such as:

{
  "name": "plugin for depC",
  "version": "1.4.7",
  "dependencies": {
    "depA": "^2.5.1",
    "depB": "^0.12.9"
  },
  "peerDependencies": {
    "depC": "1.4.x"
  }
}

Peer dependency is used, for example, by plugins, it’s a bit like for DLC in games, you can’t install the DLC if you don’t have the game. Well you can’t install the plugin (plugin for depC) if you don’t have its peerDependency; the library (depC).

It’s a concept different from the dependencies because the plugin could work on its own and while being linked to another package, does not depend on it. The devDependencies is also not a good candidate, because it is usually used for packages that are only used in your tests. So the peerDependencies are the only way to display that link between plugin and its host library.

In our case we use a flexible version ("1.4.x")for the pear dependency to avoid unnecessary conflicts Now by default with npm v7+ the peer dependencies are installed automatically.

8. Handle multiple common dependencies

When multiple of your dependencies relies on different versions of the same package, there could be some interferences. To force a version on certain package, you can use the overrides which will force a specific version.

{
  "overrides": {
    "mongoose": {
      "mongodb": "^5.7.0",
      "bson": "^5.4.0"
    }
  }
}

Here I’m forcing the mongoose package to use those versions of mongodb and bson so it matches other dependencies defined in my package.json. You will need to run npm i once again, and if it doesn’t work (as it may), delete the package-lock.json and node_nodules, before installing it again.