Articles · GuidesUpdated October 2026

When a dependency breaks your build, the answer is the lockfile you already have.

A dependency breaks your build when today's install produces a different tree from the install that built the version you shipped. Your code did not change. The tree did. There are four usual reasons. A version range in package.json let a newer release in. A package two or three levels down published something. The install ran without the lockfile that recorded the working tree. Or a peer dependency conflict stopped the install before it finished. Every step and field name below comes from npm's own documentation, read on 16 October 2026. For most people this is the last gate before publishing to the App Store.

What follows is how to read the failure, what a clean install does that an ordinary one does not, how to get back to the tree that built, and what to change so the next release does not do this. The fix is boring and it holds: commit the lockfile, then install from it everywhere except your own machine when you are adding a package.

Start here

The short version

A build is only repeatable if the install is.

npm is explicit about what the lockfile is for. It describes the exact tree that was generated, such that subsequent installs are able to generate identical trees, regardless of intermediate dependency updates. That last clause is the part that saves you. A release published yesterday by a package you have never heard of cannot change your tree if your install is reading a file that names every version in it.

The command that honours the file is npm ci, documented for automated environments such as test platforms, continuous integration and deployment. It requires a lockfile, removes node_modules first, refuses to install one package at a time, and never writes to package.json or the lock. If the lock and package.json disagree, it stops. Most teams who get bitten by this were running npm install on the machine that cut the release, which is allowed to change both files while it works.

Why does a build that worked last month fail today?

Because package.json does not record versions. It records ranges. A caret range accepts any later release that the publisher considers compatible, and npm works out what that means at install time, not at the time you wrote the line. So an install that is free to resolve will take the newest release that fits. That is the behaviour you asked for when you installed the package, and it is the behaviour that turns a quiet Tuesday into a broken release.

The second reason is depth. The package that broke is usually not one you installed. It is a dependency of a dependency, and your package.json has no opinion about its version at all. npm notes that since v7 the lockfile contains enough information to describe the full package tree, which reduces the need to read individual package.json files. That is why the lockfile, and not package.json, is the file that tells you what you actually shipped.

The third reason is that the lockfile existed and was not used. npm install treats the lock as a starting point and will rewrite it when package.json says something different. That is correct for a developer adding a package and wrong for a release machine. If your build server runs npm install, it is allowed to produce a tree that nobody has ever tested, and to commit nothing about it.

The fourth is peer dependencies, where two packages want incompatible versions of a third. npm resolves conflicting peer dependencies deep in the graph using the nearest non-peer dependency specification, and prints a warning explaining the conflict and the packages involved. Set strict-peer-deps and that warning becomes an install failure, even where npm could reasonably guess the appropriate resolution from non-peer relationships. The default is false. One flag therefore decides whether the same tree is a warning on your laptop and an error in CI.

npm docs, package-lock.json, read 16 October 2026, page last edited 4 October 2025

Read the log first

What the failure is telling you

Tick what you are actually seeing. The point is to stop guessing which package is at fault before you know which kind of failure this is.

Nothing ticked yet. If none of these match, the build is probably failing for a reason that has nothing to do with dependencies, and the log will say so near the top.

What does npm ci do that npm install does not?

npm describes the command as similar to npm install, except that it is meant to be used in automated environments such as test platforms, continuous integration and deployment, or any situation where you want a clean install of your dependencies. The documentation lists five differences, and each one removes a way for your tree to change behind your back. The project must have an existing package-lock.json or npm-shrinkwrap.json. If dependencies in the package lock do not match those in package.json, npm ci will exit with an error, instead of updating the package lock. It can only install entire projects at a time, so individual dependencies cannot be added with it. If a node_modules is already present, it will be automatically removed before the install begins. And it will never write to package.json or any of the package-locks: installs are essentially frozen.

Read the second one again, because people treat it as an obstacle. An install that exits rather than rewriting the lock is the only thing standing between a stray edit to package.json and a release built from a tree nobody reviewed. The error does not mean your project is broken. It means two files disagree and somebody needs to decide which one is right. The command has aliases, clean-install and install-clean among them, which is a reasonable description of what it is for.

Two footguns come with it. The first is flags: npm says you must provide the same flags to npm ci as you did to the install that produced the lock, or you are likely to encounter errors. The second is the environment. The default for the omit option is dev if the NODE_ENV environment variable is set to production, and otherwise empty, and if the resulting omit list includes dev then NODE_ENV is set to production for all lifecycle scripts. A release machine with NODE_ENV set to production installs a narrower tree than your laptop and runs your scripts in a different mode. That is a very good explanation for a build that only fails in one place.

Worth knowing before you go looking for the file: if both package-lock.json and npm-shrinkwrap.json are in the project root, npm-shrinkwrap.json takes precedence and package-lock.json is ignored. The two share a format. The difference is that the shrinkwrap can be published and the lock cannot, and the lock is ignored anywhere other than the root project. npm advises against the shrinkwrap unless you are deploying a command line tool or producing production packages through publication.

npm docs, npm-ci, read 16 October 2026, page last edited 5 October 2025

How do you get back to the tree that built?

Start in git, not in npm. The lockfile is a tracked file, so the commit or tag that produced your last working build still has the exact tree beside it. Check out that version of the lockfile, run a clean install, and build. If it builds, you have proved the problem is a dependency rather than your code, and you have a working release you can ship today while you work out which package to blame. If it still fails, stop looking at dependencies: something else changed, usually a toolchain or a certificate.

That recovery only works because the install is frozen. npm is clear that a clean install never writes to package.json or any of the package-locks, so the tree you check out is the tree you get. An ordinary install would see your current package.json, decide the restored lock is out of date, and quietly rewrite it back to the state you are trying to escape. People lose an hour to this and conclude that reverting the lockfile does not work.

Then narrow it down. Diff the restored lockfile against the broken one and you have a list of every version that moved. It is normally short, and one line in it is the answer. Each entry names where the package was actually resolved from and carries an integrity hash for the artifact that was unpacked in that location, so you can see not just which version changed but which tarball. Work forward one package at a time, installing cleanly between each step, instead of upgrading everything and hoping.

When the offender is a package you do not depend on directly, you cannot fix it in your dependencies block. Use overrides, which npm documents as a way to replace a package in your dependency tree with another version, or another package entirely. The field is only read from the root package.json, so adding it inside a dependency or a workspace does nothing. You also may not override a package you depend on directly unless the dependency and the override share the exact same spec, and prefixing the name with a dollar sign references the direct dependency's own spec rather than repeating it.

One practical note before you upload the rescued build. A failed native build can still have consumed a build number, and the next upload needs one that has not been used yet, so check your version numbers before you push rather than after the upload bounces.

npm docs, npm-ci, frozen installs, read 16 October 2026

What stops this from happening on the next release?

Commit the lockfile. npm says the file is intended to be committed into source repositories, and gives the reasons: one fixed tree for teammates, deployments and continuous integration, a way to return to earlier states of node_modules without committing the directory itself, readable diffs when the tree changes, and faster installs because npm can skip repeated metadata resolution for packages it has already installed. If the file is in your gitignore, nothing else on this page can help you.

Then install from it everywhere that is not a developer adding a package. Build servers, release machines and anything that produces an artifact you upload should use the clean install. The point is not speed. The point is that a mismatch between package.json and the lock becomes a loud failure on the machine that caused it, rather than a silent tree change on the machine that ships.

Pin the npm version while you are at it. The lockfileVersion field records which generation wrote the file: 1 for npm v5 and v6, 2 for npm v7 and v8 with backwards compatibility to v1, and 3 for npm v9 and above with backwards compatibility to npm v7. A file with no version is an older shrinkwrap from before npm v5. If that number moves in a diff and nothing of substance does, two people are installing with two different npm versions, and the file will churn every week until somebody fixes it.

Two settings are worth a look once the basics hold. Setting strict-peer-deps turns conflicting peer dependencies into an install failure instead of a warning, which you want if a quiet override has bitten you before; it defaults to false. Setting save-exact makes dependencies saved to package.json use an exact version rather than a range, which narrows the gap between the two files; it also defaults to false. Neither is a substitute for the lockfile. Both make the lockfile less exciting to read.

Last, timing. Do not bump dependencies in the same change as a release. A build failure costs you an hour and nobody outside the team sees it. A dependency that builds but behaves differently costs you a review cycle, because reviewers test the app and not the tree, and then you are appealing a rejection over a line you did not write. Upgrade on a quiet day, build, test, then release something boring.

npm docs, package-lock.json, why it is committed, read 16 October 2026

The two installs, side by side

What happensnpm installnpm ci
Needs a lockfile that already existsNoYes
package.json and the lock disagreeRewrites the lock to matchExits with an error
Can add a single packageYesNo
Existing node_modulesUpdated in placeRemoved before the install begins
Writes to package.json or the lockYes, that is the jobNever, installs are frozen
What it is documented forAdding and updating dependenciesTest platforms, CI and deployment

Where this lands if a builder generated your app

Nothing above changes because an AI wrote the first version of the code. A generated project still has a package.json full of ranges, still has a tree three levels deep, and still breaks when somebody else publishes. What changes is whether you can see any of it.

Newly is an AI app builder. You describe an app in plain English and it writes a real React Native and Expo project that you own. It has two-way GitHub sync, so package.json and the lockfile are ordinary tracked files in your repository, with the history that makes the recovery above possible. It uploads iOS builds to TestFlight, publishes to Google Play internal testing and builds a standalone Android APK. It costs $25 a month and there is no free tier.

The honest limits are worth saying plainly. No builder stops a package you depend on from publishing a breaking change, and no builder can tell you which of your own tests should have caught it. Newly also does not submit to the App Store for you, because Apple requires the content owner to submit under guideline 4.2.6, so the release machine in this story is yours. One small comfort: a build that never compiles never reaches a reviewer, so this whole category of failure is invisible in rejection rates. It costs you time, not a review.

Questions people ask when a dependency breaks the build

Because package.json records ranges, not versions, and an install that is free to resolve a range will take the newest release that fits it. Your code is the same and your dependency tree is not. The usual culprit is a package you never installed directly, sitting two or three levels down, which published a release since your last build. A committed lockfile prevents this: npm's documentation says the lockfile describes the exact tree that was generated, such that subsequent installs are able to generate identical trees, regardless of intermediate dependency updates.

Commit the lock, then forget about it

Check the file is tracked, switch your release step to a clean install, and this stops being a thing that happens to you. It takes about ten minutes and it buys back every afternoon you were going to spend bisecting a tree.

Start building