A dependency update cadence a small team can keep
Run `npm outdated` on a Next.js project that has been quietly working for a year and you will get something like 47 entries, a dozen or more of them major versions behind. The app works fine, and it has worked fine for months, which is exactly the problem — nothing forces you to look at this until something breaks.
We tried quarterly dependency sprints. Block a full week, run `npm audit fix`, upgrade everything, and spend three days debugging a regression in a date picker that a patch release introduced. Somebody pays for that week, and the only visible outcome is that the date picker works again.
Now we do something smaller and more frequent.
On the first working day of each month, we pull the latest advisory reports for every project we maintain. We use `npm audit --json` and `pip-audit --format json`, pipe the output to files, and diff them against the previous month. New advisories get triaged in the first thirty minutes of the day. If something is critical or high severity and remotely exploitable, we patch it that week. Medium and low go on a list.
For non-security updates, we pick one project per month and update its dependencies. Not all projects — one. We rotate through. A project with eight dependencies gets updated more often than one with sixty, but every project gets its turn roughly every quarter.
The update itself is mechanical. We bump patch and minor versions first, run the test suite, and deploy. Then we look at major versions.
Major versions are where we make deliberate decisions to skip. We skip most of them. Upgrading from React 17 to 18 in a stable app that isn't actively getting new features is not worth the risk unless we're already touching that code for a feature request. A React 18 upgrade can easily eat two days because of changes to `useEffect` timing that surface a subtle bug in some third-party modal library. That is two days nobody budgeted for, on an upgrade nobody asked for, and it is hard to bill for it honestly.
What we do instead is track what we skipped and why.
We keep a file called `UPGRADES.md` in each project root. It looks like this:
# Deferred upgrades
## Next.js 13 -> 14
Deferred: 2024-03-04
Reason: App Router migration not requested by client. Server Actions
used in two forms. Partial migration would create two routing paradigms.
Revisit: When client requests new pages or we touch routing.
## Prisma 4 -> 5
Deferred: 2024-03-04
Reason: Breaking changes to `prisma migrate` CLI. Custom migration
scripts would need review. No security advisories blocking.
Revisit: Next quarter or if advisory is filed.The file is short. Each entry has a date, a reason, and a trigger for revisiting. When we do quarterly project reviews, we read through the file and decide whether any of the deferred upgrades should move into the current month's work.
This sounds like overhead, and it is — about twenty minutes per project per month. But the alternative is worse. We've taken over projects from other shops where the dependencies were two years behind, the lockfile was in git but nobody remembered why certain versions were pinned, and the first upgrade attempt broke the build in four places. That kind of debt takes a week to clear, and the client doesn't want to pay for it because they don't see anything change.
The cadence we keep is deliberately modest. One project per month, one pass through advisories, one update to the deferred list. It doesn't catch everything. A major release of a library we depend on heavily might sit deferred for six months. But when we do upgrade, we know what we deferred, why we deferred it, and what changed in the meantime. That's worth more than being on the latest version of everything.
The projects we maintain stay within one major version of their core frameworks. That's the line we hold.