Dependency Hygiene
Every dependency is code you did not write, cannot review continuously, and are nonetheless responsible for. For a library, each one is also imposed on every user downstream — a cost you spend on their behalf without asking.
Should you add it?
Run this before every npm install:
- How much of it will you use? One function from a 40-file package is usually a
copy (with attribution and license compliance — see
license-and-legal), not a dependency. - Could the standard library do it? Modern stdlibs cover far more than they did when most "small utility" packages were written.
- Is it maintained? Last release, open issue age, bus factor, response time on recent issues. A package with one maintainer and no release in three years is a future migration you have scheduled without noticing.
- What does it pull in? Check the transitive tree, not the direct entry.
- What is its license? Especially transitively.
- Does it run code at install time?
postinstall,setup.py,build.rs. - How hard is removal? If it is woven through your public API types, you have adopted its release cadence permanently.
npm view <pkg> dependencies maintainers time.modified
npx howfat <pkg> # true install size incl. transitives
npm ls --all <pkg> # who pulls this in
cargo tree -i <crate> # reverse dependency tree
pipdeptree -r -p <pkg>
Library authors: the bar is much higher than for applications. A dependency in a library is a dependency in every consumer's tree, a potential version conflict, and a row in their security scanner's report. Many successful libraries advertise zero runtime dependencies, and that is a feature users select on.
Ranges vs pinning
The rule that resolves most confusion:
| Applications | Libraries | |
|---|---|---|
| Manifest | Ranges are fine | Wide ranges, e.g. ^1.2.0 |
| Lockfile | Commit it | Commit it (for CI reproducibility) |
| Install in CI | npm ci / --locked |
npm ci, plus a job testing latest deps |
Libraries must not pin exact versions in the manifest — it creates unresolvable diamond conflicts for consumers. Libraries should still commit a lockfile: it makes your own CI reproducible. Add a separate scheduled job that installs the newest matching versions so you learn about upstream breakage before your users do.
peerDependencies (npm) exist for the case where the consumer must control the version
— plugins, framework integrations. Use them there and nowhere else.
Bot configuration that people don't mute
The default Dependabot setup generates a PR per dependency per update, and the predictable outcome is a maintainer who stops reading them — which means security alerts get ignored too. Group aggressively.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule: { interval: weekly, day: monday }
open-pull-requests-limit: 5
groups:
dev-dependencies:
dependency-type: development
update-types: [minor, patch]
production-patch:
dependency-type: production
update-types: [patch]
ignore:
- dependency-name: "typescript"
update-types: ["version-update:semver-major"] # major upgrades are manual
- package-ecosystem: github-actions
directory: /
schedule: { interval: weekly }
Renovate offers more control and is worth it past a certain size: automerge patch
and dev-dependency updates when CI is green, batch minors into one weekly PR,
require manual review for majors, and use a dependency dashboard issue instead of open
PRs. Also enable minimumReleaseAge (a few days) so you do not automerge a package
version published ten minutes ago by a compromised account.
Always update github-actions as an ecosystem — that is what keeps pinned action SHAs
from going stale (see supply-chain-security).
Auditing an existing tree
npm audit --omit=dev # noise drops sharply without dev deps
npm ls --all | wc -l # total tree size
npx depcheck # declared but unused, used but undeclared
cargo audit && cargo udeps
pip-audit && deptry .
go mod tidy && govulncheck ./...
govulncheck is the model the rest of the ecosystem is moving toward: it reports only
vulnerabilities in code paths you actually call, rather than every CVE anywhere in
the tree. Prefer reachability-aware tools where they exist — they turn an unreadable
100-item report into a 3-item one you will act on.
Triaging an advisory:
- Is it reachable from your code? Many are not.
- Is it in a dev dependency? Lower severity for users, but real for maintainers — dev machines hold credentials.
- Is there a fix? If not, evaluate: pin to a safe version, apply an override, vendor a patch, or replace the dependency.
- Do not suppress without a note. Every ignore entry gets a reason and a date.
Overrides for a transitive vulnerability with no upstream fix:
{ "overrides": { "vulnerable-pkg": "1.2.4" } } // npm
[patch.crates-io] # Cargo
vulnerable = { git = "https://github.com/you/fork", branch = "fix" }
Track every override in an issue with a removal condition. Overrides are technical debt that silently becomes permanent.
Removing dependencies
The most durable win available. Look for:
- Single-function packages — inline them with attribution.
- Polyfills for platforms you no longer support. Check your engine range and delete accordingly; this often removes dozens of transitive packages.
- Overlaps — two date libraries, three HTTP clients, two argument parsers. Usually from different contributors at different times. Pick one and migrate.
- Dev dependencies replaced by built-ins — Node has a test runner; Python has
tomllib; most languages have shipped a formatter. - Heavy dependencies used for one call — the classic is a full HTTP client used to
make a single request that
fetchhandles.
npx depcheck # start here: unused declared deps
npm ls --all --parseable | awk -F/ '{print $NF}' | sort | uniq -c | sort -rn | head
Measure the result — install size, cold-start time, number of packages — and put it in the changelog. Users notice.
Unmaintained dependencies
When a dependency goes quiet: check for a community fork with real adoption; check whether a stdlib or a modern alternative now covers it; consider vendoring it if it is small and stable (a 200-line stable utility is a fine thing to own); or offer to maintain it upstream. Only migrate to an alternative when you have to — migration is the most expensive option and often the first one proposed.
Never quietly depend on an abandoned package with a known unfixed vulnerability because migration is inconvenient. Say something in your README if you are stuck.
Anti-patterns
- Adding a dependency for one function.
- Lockfile not committed. Nondeterministic builds, unreproducible bugs.
npm installin CI instead ofnpm ci. Silently ignores the lockfile.- Exact pins in a library manifest. Creates conflicts for every consumer.
- 50 open Dependabot PRs. The maintainer has stopped reading them; so has security.
- Automerging major version bumps.
- Automerging a version published minutes ago with no cooldown.
npm audit fix --forcewithout reading what it does — it will happily install a breaking major to silence a warning.- Suppressing advisories with no reason recorded.
- A dependency whose types leak into your public API, binding your semver to theirs.