All notes
MaintenanceAugust 8, 20269 min read

Safely updating major dependencies

Major version bumps signal breaking changes, but not all breaks are equal. A repeatable staging process turns a risky upgrade into a controlled migration.

Semantic Versioning gave the JavaScript ecosystem a shared language. Around 2010, npm and the first SemVer conventions were turning version numbers into a practical contract for a rapidly growing package ecosystem. Bump the major version, and you are telling consumers that code may need to change. The npm registry, launched in 2010, made that contract useful at scale. Today, millions of packages publish under SemVer, and everynpm install resolves ranges by reading those three dot-separated numbers.

But SemVer is a social convention, not a compiler pass. A major bump from a well-maintained project often ships with migration guides, deprecation warnings from the previous minor series, and codemods that automate the mechanical part of the upgrade. React's Fiber rewrite in 16, Hooks in 16.8, Angular's 2.x rewrite, and Node.js's gradual ESM rollout all arrived with substantial migration guidance. The worst upgrades are not necessarily the biggest ones --- they are the ones you did not plan for.

1. Inventory what you have and what depends on it

Start by listing every direct dependency and the major version you are on.npm outdated shows wanted and latest. npm ls reveals nested peer dependencies that may block a move. Pay special attention to packages with peerDependencies --- a major bump there often forces an upgrade in every project that consumes yours.

Check the engines field in each package's manifest. A library that drops Node.js 16 support may look like a minor bump but effectively act as a major constraint change. Use npm view <pkg> engines or read thepackage.json in the published tarball to confirm.

2. Read the release notes and changelog

Every well-maintained project publishes a changelog or a GitHub Releases page alongside a major version. Look for three things: removed APIs, renamed exports, and behaviour changes that are not guarded by a feature flag. The migration guide is often linked from the release notes or from aUPGRADING.md in the repository root.

Many projects also provide codemods. React, Angular, Ember, and Jest all shipjscodeshift-based transforms that rewrite your source tree to match the new API. Running a codemod before the manual edit handles much of the mechanical work and leaves you with a diff that is mostly the intentional refactoring.

Historical notejscodeshift was released by Facebook in 2015, the same year React Native and Relay debuted. Its codemod pattern was quickly adopted by the wider ecosystem and remains the standard way to automate breaking-change migrations.

3. Create a dedicated branch for each major family

A single branch that upgrades React, a bundler, a testing framework, and a database driver all at once produces a diff that is impossible to review and impossible to bisect. Instead, create one branch per upgrade family. A family is a set of packages whose versions are coupled: React and React DOM, or a GraphQL client and its codegen plugin.

Name the branch so the target version is obvious:deps/react-19 or deps/next-15. This convention makes it easy to see which upgrades are in flight and which have stalled. It also lets you merge completed upgrades while leaving difficult ones for a follow-up cycle.

4. Upgrade one family at a time

Update the version string in package.json, run the package manager's install command, and then compile or type-check before running the test suite. If the new major ships a codemod, run it against the source tree before making manual edits. Commit the codemod output and the manifest change as a single logical unit.

Run the project's linter, type checker (tsc --noEmit), and unit tests. Then run integration tests and any end-to-end suite. If the upgrade touches a library that ships its own type declarations (most do), check that@types/<pkg> has been updated or is no longer needed. Many packages began bundling types in major versions around 2019-2021, and keeping a stale DefinitelyTyped package can cause duplicate-install conflicts.

5. Verify peer and engine constraints

After the upgrade compiles and tests pass, audit the dependency tree for peer dependency warnings. npm and yarn both print warnings when a package receives a peer outside its declared range. Investigate every one --- a peer mismatch may be benign at runtime, but it will surface when a consumer installs your package.

Check engines at the project level too. If the new major of a build tool requires Node.js 20 and your CI runs Node.js 18, the upgrade is blocked until the runtime moves. Node.js's release schedule (every six months for Current, even years go LTS with 30 months of support) makes this a predictable constraint --- you can plan engine upgrades alongside dependency upgrades.

6. Know how to roll back safely

A dedicated branch makes rollback trivial: delete or reset it. But rollback is only safe if the migration did not alter shared state. Avoid running database migrations, modifying CI configuration on the same branch, or deploying the upgraded code to a shared environment before the branch is merged.

If the upgrade also changes the lockfile format or resolution strategy, the rollback must restore the old lockfile state too. Commit the lockfile change in the same commit as the version bump so that reverting the commit restores both. Git'sgit revert on a well-scoped commit is the safest undo mechanism.

Majors are not the enemy

The JavaScript ecosystem would be brittle without major versions. They let React redesign its reconciler, Node.js add ESM support, and TypeScript ship breaking syntax improvements without stalling the entire platform. The danger is not the version bump itself --- it is the undiscovered incompatibility buried in a large, unreviewable change.

A staged workflow treats each major upgrade as its own project: inventory, read the notes, branch, upgrade one family, test, verify peers, and commit a clean diff. The overhead of branching is negligible compared to the cost of a production incident caused by an unplanned breaking change. Plan the bump, and the bump stays boring.

Sources and further reading

Also in this series

Updating every package in package.json

Read the companion post