on this page

You ran npm install once, a folder called node_modules appeared with roughly nine thousand files in it, and you have been politely not asking what just happened ever since. That’s fine. Almost nobody explains it. You type the magic word, the libraries show up, and you get back to building.

But there’s a quiet little file sitting next to your code that decides whether your project works the same way on your laptop, on your teammate’s laptop, and on the server that actually ships it to people. Most beginners ignore it, some delete it to “fix” things, and a few accidentally commit two of them. This guide is about that file, the tools that write it, and how to stop fighting it.

What a package manager actually does

A package manager is the tool that handles the libraries your project depends on. Real apps lean on other people’s code: a web framework like Astro or Express, a date library so you never hand-write timezone math, a database client for PostgreSQL. Those are dependencies, and they have their own dependencies, which have their own dependencies, all the way down.

You are not going to track that tree by hand. The package manager does three jobs for you:

  1. It reads your project’s list of dependencies (that’s package.json, coming up next).
  2. It downloads each one, plus everything they depend on, from a registry (a big public warehouse of packages; the default is the npm registry

    Heads up. You're leaving raindev.fyi

    This link heads to www.npmjs.com, an external site we don't control. Cool to keep going?

    Continue
    ).
  3. It writes down exactly what it installed, down to the precise version of every single package, so the install can be repeated perfectly later.

That third job is the one people sleep on, and it’s the reason this whole guide exists.

The big three managers for JavaScript projects are npm (Node Package Manager), pnpm (performant npm), and Yarn (Yet Another Resource Negotiator, which is a joke name that stuck). They all do the same three jobs. They mostly differ in speed, disk usage, and which file they write things down in.

package.json: your project’s ingredient list

package.json is the file that describes your project to the package manager. The part you care about right now is the dependencies. Here’s a trimmed-down example:

{
  "name": "my-app",
  "scripts": {
    "dev": "astro dev",
    "build": "astro build"
  },
  "dependencies": {
    "astro": "^4.5.0",
    "react": "^18.2.0"
  },
  "devDependencies": {
    "eslint": "^8.57.0",
    "prettier": "^3.2.0",
    "vitest": "^1.4.0"
  }
}

Three sections matter here.

dependencies are the packages your app needs to actually run. Astro and React are in here because the site can’t function without them. If it ships, it needs these.

devDependencies are the packages you only need while building and testing on your own machine: ESLint to catch sloppy code, Prettier to format it, Vitest to run your tests. Your users never touch these, and a production build can skip installing them. The split exists so you don’t ship your test runner to people loading your homepage.

scripts are named shortcuts for longer commands. npm run dev runs whatever dev points to. That’s a whole topic of its own, so I’ll point you at the npm scripts guide instead of repeating it here.

The thing to notice is that caret in "react": "^18.2.0". That ^ is not decoration. It means a range, and ranges are where the lockfile earns its keep.

The lockfile, in plain terms

Look at "react": "^18.2.0" again. That caret means “React version 18.2.0, or any newer 18.x release.” It’s a range, not a single version. So when the package manager goes to install React, it has a decision to make: 18.2.0? 18.3.1? Whatever the latest 18.x happens to be on the day it runs?

If two people install on two different days, they can get two different versions of React, plus different versions of the dozens of sub-packages underneath it. That’s how you end up with the oldest line in software: “but it works on my machine.”

The lockfile is how the package manager closes that gap. After it resolves every range into a real version, it records the exact version of every package and every sub-package it installed, pinning the whole tree. The next person who installs doesn’t re-guess from the ranges. They read the lockfile and get the identical tree you got, byte for byte.

So package.json says what you want (“React 18-ish or newer”). The lockfile says what you actually got (“React 18.2.0 exactly, with this exact version of every dependency under it”). You write the first one by hand. The package manager writes and maintains the second one, and you commit it to Git so everybody shares the same answer.

Each manager names its lockfile differently, which is the most common source of “wait, which one am I using” confusion:

ManagerLockfile
npmpackage-lock.json
pnpmpnpm-lock.yaml
Yarnyarn.lock

npm install versus npm ci

These two commands look similar and do meaningfully different things. Knowing which is which saves you from a whole category of confusing build failures.

npm install (you can also type npm i) is the one you use at your desk. It reads package.json, resolves the ranges into real versions, installs everything into node_modules, and updates the lockfile if anything changed. When you run npm install react-router to add a new package, this is exactly what you want: it figures out a version, installs it, and records the result in the lockfile so your teammates get the same one.

# At your desk, adding or updating packages
npm install               # install everything from package.json
npm install react-router  # add a new dependency and update the lockfile

npm ci (short for “clean install”) is the one build servers use. It does not guess. It ignores the ranges in package.json and installs exactly what the lockfile pins, nothing more, nothing less. Before it starts, it wipes node_modules entirely so there’s no leftover state from a previous run. And if package.json and the lockfile disagree, it doesn’t quietly paper over the difference: it fails on the spot and tells you to sort it out.

# On a build server: reproducible and strict
npm ci   # delete node_modules, install the exact lockfile tree, or fail

That strictness is the point. On GitHub Actions

Heads up. You're leaving raindev.fyi

This link heads to github.com, an external site we don't control. Cool to keep going?

Continue
, Cloudflare Pages, Railway, or Render, you want the build to install the same versions every single time, with no surprise upgrades sneaking in because someone published a new release this morning. npm ci is also faster in that setting, because skipping the resolve-and-guess step is less work. It even refuses to run if there’s no lockfile at all, which is deliberate: it’s telling you the thing it relies on to be reproducible is missing.

The anchor of this rule: npm install is for changing your dependencies. npm ci is for installing them exactly as recorded. Use the first one while you build, the second one wherever the build has to be repeatable.

The three managers, briefly and fairly

People get weirdly tribal about this. The honest truth is that all three are fine, they all read a package.json, and you can build a real project with any of them. They differ in ways that mostly matter at the edges.

ManagerLockfileInstall commandWhy you’d pick it
npmpackage-lock.jsonnpm installShips with Node, zero setup, the safe default
pnpmpnpm-lock.yamlpnpm installFast, saves disk by sharing one global store
Yarnyarn.lockyarn installThe older challenger, still common in existing codebases

npm comes bundled with Node.js. If you installed Node, you already have npm, no extra step. It’s the default, it’s everywhere, and for most beginners that’s reason enough. Nothing wrong with the thing that’s already on your machine and works.

pnpm is the speed-and-disk option. Normally every project keeps its own private copy of every package, so ten Astro projects means ten full copies of Astro eating your SSD. pnpm keeps one copy of each package version in a single global store and hard links it into each project, which is an operating-system trick for pointing at the same file from two places without duplicating it. The result: installs are quick and node_modules stops devouring your disk. If you juggle a lot of projects, you’ll feel the difference.

Yarn showed up years ago when npm was slower and less reliable, fixed a bunch of those problems, and pushed npm to get better. npm caught up. Yarn is still around and you’ll meet it in existing codebases, so it’s worth recognizing, but for a brand-new project most people now reach for npm or pnpm.

If you genuinely have no preference: start with npm because it’s already installed, and switch to pnpm later if disk space or install speed starts to bug you. That’s a five-minute change, not a life decision.

Don’t mix package managers in one repo

Here’s a rule that prevents a specific, annoying class of bug: pick one package manager per repository and stick with it.

The reason is mechanical. Each manager writes its own lockfile. If you run npm install today and your teammate runs yarn install tomorrow, the repo now has both a package-lock.json and a yarn.lock. That’s two lockfiles, which means two separate records of “the exact versions,” and nothing keeps them in sync. They drift. One person’s install resolves from one file, another person’s from the other, and you’re right back to mismatched dependency trees, except now it’s harder to spot because everything looks committed and fine.

So decide once, commit only that manager’s lockfile, and delete the others. If you ever see two lockfiles in a project, that’s not a tidy “we support both” setup. It’s a bug waiting to bite someone.

This is also where matching the existing environment matters. Locking dependency versions is the same instinct as pinning environment variables per environment: you want the machine that ships your code to behave like the machine you wrote it on. Two sources of truth, whether it’s two lockfiles or two sets of config, is how that promise quietly breaks.

Why deleting the lockfile is usually goblin behavior

At some point an install will act weird, you’ll search for the error, and a forum reply from 2019 will tell you to delete node_modules and the lockfile and reinstall. Deleting node_modules is harmless; it’s a generated folder, it comes right back. Deleting the lockfile is a different story, and it’s worth understanding why before you do it on reflex.

The lockfile is the record of the exact versions that were known to work. Delete it, and the next npm install has nothing to read, so it falls back to resolving the ranges in package.json from scratch. It happily picks fresh versions, possibly newer than what your team has been running, and writes a brand-new lockfile. Sometimes that’s fine. Sometimes one of those fresh versions has a behavior change, and now the bug you were chasing has a sibling. You threw away the one artifact that pinned a working state, and you reintroduced exactly the “works on my machine” gap the lockfile existed to prevent.

There is a time to delete it on purpose: when you actually want to force an upgrade and let everything re-resolve to current versions. That’s a deliberate move, not a panic reflex. If you do it, do it intentionally, run your tests, and commit the regenerated lockfile so the new pinned state is the one everyone shares. Deleting it and not committing the result is the goblin move, because now your machine has one set of versions and the repo has another.

Common mistakes

These are the package-manager mistakes that catch beginners most often. Each one comes from treating the lockfile as noise instead of the contract it is.

MistakeWhat happensDo this instead
Committing two lockfilesTwo sources of truth, drifting installsPick one manager, delete the other lockfile
Deleting the lockfile to “fix” an installFresh versions resolved, new bugs introducedLeave it alone unless you mean to upgrade
npm install on CIBuild can grab different versions each runUse npm ci so the lockfile is law
Not committing the lockfile at allNobody shares the same versionsCommit the lockfile like it’s source code

If you fix only one of these, make it the last one. A committed lockfile is what turns “it worked when I built it” into “it works the same way wherever it runs.”

Why any of this matters for shipping

Step back and the whole thing is one idea: an install you can repeat exactly. package.json says roughly what you want, the lockfile pins exactly what you got, npm ci reinstalls that exact tree wherever it needs to run, and committing the lockfile means everyone (you, your teammates, the build server) is working from the same versions.

That matters the moment you stop coding alone and start shipping. When you push to GitHub and a host like Cloudflare Pages or Railway runs npm ci to build your project, the only way it can match what you tested locally is if the lockfile told it precisely what to install. Skip the lockfile, mix managers, or delete it in a panic, and you reintroduce the exact uncertainty that makes deploys scary. Respect it, and the boring, beautiful outcome is that the thing you built on your laptop is the same thing strangers load in their browser. That’s the whole job.