Skip to content
Preptima
mediumConceptMidSeniorStaff

You add one small npm package to a service. What have you just taken on?

Not one package but its whole transitive closure, install-time scripts from every node in it, a build that now depends on the registry being reachable, and the possibility of two resolved copies of the same library whose instances fail each other's identity checks.

5 min readUpdated 2026-07-29Target archetype: Big Tech, Enterprise Captive, Product Startup
Practice answering out loud

What the interviewer is scoring

  • Does the candidate reason about the transitive closure rather than the one package they chose
  • Whether they mention that install executes code from the graph before any test of theirs runs
  • That npm ci with a committed lockfile is distinguished from npm install against a semver range
  • Whether the candidate recognises two resolved copies of one library as a correctness bug, not a disk-space one
  • Does the answer offer a concrete threshold at which writing the code beats taking the dependency

Answer

Resolution is what makes a dependency feel free

A bare require('lodash') or import ... from 'lodash' does not name a file. Node takes the identifier, looks for node_modules/lodash in the directory of the importing file, then in its parent, then its parent's parent, all the way to the filesystem root, and uses the first match. Nothing in that walk consults your package.json. The resolver does not care who declared the dependency; it cares only that a directory with the right name exists somewhere above the importing file.

Because npm hoists the installed tree flat where it can, most of your dependencies end up as siblings in the top-level node_modules, which means your code can successfully import a package you never declared. It resolves because some transitive dependency pulled it in and it happened to land at the top. That is a phantom dependency, and it works right up until the package that pulled it in bumps its own version, drops it, or gets hoisted differently, at which point your code fails to resolve a module you thought you owned.

The unit you took on is the closure, not the package

Reviewing the one package you chose is close to worthless if the graph beneath it is unexamined. The interesting number is not how many dependencies you declared but how many distinct packages, maintainers and publish credentials your node_modules ends up representing, because any of them can ship a version that runs in your build and your production process.

Two habits make that governable. The first is looking at the closure before you add anything: how many packages does this bring, and does the leaf-node count feel proportionate to the problem being solved. The second is treating a very small dependency as a candidate for inlining. The reason is not distaste for reuse but arithmetic: for a function you could write in an afternoon and never touch again, taking the dependency buys you nothing and adds a maintainer, a version stream, an install-time hook and a resolution edge.

Install runs code from every node in the graph

npm install executes lifecycle scripts, and preinstall, install and postinstall in a transitive dependency run with the privileges of whoever ran the install. On a developer machine that is your shell and your credentials; in CI it is often a runner with a registry token and cloud credentials attached. This happens before a single line of your own test suite executes, so nothing in your test strategy is a control on it.

Practically, that means --ignore-scripts in CI wherever the graph does not genuinely need a native build step, a build agent whose credentials are scoped to the job rather than to the organisation, and a real distinction between the machine that installs dependencies and the machine that holds your deployment keys.

Reproducibility is a property of how you install, not of what you declared

A package.json range such as ^4.17.0 is an instruction to resolve the newest matching version at install time, so two installs a week apart legitimately produce different trees. The lockfile records the resolution that was actually made — exact versions, resolved URLs and integrity hashes for the whole closure — and it only helps if the command you run respects it.

npm ci is the command that does. It requires a lockfile to be present, removes any existing node_modules before it starts, installs exactly what the lockfile records, and fails outright if the lockfile and package.json disagree. npm install may instead update the lockfile to satisfy the ranges, which is what you want on a developer machine and precisely what you do not want in a pipeline whose output ships. A lockfile that is committed but not enforced by the install command is decoration.

When the registry cannot give you the package

In March 2016, the maintainer of the npm package left-pad unpublished eleven lines of JavaScript, and builds failed across the ecosystem because that module sat somewhere deep in their transitive dependency graphs. The point for an interview is that Node's module resolution makes it trivially cheap to add a dependency and therefore easy to lose track of how many you actually run. The questions worth asking about the runtime are which modules you could still install if the registry were unavailable, and whether an install is reproducible from a lockfile and a cache rather than from the network.

The answers are concrete and testable. A pull-through cache or a private registry mirror turns an upstream outage into a cache hit. Vendoring the closure, or committing a verified offline cache, turns it into a non-event. Neither is free, and the honest framing in an interview is that you buy availability of your build with storage and a mirror to maintain.

Two copies of one library is a correctness bug

The failure that catches people out is not a missing package but a duplicated one. When two dependencies need incompatible versions, npm nests one of them, and you end up with two copies loaded into the same process. Each copy has its own module-level state and its own class objects, so a value created by one copy fails an instanceof check against the other's constructor, a registry or plugin table registered against one copy is invisible to the other, and a library that relies on a singleton silently has two.

The symptom is a type error or a "not registered" error that makes no sense from reading the source, because the source is correct and the identity is not. Diagnosing it means asking the tree rather than the code: list every resolved copy of the package and look at what nested it. Fixing it means aligning the version ranges, or declaring the shared library a peer dependency so the duplication becomes an install-time warning rather than a runtime mystery. This is the same hazard in a different coat when a package ships both CommonJS and ESM entry points, since a graph that reaches it both ways can load both builds and get two instances of one library from a single installed copy.

A dependency is not a package, it is a closure with install-time code execution, a network dependency in your build, and a resolution graph that can hand you two copies of the same library whose objects do not recognise each other.

Likely follow-ups

  • Your lockfile pins every version and a fresh install still yields a different tree. How is that possible?
  • A module resolves at runtime that nothing in your package.json declares. What made that work, and why will it stop?
  • How would you make your build succeed with the registry unreachable for an hour?
  • What changes when the dependency ships both a CommonJS and an ESM entry point?

Related questions

Further reading

npmdependenciesmodule-resolutionsupply-chainnodejs