NPM is confusing.
npm ci vs. npm i came up in conversation recently, and I realised I didn't actually know the difference. So let's dig in.
npm i
Most of us are very familiar with this command. It's the one we use day to day, but it might not necessarily work in the way you expect. npm i will of course, install the packages as defined in your package.json file — that much we know. But how exactly does this work?
Firstly, npm will compare the package.json file with the package-lock.json file. If the version in the package-lock.json file satisfies the version range in the package.json file, the version from the package-lock.json file is used. For example, if the package.json file specifies ^1.0.0 and the lock file contains version 1.5.3, the version in the lock file will be used. If, however, the package.json specifies ^3.0.0 but the lockfile contains 1.4.5, the lockfile entry is no longer valid for that range, so npm will resolve and install the latest version that satisfies ^3.0.0, and update the lockfile accordingly.
So, what does that mean and how has that challenged my understanding? Well, firstly, I thought that the ^ and the ~ made a bigger difference than they do. In reality, unless the package is missing from the lockfile, the lockfile doesn't exist, or the locked version no longer satisfies the range in package.json, that semantic versioning won't come into play at all when running npm i. Furthermore, although not entirely deterministic, npm i is more deterministic than I originally thought! In most cases, where there is a lock file that is in sync with the package.json file and everyone is on the same version of NPM, the output should be relatively stable. It's only when this isn't the case that npm i will go rogue, resolving packages as best it can. That's where npm ci comes in...
npm ci
So, how is npm ci different and what does it do? In essence, it does the same as npm i, but it's designed to work in automated environments, for example, deploy scripts or CI/CD. But why is that and how does it differ from npm i?
Probably the most important distinction to make is that npm ci is more stable, and deterministic, than npm i. As we discussed above, npm i is capable, albeit in a small number of scenarios, of altering the version of the package(s) used. It compares the lock file and the package.json file and determines what to install. Depending on the compatibility of these files, and their mere existence, the outcome can be very different.
Conversely, npm ci won't run without a lock file and it won't alter versions. It will still compare the two files, but if there are any inconsistencies it'll bail. The reason for this is quite simple: determinism. If it doesn't know the exact versions to use, it does nothing. This avoids scenarios where deploying to production or running in a CI/CD environment produces different outcomes than expected. And nobody wants that!
Another distinction is this: npm ci will always remove node_modules when run. It does a "clean" install, hence the command. It also can't be used to install a single dependency.
Consider this: you're working on a feature locally that uses a third-party module. That third-party module is running on v1.1.1. You finish the feature and deploy it to production. But wait! The version on production is 1.1.2, and, unbeknownst to both you and the package author there's a regression in that version. You frantically try to debug the issue locally, only to realise it's a version mismatch — your local environment and production are out of sync. This will not happen when using npm ci.
Use npm i locally when you need flexibility; reach for npm ci the moment determinism matters.