---
title: "07 — Package Managers — How I Reuse Other People's Code Safely"
uid: package-managers
tags: ["yarn", "tooling", "pnpm", "npm", "bun", "roadmap:frontend", "dependencies"]
excerpt: "package.json declares what you want, the lockfile pins exactly what everyone gets, node_modules is the materialized result — that's the whole package manager."
date: 2026-08-12T18:35:11+0000
source: https://www.aveshina.my.id/en/blog/package-managers
---

"The thing that makes node_modules appear" was my package-manager model, and it stopped working the first time a lockfile conflict appeared. The model that everything else hangs off: **a package manager is how I reuse other people's code safely.** I declare what I want, it resolves a tree of dependencies from a registry, and it pins the result so that what runs on my machine is what runs on yours [1].

The way of thinking that finally clicked is a three-part split, and getting those three parts cleanly separated is most of the value:

```figure
<svg viewBox="0 0 720 250" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="Three stages of dependency management. Left box: package.json, labelled declares WHAT I want — a list of package names and version ranges. Middle box: the lockfile, labelled pins EXACTLY which versions everyone gets — full resolved tree, hashes, every transitive dependency. Right box: node_modules, labelled the materialized result — the actual files on disk. Arrows flow left to right between them.">
  <defs>
    <marker id="parrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>
    </marker>
  </defs>
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">
    <!-- package.json -->
    <rect x="20" y="50" width="200" height="150" rx="10" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="120" y="78" font-size="14" font-family="ui-monospace, monospace" font-weight="700" fill="#1e1b4b" text-anchor="middle">package.json</text>
    <text x="120" y="104" font-size="11" font-weight="700" fill="#1e1b4b" text-anchor="middle">declares WHAT I want</text>
    <text x="120" y="124" font-size="10" fill="#475569" text-anchor="middle">names + version ranges</text>
    <text x="120" y="142" font-size="10" fill="#475569" text-anchor="middle">"react": "^18.2.0"</text>
    <text x="120" y="160" font-size="10" fill="#475569" text-anchor="middle">"next": "^16.0.0"</text>

    <!-- lockfile -->
    <rect x="260" y="50" width="200" height="150" rx="10" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="360" y="78" font-size="14" font-family="ui-monospace, monospace" font-weight="700" fill="#422006" text-anchor="middle">lockfile</text>
    <text x="360" y="104" font-size="11" font-weight="700" fill="#422006" text-anchor="middle">pins EXACTLY what</text>
    <text x="360" y="120" font-size="11" font-weight="700" fill="#422006" text-anchor="middle">everyone gets</text>
    <text x="360" y="146" font-size="10" fill="#475569" text-anchor="middle">full resolved tree</text>
    <text x="360" y="162" font-size="10" fill="#475569" text-anchor="middle">integrity hashes</text>
    <text x="360" y="178" font-size="10" fill="#475569" text-anchor="middle">every transitive dep</text>

    <!-- node_modules -->
    <rect x="500" y="50" width="200" height="150" rx="10" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="600" y="78" font-size="14" font-family="ui-monospace, monospace" font-weight="700" fill="#052e16" text-anchor="middle">node_modules/</text>
    <text x="600" y="104" font-size="11" font-weight="700" fill="#052e16" text-anchor="middle">the materialized</text>
    <text x="600" y="120" font-size="11" font-weight="700" fill="#052e16" text-anchor="middle">result</text>
    <text x="600" y="146" font-size="10" fill="#475569" text-anchor="middle">actual files on disk</text>
    <text x="600" y="162" font-size="10" fill="#475569" text-anchor="middle">what the code imports</text>

    <!-- arrows -->
    <line x1="222" y1="125" x2="258" y2="125" stroke="#64748b" stroke-width="1.5" marker-end="url(#parrow)"/>
    <line x1="462" y1="125" x2="498" y2="125" stroke="#64748b" stroke-width="1.5" marker-end="url(#parrow)"/>

    <text x="360" y="225" font-size="11" fill="#64748b" text-anchor="middle" font-style="italic">declare  ▸  resolve &amp; pin  ▸  materialize</text>
  </g>
</svg>
```

Once I could see those three stages, the differences between npm, Yarn, pnpm, and Bun stopped being a feature checklist. They're four answers to the same three questions — how they resolve, how they pin, and how they materialize. Most of the "which one should I use" debate collapses to how each one answers the third question.

## The three parts, and why each exists

**package.json is the declaration.** I list the packages I directly depend on and the version *ranges* I'll accept [2]. "react": "^18.2.0" means "any 18.x that's at least 18.2.0." Ranges, not exact versions, because I want bugfix patches to flow in. The declaration is small, human-edited, and checked into git. It answers *what do I want*, nothing more.

**The lockfile is the pin.** This is the part I underweighted for years. The lockfile records the *exact* version of every package that got installed — including every transitive dependency I never asked for directly, plus an integrity hash for each [3]. It exists for one reason: so that the build on my machine, the build in CI, and the build on a teammate's fresh clone all resolve to the *same* tree. Without it, two npm install runs a week apart can quietly produce different node_modules from the same package.json, because a dependency published a new patch. The lockfile is the contract. It is checked into git; the declaration alone is not enough.

**node_modules is the materialized result.** It's the actual files on disk that my import statements resolve to [1]. It is large, machine-generated, and gitignored. The mistake I made early on was treating node_modules as the source of truth — deleting it and re-installing would sometimes "change behavior," and I couldn't explain why. The truth is the lockfile is the source of truth; node_modules is just the rendered output, and the reason a fresh install could differ was that I didn't have a lockfile worth the name.

So the whole job is: **declare loosely, pin exactly, materialize faithfully.** Every package manager does this; they differ in how.

## npm — the default, and the biggest

npm ships with Node, so it's the one I reached for first by sheer availability [2]. It reads package.json, writes package-lock.json, and installs into node_modules with a flattened tree — every package's dependencies get hoisted to the top level where possible, with nested copies only when versions conflict.

The flattening matters because Node's resolution algorithm walks up the directory tree looking for a module [1]. Hoisting means most packages end up at the top node_modules/ and a single import resolves. The cost is that this flat structure lets me require() packages I never declared — so-called *phantom dependencies* — which work until they suddenly don't. npm is the most popular choice and the lowest-friction default, and for most projects it's the right answer simply because it's already there [4].

## Yarn — lockfiles first, then Plug'n'Play

Yarn was the first serious npm alternative, and its original contribution to my way of thinking was the lockfile: Yarn shipped yarn.lock with deterministic, checksummed resolution before npm had a stable equivalent [5]. The modern line is **Yarn Berry**, whose headline feature is **Plug'n'Play (PnP)** — instead of materializing node_modules at all, Yarn keeps packages as .zip archives in a cache and teaches Node to resolve imports directly from them [5].

The appeal is that there's no node_modules folder to write — installs are fast and projects stay small on disk. The catch is that PnP requires tooling to play along: anything that expects to find node_modules/ on disk (some bundlers, some IDEs, native add-ons) needs a compatibility shim or breaks. Yarn Berry is excellent in a controlled, monorepo-friendly setup; it asks more of the surrounding toolchain than npm does.

## pnpm — the global store and strict symlinks

pnpm is where the model clicked hardest for me. Its defining choice is a **global content-addressable store**: every version of every package is downloaded exactly once, machine-wide, and each project's node_modules is built from *symlinks* into that store [6]. Two consequences fall out of that, and they're the reason pnpm exists.

First, **disk efficiency.** Ten projects depending on React 18.2.0 share one copy of those files on disk, not ten [6]. For a machine with many repos this is a real saving — my ~/.pnpm-store is a few gigabytes where an npm-style setup would be many tens.

Second, **strictness.** Because the on-disk layout uses symlinks arranged to match the declared dependency tree, pnpm does *not* flatten the way npm does. A package can only import what it declared in its own package.json — phantom dependencies simply fail to resolve [6]. This bit me once when a project that "worked fine under npm" broke under pnpm: it had been silently importing a transitive dependency it never declared. pnpm surfaced the bug that npm's flat hoisting had been hiding. That strictness is the feature, not the downside.

```figure
<svg viewBox="0 0 720 240" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="Two materialization strategies compared. Left side, npm style: three project folders each contain their own full node_modules with duplicated copies of react and lodash — boxes repeated. Right side, pnpm style: one global store at the top holds single copies of react and lodash; the three project folders each contain a node_modules made of symlinks (dashed arrows) pointing up into the store. No duplication.">
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">
    <!-- npm side header -->
    <text x="180" y="22" font-size="12" font-weight="700" fill="#1e1b4b" text-anchor="middle">npm / Yarn classic — copies per project</text>

    <!-- three npm projects -->
    <g font-size="10" font-family="ui-monospace, monospace">
      <rect x="40" y="40" width="100" height="170" rx="6" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.2"/>
      <text x="90" y="58" font-weight="700" fill="#1e1b4b" text-anchor="middle">proj A/</text>
      <rect x="52" y="70" width="76" height="22" rx="3" fill="#c7d2fe" stroke="#6366f1"/>
      <text x="90" y="85" fill="#1e1b4b" text-anchor="middle">react@18</text>
      <rect x="52" y="98" width="76" height="22" rx="3" fill="#c7d2fe" stroke="#6366f1"/>
      <text x="90" y="113" fill="#1e1b4b" text-anchor="middle">lodash@4</text>
      <rect x="52" y="126" width="76" height="22" rx="3" fill="#c7d2fe" stroke="#6366f1"/>
      <text x="90" y="141" fill="#1e1b4b" text-anchor="middle">react@18</text>

      <rect x="150" y="40" width="100" height="170" rx="6" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.2"/>
      <text x="200" y="58" font-weight="700" fill="#1e1b4b" text-anchor="middle">proj B/</text>
      <rect x="162" y="70" width="76" height="22" rx="3" fill="#c7d2fe" stroke="#6366f1"/>
      <text x="200" y="85" fill="#1e1b4b" text-anchor="middle">react@18</text>
      <rect x="162" y="98" width="76" height="22" rx="3" fill="#c7d2fe" stroke="#6366f1"/>
      <text x="200" y="113" fill="#1e1b4b" text-anchor="middle">lodash@4</text>

      <rect x="260" y="40" width="100" height="170" rx="6" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.2"/>
      <text x="310" y="58" font-weight="700" fill="#1e1b4b" text-anchor="middle">proj C/</text>
      <rect x="272" y="70" width="76" height="22" rx="3" fill="#c7d2fe" stroke="#6366f1"/>
      <text x="310" y="85" fill="#1e1b4b" text-anchor="middle">react@18</text>
      <rect x="272" y="98" width="76" height="22" rx="3" fill="#c7d2fe" stroke="#6366f1"/>
      <text x="310" y="113" fill="#1e1b4b" text-anchor="middle">react@18</text>
    </g>
    <text x="180" y="222" font-size="10" fill="#64748b" text-anchor="middle" font-style="italic">same files, stored many times</text>

    <!-- divider -->
    <line x1="378" y1="30" x2="378" y2="210" stroke="#cbd5e1" stroke-width="1" stroke-dasharray="4 4"/>

    <!-- pnpm side header -->
    <text x="540" y="22" font-size="12" font-weight="700" fill="#052e16" text-anchor="middle">pnpm — global store + symlinks</text>

    <!-- global store -->
    <rect x="430" y="36" width="220" height="44" rx="6" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="540" y="54" font-size="11" font-family="ui-monospace, monospace" font-weight="700" fill="#052e16" text-anchor="middle">~/.pnpm-store (one copy each)</text>
    <text x="540" y="70" font-size="10" font-family="ui-monospace, monospace" fill="#052e16" text-anchor="middle">react@18 · lodash@4</text>

    <!-- three pnpm projects -->
    <g font-size="10" font-family="ui-monospace, monospace">
      <rect x="430" y="120" width="60" height="80" rx="6" fill="#bbf7d0" stroke="#16a34a" stroke-width="1.2"/>
      <text x="460" y="138" font-weight="700" fill="#052e16" text-anchor="middle">A/</text>
      <text x="460" y="158" fill="#052e16" text-anchor="middle" font-style="italic">symlinks</text>
      <text x="460" y="174" fill="#052e16" text-anchor="middle" font-style="italic">→ store</text>

      <rect x="510" y="120" width="60" height="80" rx="6" fill="#bbf7d0" stroke="#16a34a" stroke-width="1.2"/>
      <text x="540" y="138" font-weight="700" fill="#052e16" text-anchor="middle">B/</text>
      <text x="540" y="158" fill="#052e16" text-anchor="middle" font-style="italic">symlinks</text>
      <text x="540" y="174" fill="#052e16" text-anchor="middle" font-style="italic">→ store</text>

      <rect x="590" y="120" width="60" height="80" rx="6" fill="#bbf7d0" stroke="#16a34a" stroke-width="1.2"/>
      <text x="620" y="138" font-weight="700" fill="#052e16" text-anchor="middle">C/</text>
      <text x="620" y="158" fill="#052e16" text-anchor="middle" font-style="italic">symlinks</text>
      <text x="620" y="174" fill="#052e16" text-anchor="middle" font-style="italic">→ store</text>
    </g>

    <!-- dashed arrows up -->
    <g stroke="#16a34a" stroke-width="1" stroke-dasharray="3 3" fill="none">
      <line x1="460" y1="120" x2="490" y2="82"/>
      <line x1="540" y1="120" x2="540" y2="82"/>
      <line x1="620" y1="120" x2="590" y2="82"/>
    </g>
    <text x="540" y="222" font-size="10" fill="#64748b" text-anchor="middle" font-style="italic">stored once, referenced everywhere</text>
  </g>
</svg>
```

pnpm also has first-class monorepo support (workspaces) and, because of the strict layout, stronger isolation between packages [6]. If npm's flat hoisting is permissive, pnpm's symlink layout is the strict one — and for any project where "it worked under npm but broke under pnpm" happens, the bug was always in the declarations, not in pnpm.

## Bun — runtime, bundler, and package manager in one

Bun is the outlier, because it isn't really *just* a package manager [7]. It's a JavaScript runtime (an alternative to Node itself), a bundler, a transpiler, and a package manager, all built together and aimed at raw speed — it uses JavaScriptCore rather than V8 and is written in a lower-level stack optimized for fast installs [7].

The reason I keep it in a separate category in my head is that the other three are *package managers that run on top of Node.* Bun replaces the runtime too. So bun install is a drop-in package manager for a Node project — Bun reads package.json, writes a bun.lockb, and installs faster than the others — but it's also the entry point to a whole different toolchain (bun run, bun build) [7]. The package-manager piece is excellent and notably quick. Whether I'd adopt the rest of the runtime is a separate decision from whether I like its installs.

## How I use this

The practical payoff is mostly one question when I start a project: *which materialization strategy do I want?* Default to npm when I want zero friction and the ecosystem's broadest compatibility; reach for pnpm when disk usage and dependency strictness matter (monorepos, many repos on one machine, or a codebase that's been quietly accumulating phantom imports); consider Yarn Berry when I'm committed to its toolchain and want to skip node_modules entirely; and treat Bun as a fast installer I can adopt incrementally, with the option of the full runtime later. In every case the three-part model still holds — declare in package.json, pin in the lockfile, materialize in node_modules (or its equivalent). The lockfile is the part I check in and trust; everything else is rendering.

## References

[1] npm, Inc., "About npm," npm Docs, 2024. [Online]. Available: [https://docs.npmjs.com/about-npm](https://docs.npmjs.com/about-npm)

[2] npm, Inc., "Specifics of npm's package.json handling," npm Docs, 2024. [Online]. Available: [https://docs.npmjs.com/cli/v10/configuring-npm/package-json](https://docs.npmjs.com/cli/v10/configuring-npm/package-json)

[3] npm, Inc., "package-lock.json," npm Docs, 2024. [Online]. Available: [https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json](https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json)

[4] NodeSource, "An Absolute Beginner's Guide to Using npm," 2023. [Online]. Available: [https://nodesource.com/blog/an-absolute-beginners-guide-to-using-npm/](https://nodesource.com/blog/an-absolute-beginners-guide-to-using-npm/)

[5] Maël Nison and contributors, "Yarn — Plug'n'Play," Yarn Docs, 2024. [Online]. Available: [https://yarnpkg.com/features/pnp](https://yarnpkg.com/features/pnp)

[6] Zoltan Kochan and contributors, "About pnpm," pnpm.io, 2024. [Online]. Available: [https://pnpm.io/about](https://pnpm.io/about)

[7] Oven-sh, "Bun — Package Manager," Bun Docs, 2024. [Online]. Available: [https://bun.com/docs/install](https://bun.com/docs/install)

```quiz
Q: Which file declares WHAT packages you want, as version ranges?
- package.json
- the lockfile
- node_modules
correct: 0
explain: package.json holds the direct dependencies as ranges (e.g. "react": "^18.2.0"). It declares intent; it does not pin exact resolutions.

Q: Two `npm install` runs from the same package.json a week apart produce different node_modules. What was missing?
- Nothing — that's expected and fine
- A checked-in lockfile pinning the exact resolved tree
- A larger node_modules folder
correct: 1
explain: The lockfile records the exact version and integrity hash of every package, including transitive ones. Without it, fresh installs can resolve differently as new patches are published.

Q: Why does pnpm avoid duplicating files across many projects on the same machine?
- It downloads smaller packages
- It keeps one global store and builds each project's node_modules from symlinks into it
- It compresses node_modules
correct: 1
explain: pnpm uses a content-addressable global store; each project references the single copy via symlinks, so identical versions are stored once machine-wide.

Q: A project "works under npm" but breaks under pnpm with "module not found." What's the most likely cause?
- pnpm has a bug resolving common packages
- The code was importing a transitive dependency it never declared — npm's flat hoisting hid it, pnpm's strict layout exposes it
- The lockfile is corrupt
correct: 1
explain: npm flattens node_modules and lets you import undeclared (phantom) dependencies. pnpm's symlink layout is strict, so only declared dependencies resolve — it surfaces the bug npm was masking.

Q: What makes Bun different from npm, Yarn, and pnpm?
- It is the only one with a lockfile
- It is a JavaScript runtime, bundler, and package manager combined — not just a package manager running on Node
- It cannot read package.json
correct: 1
explain: Bun replaces the runtime too (using JavaScriptCore), with the package manager as one piece. The others are package managers that run on top of Node.
```
