DEV Community

Cover image for What maintaining react dependencies costs
Kornel Maraz
Kornel Maraz

Posted on • Originally published at mindmapvault.com

What maintaining react dependencies costs

Serious software doesn’t collapse because of a missing feature.
It collapses because someone skipped a year of boring dependency maintenance.

A CI job called Security Guard went red this week. It runs pnpm audit on every push, and it had found seven vulnerabilities in one of our frontends. That sounds like a small problem. It took most of a working day to close properly, and the shape of that day is worth describing, because it is the recurring cost of building on the React ecosystem and almost nobody budgets for it.

What the audit actually said

The first surprise was that the failing job was not the whole picture. The report I was looking at came from our admin frontend and listed seven findings. The main application, audited the same way, had thirty.

Thirty sounds alarming until you split them by where they live:

  • Build toolchain: vite, postcss, nanoid, @babel/core, brace-expansion, fast-uri. These run on my machine and in CI. They never reach a user's browser.
  • Runtime: dompurify, pdfjs-dist, react-router. These are compiled into the bundle that every user downloads and executes.

That distinction matters more than the severity labels. pnpm audit reported four "high" findings in the build toolchain and ten "low" and "moderate" ones in dompurify. But dompurify is the HTML sanitizer we use when rendering notes. A sanitizer bypass in a note-taking application is cross-site scripting in the user's own content. Ten moderate findings there deserved more of my attention than four high ones in a dev server I run locally.

An audit tool cannot make that judgement for you. It does not know which packages you ship. That first triage is human work, and it is the part that decides whether the rest of the day is spent well.

The trap of upgrading to latest

My first instinct was the obvious one:

pnpm up vite@latest
Enter fullscreen mode Exit fullscreen mode

That moved Vite from 6.4.2 to 8.2.1. The build still passed, which was almost the worst outcome, because it made the change look safe. It was not: the patched version I actually needed was 6.4.3. I had accepted two major versions of behavioural change to fix a security advisory, and I would have found out what broke somewhere later, in a context with no obvious connection to this afternoon.

I reverted it and moved to minimum patched versions instead. Slower, and much easier to reason about when something misbehaves next month.

The same trap caught me again a step later. @vitejs/plugin-react@latest resolved to 6.0.5, which declares vite@^8.0.0 as a peer dependency. With Vite pinned back to 6.4.3, the install produced an unmet peer warning. The fix was version archaeology: version 5.2.0 of the plugin supports vite@^4 || ^5 || ^6 || ^7 || ^8. Not the newest, but the one that fits the rest of the tree.

This is the actual work. Not "upgrade the packages" but "find the combination of versions that are simultaneously patched, mutually compatible, and stable for a while".

The migration that was not a migration

The finding I dreaded was react-router. The advisory said the patched version was >=7.18.0, and we were on 6.30.4. There is no patched release in the 6 line, so the only route to a clean audit was a major version upgrade — the kind of change that usually means a day of testing on its own.

Two checks turned that around in about ten minutes.

First, the peer dependencies of react-router@7:

{ react: '>=18', 'react-dom': '>=18' }
Enter fullscreen mode Exit fullscreen mode

React 7 does not require React 19. We could stay on 18.3.1. No React upgrade, no ripple through every component.

Second, our own codebase already contained the answer. Our open source desktop build had been moved to react-router-dom@7 earlier, and it still imports from the same package name with the same API surface:

import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
Enter fullscreen mode Exit fullscreen mode

All twelve call sites in the application use BrowserRouter, Routes, Route, Navigate, Link, useNavigate, useParams, useSearchParams and Outlet. Every one of those is unchanged between v6 and v7. The "migration" was a single line in package.json. Type check passed, build passed, three advisories disappeared.

I want to be honest about the lesson here, because it cuts both ways. The upgrade was easy because we had done it once already elsewhere and because we had never reached for the exotic parts of the router. Projects that use data loaders, deferred routes or the framework mode would have had a real migration. Restraint in what you adopt is what makes upgrades cheap later.

Dependencies you never chose

That left ten findings in packages nobody on the team had ever typed:

.>vite-plugin-pwa>workbox-build>glob>minimatch>brace-expansion
.>vite-plugin-pwa>workbox-build>ajv>fast-uri
Enter fullscreen mode Exit fullscreen mode

You cannot upgrade these directly. You either wait for the maintainers above them to release, or you override the resolution yourself. We already had a small pnpm.overrides block for exactly this, so I extended it.

The subtlety is in the ranges. brace-expansion had two versions installed at once, 2.1.0 and 5.0.6, needed by different parts of the tree. A blanket override to the newest would have forced 5.x onto packages written against 1.x. The override has to name the range:

"brace-expansion@<2.1.4": "2.1.4",
"brace-expansion@>=5.0.0 <5.0.9": "5.0.9",
"fast-uri@>=3.0.0 <3.1.5": "3.1.5"
Enter fullscreen mode Exit fullscreen mode

Overrides are a debt of their own. Each one is a note saying "I know better than the resolver", and each needs removing when the upstream package catches up. They should be narrow and few.

The ledger

At the end of it: thirty findings to zero in the main app, seven to zero in the admin app, both verified the way CI does it, with rm -rf node_modules and a frozen lockfile install. Type check and build green in both.

Time spent: roughly half a day, most of it reading advisories, checking peer dependency tables and undoing one upgrade that went too far.

And here is the part I keep coming back to. Almost everything in that day was given to me for free. I did not write a router, a PDF renderer, an HTML sanitizer or a build tool. Someone else maintains all of them. Someone else runs the infrastructure that discovered these vulnerabilities, wrote them up, assigned severities and published them in a machine-readable form that my CI could consume without me configuring anything. The advisory database, the audit tooling, the packages themselves — all of it arrives at no cost.

What is not free is the fit. The ecosystem hands you a pile of excellent, independently maintained parts, and the work of holding a particular combination of them together, on your project, on a version of React you have chosen, remains yours. It is not hard work. It is not even especially interesting work. But it is real, it does not go away, and it lands about once a quarter.

Half a day every three months is a genuinely good deal for what you get back. It stops being a good deal the moment you decide it is not work, skip it for a year, and then face twelve months of accumulated major versions in a single sitting — usually because something has already gone wrong.

It could have gone worse, and elsewhere it has

I should be careful not to make this sound universal, because the same job on our Rust backend is open on my machine as I write this, and it is not going to be an install command.

cargo audit flagged quick-xml (RUSTSEC-2026-0195 and RUSTSEC-2026-0194, both denial of service) and rgb. We do not depend on any of them. The chain is:

pprof (feature "flamegraph") → inferno → quick-xml
Enter fullscreen mode Exit fullscreen mode

We use pprof to profile the backend. Its flamegraph feature pulls in inferno to render the SVG, and inferno uses an XML writer to produce it. The vulnerability is three levels below anything we chose, in a component whose only job is writing the tags of a picture.

There is no version to bump here. quick-xml had not published a fix we could take, and the crate was only present because of one feature flag. So the fix was to stop pulling that branch of the tree in at all:

-pprof = { version = "0.15", features = ["flamegraph"] }
+pprof = { version = "0.15", features = ["protobuf-codec"] }
Enter fullscreen mode Exit fullscreen mode

The version did not change. Nine crates left the lockfile. And that is where the cost showed up, because the feature does not just add a renderer, it decides which API exists. report.flamegraph(&mut file) was simply gone. The profiler now hands back a protobuf message that you serialise yourself:

-if let Ok(mut file) = File::create("/tmp/backend-flamegraph.svg") {
-    let _ = report.flamegraph(&mut file);
+if let Ok(profile) = report.pprof() {
+    if let Ok(bytes) = profile.write_to_bytes() {
+        if let Ok(mut file) = std::fs::File::create("/tmp/backend-profile.pb") {
+            let _ = file.write_all(&bytes);
Enter fullscreen mode Exit fullscreen mode

Eleven lines rewritten, the imports changed, and the output is no longer an SVG I can open in a browser. It is a .pb file for go tool pprof or speedscope. A working habit changed because of a denial-of-service bug in an XML writer we never asked for.

That is the difference in one picture. On the JavaScript side, closing thirty findings meant editing a manifest and choosing versions carefully. On the Rust side, closing three meant reading someone else's feature flags, understanding why a crate was there at all, and then rewriting our own code and changing how we profile.

The compiler does at least tell you. In JavaScript the upgrade usually installs cleanly and the surprise arrives later, at runtime, in front of a user. In Rust it arrives immediately, as a wall of red text, and nothing ships until it is dealt with. I would rather have the wall of red text. But it is not half a day, and it is worth knowing which of the two you are signing up for.

Put it in the calendar. Ninety minutes of triage, deciding what actually ships to users. A couple of hours of finding versions that agree with each other. A clean install and a real build to prove it. Then close the laptop and go back to the product.

Top comments (0)