If you have been working on JavaScript projects for a while and have used npm, you have probably run into issues with package versions. Maybe your automated build fails, while everything works just fine on your laptop. Or you cannot get an existing project to run on your new machine, while your team mates have no problems at all with the exact same code base. Version differences between npm packages can cause quite some headaches, so how can you keep some sanity in all of this? Let’s examine how it all works.

Your package.json file lists what your project needs

It all starts with the package.json file. This file lists all node packages that your projects depends on. This file can specify versions in three different ways:

7.2.0 Install exactly version 7.2.0
~7.2.0 Install the latest 7.2.* version
^7.2.0 Install the latest 7.*.* version

When you run npm install to install all required packages, npm will determine at that time which versions of packages to install. This means that there may be differences in the installed package versions, depending on the time that you run npm install. Let’s look at an example.

My package.json file specifies that my project requires version ~7.2.0 (so any 7.2.* version) of some package. When I run the command today, 7.2.0 is the latest available version, and that’s what I will get. The next day, the authors of this package update their package to version 7.2.1. Now the day after, when my colleague runs npm install on the exact same package.json file, he will get version 7.2.1 of that package.

Minor differences between versions could break the build

As you can see, this can be quite a ‘relaxed’ way of specifying versions. This has the potential benefit of receiving minor upgrades and security patches of your dependencies, without requiring any configuration change on your part. That’s a good thing, and during active development, that’s probably what you want. Besides, theoretically, minor and patch updates should never contain any breaking changes…

However, we don’t live in a perfect world, and sometimes even the smallest update of a package can introduce problems. So if your build server installs a slightly different version than your development laptop, things may suddenly and unexpectedly break. So how do we deal with this?

We could resort to only specifying exact versions in our package.json file. But this has two drawbacks. First, we lose the benefit of easy updates to minor or patch versions. And secondly, we still cannot control our dependencies’ dependencies.

Let’s say we depend on version 1.1.0 of Package A, and Package A in turn depends on version ^2.1.1 of Package B. Notice the ^ in front: package A only depends on the major version, any version 2.* will be allowed. In this scenario, we cannot guarantee which version of Package B we get. We might get version 2.1.1, but it could also be 2.1.8 or 2.3.0, since Package A only specified a dependency on the major version number. So while we have locked down our direct dependency, we still don’t control the entire package tree.

Fortunately, there are some mechanism to lock down the exact versions. Let’s see how that works.

The package-lock.json file “logs” which versions were installed

When you run npm install, npm creates a package-lock.json file. The name is somewhat confusing, as it might suggest that this file somehow “locks down” which version of packages gets installed. Instead, it is best thought of as a “log”: a file that lists which exact versions of packages were installed at the time. When you run npm install again after a few days or weeks, when new versions of some packages are available, npm will update the existing package-lock.json file to create an updated log of the installed package versions.

Note that the package-lock.json lists all packages that are installed. So not just the primary packages on which your project depends, but also of those packages’ dependencies. In this way, the package-lock.json file is an exact manifest of all packages that were installed at a certain time. This is very helpful! It means that when we have a working installation on our development laptop and we commit the corresponding package-lock.json file, we at least have a ‘recipe’ that can guarantee a working configuration.

Now we just need one more missing piece: how can we restore a working installation from this committed package-lock.json file?

Use npm ci to install exact package versions from the lock file

That’s where the npm ci command comes in.

> npm ci

This command is similar to npm install, but it will look at the package-lock.json file instead of the package.json file, and it will install the exact versions that are listed in that file. This way, you can guarantee that your automated build environment will install the exact same versions that you installed on your dev machine.

And as a nice bonus: the npm ci command is actually about twice as fast as the regular npm install command, so you get a nice optimization of your automated build pipeline for free!

Hopefully, these tips help you to get some measure of control over what could quickly become npm dependency hell. If you have other tricks to keep your npm versioning under control, let them know in the comments!

See also