npm, npm -g, and npx: A Deep Dive
You've used these tools thousands of times. But do you actually know what's happening under the hood?
The Mental Model You Probably Have (And Why It's Slightly Wrong)
Most engineers carry a vague mental model: npm install puts stuff in node_modules, -g puts it "somewhere global," and npx "just runs it." That's good enough to be productive — but not good enough to debug a broken PATH, reason about version conflicts, or design a clean CI pipeline.
Let's fix that.
npm: The Package Manager
npm is three things simultaneously: a CLI tool, a package registry (npmjs.com), and a package format (the package.json contract). When engineers say "npm," they usually mean the CLI — but conflating all three causes confusion.
What npm install actually does
When you run npm install, npm:
- Reads
package.jsonandpackage-lock.json - Resolves the full dependency tree (including transitive deps)
- Fetches tarballs from the registry (or cache at
~/.npm) - Extracts packages into
node_modules/ - Writes/updates
package-lock.json
The lock file is a deterministic snapshot — a tree of resolved versions, download URLs, and integrity hashes. This is what npm ci uses to guarantee byte-for-byte reproducible installs. If you're not using npm ci in CI pipelines, you're flying blind.
npm install # installs from package.json, updates lock file
npm ci # installs from lock file exactly, never updates it
npm install express # adds to dependencies, updates both files
npm install -D vitest # adds to devDependencies
The node_modules structure
npm v3+ uses a flat node_modules structure (with some exceptions). Instead of deeply nested trees, packages are hoisted to the top level when possible. This reduces duplication but can cause phantom dependency issues — your code imports a package that isn't in your package.json, because it was hoisted from a transitive dep. It works until it doesn't.
node_modules/
express/ ← hoisted, even though you only depend on it transitively
lodash/
your-package/
node_modules/
lodash/ ← NOT hoisted — version conflict with top-level lodash
This is why pnpm exists, with its symlinked, content-addressed store and strict dependency isolation. But that's another article.
npm -g: The Global Install
Global installs live outside any project. They're tools you use across your machine, not dependencies of a specific codebase.
npm install -g typescript
npm install -g eslint
npm install -g @anthropic-ai/claude-code
Where do globals actually go?
npm root -g # shows global node_modules path
npm bin -g # shows global bin path
On a typical Unix system:
/usr/local/lib/node_modules/ ← global node_modules
/usr/local/bin/ ← symlinks to global package binaries
With nvm, it's per-Node version:
~/.nvm/versions/node/v22.0.0/lib/node_modules/
~/.nvm/versions/node/v22.0.0/bin/
This is the source of a classic bug: you install a global tool with Node 18, switch to Node 20 via nvm, and the tool vanishes. You haven't lost it — it's just under the Node 18 path, which is no longer on your PATH.
The case against globals
Globals have a fundamental problem: they're not reproducible. Your machine has typescript@5.4 globally. A teammate has 5.2. CI has whatever was last installed by whoever set it up. This is fine for personal scripts, terrible for team tooling.
The modern best practice is to keep CLI tools as devDependencies and invoke them via npx or npm scripts:
{
"devDependencies": {
"typescript": "^5.4.0",
"eslint": "^9.0.0"
},
"scripts": {
"lint": "eslint .",
"build": "tsc"
}
}
Now every developer and every CI runner uses exactly the version pinned in package.json. Reproducibility is free.
When globals are still appropriate
- Interactive REPL tools you want available everywhere (
nodemon,http-server) - Package managers themselves (
pnpm,yarn) - Tools that need to work before you have a project context
- Your personal developer utilities not tied to any specific project
npx: The Package Executor
npx shipped with npm 5.2 in 2017 and fundamentally changed how we think about CLI tools. It separates execution from installation.
The resolution algorithm
When you run npx some-tool, npm resolves in this order:
- Local
node_modules/.bin/— if the package is installed as a project dependency, use it - Global installs — if it's installed globally, use it
- npx cache (
~/.npm/_npx/) — if it was previously downloaded by npx, use the cache - Registry — download it, cache it, run it, then remove it from disk (but keep in cache)
Steps 1 and 2 are instant. Step 3 is fast (disk read). Step 4 is the one that shows the "Need to install?" prompt.
npx create-cloudflare@latest
# → not in local deps, not global, not in cache
# → "Need to install the following packages: create-cloudflare@latest"
# → downloads, caches, runs, then the binary is removed from disk
# → cache entry stays: ~/.npm/_npx/{hash}/node_modules/
The cache
npx caches packages by a hash of the package name + version. On subsequent runs, it hits the cache — no download, no prompt. The cache persists until you run npm cache clean --force or the OS clears temp files.
npm cache verify # check cache integrity
npm cache clean --force # nuclear option — clears everything
The --yes flag
Tired of the prompt in scripts?
npx --yes create-cloudflare@latest
# or
npx -y create-cloudflare@latest
Useful in Dockerfiles and CI where stdin isn't interactive. Don't use blindly on developer machines — the prompt is a security feature.
Always pin versions with npx
# Dangerous — grabs latest, may break tomorrow
npx create-react-app my-app
# Correct — pinned, reproducible
npx create-react-app@5.0.1 my-app
This is especially important for project scaffolding tools used in CI or shared scripts.
The Version Conflict Problem
Here's a real scenario that trips up even experienced engineers:
# Your project has eslint@8 in devDependencies
npm install
# You also have eslint@9 installed globally
npm install -g eslint
# Now you run:
eslint . # runs global v9 — ignores your project's v8
npx eslint . # runs LOCAL v8 — correct behavior
npm run lint # runs LOCAL v8 via PATH resolution — correct
npm scripts and npx both prioritize local node_modules/.bin/ over globals. The bare eslint command uses your shell's PATH, which puts the global binary first. This divergence causes subtle, maddening bugs.
Rule of thumb: Never invoke project tools with bare commands. Always use npm run or npx to ensure you're using the version your project declares.
npm exec vs npx
As of npm v7, npm exec is the "official" version of npx. They're functionally equivalent but with slightly different flags:
npx some-tool arg1 arg2
npm exec -- some-tool arg1 arg2 # note the -- separator
npm exec is better documented, more predictable in edge cases, and is what the npm team recommends going forward. npx remains available as a convenience alias.
CI/CD Implications
Understanding these tools at a deeper level pays off in CI:
# Dockerfile — don't do this
RUN npm install -g typescript
RUN tsc --build
# Do this instead
COPY package*.json ./
RUN npm ci --ignore-scripts # deterministic, no lifecycle scripts
RUN npx tsc --build # uses local dep, no global install needed
npm ci over npm install in CI is non-negotiable: it's faster (skips dependency resolution), deterministic (reads lock file only), and fails loudly if the lock file is out of sync with package.json.
Quick Reference
| Command | Installs to | Persists? | Version-pinned? | Use case |
|---|---|---|---|---|
npm install pkg | ./node_modules | Yes | Via lock file | Project dependencies |
npm install -g pkg | Global node_modules | Yes | No | Machine-wide CLI tools |
npx pkg | Cache | Cache only | Only if you specify @version | One-off execution |
npm exec -- pkg | Cache | Cache only | Only if you specify @version | Same as npx, more explicit |
npm ci | ./node_modules | Yes | Yes (from lock file) | CI/CD installs |
The Takeaway
- Use
npm install --save-devfor any tool your project uses — lock it, commit it, reproduce it - Treat global installs as personal convenience tools, not team infrastructure
- Use
npxornpm execfor one-off scaffolding and rarely-used CLIs - Always pin versions when using npx in scripts or CI
- Use
npm ciin pipelines, nevernpm install - Prefer
npm run <script>over bare binary invocations to guarantee local resolution
Node's package tooling is mature and well-designed. The footguns mostly come from not understanding which resolution path you're on. Once you do, the behaviour becomes entirely predictable.