npm & Package Management

How Node.js projects declare, install, and lock their external dependencies using npm and package.json.

What is it?

Almost no backend is written entirely from scratch — a project reaches for a web framework, a database driver, a validation library, and dozens of things those libraries themselves depend on. npm (Node Package Manager) is the tool that installs and manages all of that.

Every Node project has a package.json file describing it: its name, its dependencies (code needed to run in production, like a web framework) and devDependencies (tools only needed while developing, like a test runner), and a scripts section defining shortcuts like npm run dev. Running npm install reads that file, downloads everything listed (plus whatever those packages depend on), and puts it all in a node_modules folder.

Explain like I'm 10

package.json is like a shopping list for your project — 'we need this framework, this validator, and only this test tool while we're building.' node_modules is the fully-stocked pantry that results from shopping off that list. The lock file is the receipt, recording the exact brand and size of everything bought, so a second shopping trip buys precisely the same items again.

Examples

A minimal package.json

{
  "name": "my-api",
  "version": "1.0.0",
  "scripts": {
    "dev": "node server.js",
    "test": "jest"
  },
  "dependencies": {
    "express": "^4.19.0",
    "pg": "^8.11.0"
  },
  "devDependencies": {
    "jest": "^29.7.0"
  }
}

express and pg are needed for the app to actually run in production; jest is only used while developing and testing, so it's kept separate.

Common npm commands

npm install              # install everything listed in package.json
npm install express      # add express as a dependency
npm install -D jest      # add jest as a devDependency
npm run dev              # run the "dev" script
npx some-cli-tool        # run a package's command without installing it globally

npm install without arguments sets up a project you just cloned; with a package name, it adds something new and updates package.json for you.

How it works

When you run npm install <package>, npm looks up that package (and every package it depends on) in the public npm registry, downloads them all into node_modules, and adds an entry to package.json. It also writes the exact resolved version of every package — direct and transitive — into package-lock.json. That lock file is what makes installs reproducible: without it, two developers running npm install weeks apart could get different versions of the same "^4.19.0" dependency as new compatible releases come out.

Why does it exist?

Before package managers, reusing someone else's code meant manually downloading and copying files, with no easy way to track versions or pull in updates. npm exists to standardize declaring what a project needs, discovering existing packages instead of writing everything from scratch, and installing all of it (transitive dependencies included) in a reproducible way.

When to use it

Any real Node.js project uses npm — even a project with just one dependency benefits from package.json documenting what it needs and scripts standardizing how to run it.

When not to use it

The question is less "when to skip npm" and more "when to skip adding a dependency": pulling in a package for something trivial you could write in a few lines yourself (like checking if a number is even) adds an extra thing to keep updated and trust, for very little benefit.

Common mistakes

  • Committing the node_modules folder to git instead of .gitignore-ing it and letting npm install regenerate it from package.json.

  • Hand-editing package-lock.json, which npm manages automatically and expects to stay in sync with actual installs.

  • Installing something needed only for testing or building as a regular dependency instead of a devDependency, bloating what ships to production.

Practice exercises

  1. Easy:

    Explain the difference between dependencies and devDependencies in package.json.

  2. Medium:

    Given a package.json with express under dependencies and jest under devDependencies, name the command that installs only what's needed to run the app in production.

  3. Hard:

    Explain how two developers without a package-lock.json file could end up with different versions of the same dependency, and how committing that file prevents it.

Interview questions

What is package.json?

A file describing a Node.js project — its name, its dependencies and devDependencies, and shortcut commands defined under scripts.

What's the difference between dependencies and devDependencies?

dependencies are needed for the app to run in production; devDependencies are only needed while developing, like test runners or build tools.

What is package-lock.json for?

It records the exact resolved version of every installed package, direct and transitive, so future installs are reproducible instead of drifting as new compatible versions are published.

Why shouldn't `node_modules` be committed to version control, given that `npm install` can regenerate it?

It can be regenerated entirely from package.json (and package-lock.json) on any machine, so committing it just bloats the repository with a huge, derived folder that adds no information beyond what those two files already capture.

What does the caret (`^`) in `"express": "^4.19.0"` allow npm to install, and what does it forbid?

It allows npm to install any version that's backward-compatible according to semantic versioning — any 4.x.x release equal to or newer than 4.19.0 — but forbids installing a new major version like 5.0.0, which may include breaking changes.

Two developers run `npm install` weeks apart on a project with only `"express": "^4.19.0"` in package.json and no lock file. Why might they get different versions?

The caret range allows any compatible 4.x.x release, and new ones can be published to the registry between the two installs — without a lock file pinning the exact resolved version, each install just grabs whatever the newest matching version happens to be at that moment.

How does committing package-lock.json solve the problem in the previous scenario?

The lock file records the exact version (and the exact versions of every transitive dependency) that was actually resolved at install time; as long as it's committed and present, npm install reads and reuses those exact pinned versions instead of re-resolving the caret ranges fresh.

Why is hand-editing package-lock.json considered a mistake, even though it's just a text file?

The lock file's entries need to stay internally consistent with each other and with package.json (exact versions, resolved integrity hashes, and the dependency tree); npm manages and regenerates it automatically, and manual edits can easily desynchronize it from what's actually installed or from package.json itself.

What does `npm install express` do differently from `npm install` with no arguments?

npm install with no arguments installs everything already listed in package.json; npm install express additionally adds express as a new entry under dependencies in package.json (and then installs it), for a package not previously listed.

What does the `-D` flag do in `npm install -D jest`, and why does it matter which category a package ends up in?

-D installs the package as a devDependency instead of a regular dependency; the distinction matters because tools that install "production only" dependencies (for deployment) skip devDependencies entirely, so miscategorizing a package changes what actually ships.

`jest` is accidentally installed as a regular dependency instead of a devDependency. What's the practical consequence in production?

A production install that's meant to skip devDependencies would still pull in jest and everything it depends on, unnecessarily bloating the deployed node_modules with a testing tool the running app never actually uses.

What does `npx some-cli-tool` let you do that installing the package first and then running it does not?

It runs the package's command directly (downloading it temporarily if it isn't already installed) without permanently adding it to the project or installing it globally, which is convenient for one-off or infrequently-used command-line tools.

What's the difference between what's read from package.json's scripts section and what running `node server.js` directly does?

A script like "dev": "node server.js" is just a named alias stored in package.json — running npm run dev looks up and executes that exact underlying command, so it behaves identically to typing the command yourself, just under a shorter, memorable, standardized name.

Why does npm need to resolve and download transitive dependencies, not just the ones listed directly in package.json?

Most packages themselves depend on other packages to function; if npm only installed what's explicitly listed in your package.json, those direct dependencies would be missing the code they themselves require and would fail to work.

If dependencies includes express and devDependencies includes jest, what command installs only what's needed to run the app in production?

npm install --omit=dev (or the older npm install --production), which skips everything listed under devDependencies and installs only the regular dependencies.

Why does npm exist — what problem did developers have before package managers when reusing someone else's code?

Reusing code meant manually finding, downloading, and copying files into a project, with no standard way to track which version you had, discover updates, or ensure that a dependency's own dependencies were also included; npm standardizes declaring, discovering, and installing packages (and their transitive dependencies) reproducibly.

You clone a teammate's project and run `npm install`, but there's also a `.env` file needed with a `DATABASE_URL`. Why isn't `npm install` alone enough to run the app?

npm install only installs the code dependencies listed in package.json; it has no knowledge of environment-specific configuration like database connection strings, which are deliberately kept out of the package and version control entirely (often via .env, which is typically gitignored) and must be set up separately.

What's the risk of adding a dependency for something trivial you could write yourself in a few lines, from a package-management point of view?

Every dependency is something you now have to trust, keep updated, and that adds to your install size and transitive dependency tree — for something as small as checking if a number is even, that ongoing cost outweighs the negligible effort saved versus writing it directly.

Why is package.json's scripts section useful even for a solo developer working alone?

It documents and standardizes exactly how to run, test, or build the project without having to remember or look up the precise underlying command each time, and it keeps working the same way even after the developer forgets the details months later.

What's recorded in package.json versus package-lock.json — are they redundant with each other?

package.json records the intended version ranges (like ^4.19.0) for direct dependencies chosen by the developer; package-lock.json records the exact, specific version actually resolved and installed for every package in the entire dependency tree, direct and transitive — they serve different purposes and aren't redundant.