JavaScript

Using npm to manage front-end dependencies, not just Node packages

JS

Our front-end dependencies, jQuery plugins mostly, used to live as manually downloaded files sitting in a /vendor folder, updated whenever someone remembered to check for a new version, or more honestly, almost never, since checking meant visiting each plugin's own page individually and comparing version numbers by hand. This year we moved to managing them through npm even on projects that aren't otherwise Node-based, treating it as a dependency manager for browser code rather than strictly a tool for server-side JavaScript.

The problem this actually solved

A package.json listing exact versions means we can hand a project to any of us and get an identical set of dependencies with one command, instead of hoping the vendor folder that's been committed to Git actually matches what's documented anywhere, or worse, discovering mid-project that the version of a plugin sitting in the folder has been hand-patched by someone at some point with no record of what changed or why. That last scenario happened to us at least twice before this switch, a plugin file that looked standard but had a small undocumented tweak buried in it, and untangling which version was actually running in production versus which version matched the plugin's official changelog cost real time we'd rather not have spent.

What the actual switch looked like

  • Every existing vendored library got an equivalent npm package identified and pinned to a specific version in package.json, exact versions rather than a loose range, since a silently auto-updated dependency breaking a client site the week before a deadline is a specifically bad kind of surprise.
  • A short list of internal notes documenting any hand-patched difference we'd been carrying, so at least the ones we were aware of got either fixed upstream through a real pull request or explicitly re-documented as a deliberate override rather than lost to memory.
  • Our Gulp pipeline picked up a step to bundle the relevant files out of node_modules into the actual build output, since browsers can't require a CommonJS module directly and none of this is meant to run in Node itself.

It feels like using the wrong tool for the job, and mostly isn't

npm was built for Node packages, not browser libraries, and there's a real awkwardness to using it this way, a lot of what's in node_modules after an install is code that will never run in a browser and never needed to be downloaded at all, build tooling, test frameworks bundled as dependencies of dependencies, documentation generators. We're not thrilled about the disk space or the install time that comes with that, a node_modules folder for what's conceptually a handful of browser scripts can easily run to hundreds of megabytes. But paired with our new Gulp pipeline to bundle things up, it's been a real improvement over manual file management, and the actual day-to-day workflow, npm install, then a Gulp task pulls in exactly what's needed, is simple enough that the underlying awkwardness mostly stays invisible during normal work.

The version-locking habit this created

Beyond just tracking versions, we've gotten more disciplined generally about when we bump a dependency. Before, updating a vendored plugin meant redownloading a file and hoping nothing broke, a decision made casually, often while doing something unrelated. Now, bumping a version in package.json is a visible, reviewable change in Git, something that shows up in a diff and gets looked at before merging rather than silently swapping a file. That visibility alone has caught at least one case where a plugin's newer version changed a default option in a way that broke a specific client feature, caught in review before it ever reached production, rather than discovered live after a casual file swap.

A gotcha with global scripts and load order

One thing that isn't automatic: npm knows about a package's declared dependencies, but it doesn't know anything about browser-specific load order requirements, a jQuery plugin still needs jQuery loaded onto the page first, and npm's own dependency resolution doesn't express that kind of runtime ordering constraint the way it expresses a Node module's require statements. We handle this manually in our Gulp concatenation step, explicit ordering rather than anything npm figures out for us, which is a limitation worth knowing about before assuming npm alone solves every dependency problem a front-end project has.

Where we're not using this yet

We're not managing every third-party script this way, a couple of small, single-file utilities that will genuinely never be updated again don't get the npm treatment, mostly to avoid node_modules bloat for something that doesn't need version tracking at all. It's a judgment call, is this a dependency likely to need an update or a security patch someday, rather than a blanket rule, and we're comfortable leaving genuinely static, tiny scripts exactly where they've always lived.

The install-time cost, in real numbers

Curious how much overhead we were actually adding, we timed a clean install on our largest client project, twenty-some npm packages once everything was accounted for, front-end libraries plus the handful of build tool dependencies underneath them. A full npm install on a fresh machine took just under three minutes on a normal office connection, versus effectively zero time for the old approach of files already committed to the repo. Three minutes isn't nothing when you're setting up a new machine or a contractor is onboarding, but it's a one-time cost per environment rather than a recurring one, and it's genuinely small next to the hours we've spent in the past untangling a mismatched vendor file.

What we do when a package has no proper npm distribution

Not every jQuery plugin we use is properly published to npm with sensible versioning, a few of the smaller or older ones we still rely on are really just a single file hosted on someone's personal site or an old, abandoned GitHub repo with no package.json at all. For those we've settled on vendoring the specific file we need into a small local folder that isn't node_modules, with a comment at the top of the file linking back to its original source and the exact commit or download date, and treating that folder as the deliberate exception to the "everything through npm" rule rather than pretending npm solves a problem it structurally can't for a package that was never published there in the first place.

Locking versions further with a shrinkwrap file

Pinning exact versions in package.json helps, but it doesn't fully protect against a dependency's own dependencies shifting underneath us, since those are typically specified as ranges rather than exact versions by whoever published them. We started using npm's shrinkwrap feature on our most sensitive client projects, which locks the entire resolved dependency tree, not just our own direct dependencies, to specific versions. It's an extra file to keep in the repo and a small amount of extra process when we do want to intentionally update something, running the shrinkwrap regeneration step explicitly rather than letting it happen implicitly, but for a client site where an unexpected transitive dependency change breaking production would be a genuinely bad day, that tradeoff is worth it.

Explaining this decision to a less technical client

A couple of clients have asked, in passing, why their invoice this month includes time for something called "dependency management," a phrase that means nothing to someone who isn't writing code themselves. We've settled on a short, honest explanation: it's the difference between a project where anyone on our team can rebuild the exact same working version of your site from scratch at any time, versus one where that depends on a specific folder on a specific laptop staying intact forever. Framed that way, as risk reduction rather than a technical curiosity, it's landed well every time we've had to explain it, because it maps onto something every client already understands, not wanting their site's fate to depend on one person's hard drive.

One last habit this pushed us into

We've also started running a quick audit of our dependency list every couple of months now, just skimming package.json and asking whether each entry is still actually used anywhere in the project, since it's easy for an npm-managed dependency to outlive the feature it was added for in a way a manually placed vendor file, visible right there in the file tree, made more obvious by comparison. It's a small periodic chore, but it keeps the dependency list honest rather than slowly accumulating unused entries nobody notices until someone asks why a project has thirty dependencies for a site that only visibly uses a handful of them.

← Back to the journal

Have a project in mind?
Let’s talk.

Tell us where you are and where you want to go. We'll map the fastest route between the two.

Currently accepting new clients