1. Courses
  2. Developer Foundations
  3. JavaScript in the Real World
  4. Stop 2

35 minIntermediate

Node.js and npm

Every JavaScript project Mangrove's engineers own, from partner-api to store-app, starts with a package.json and hundreds of packages someone else wrote. This lesson shows what Node is, how npm decides which versions to install, and how a lockfile and a pinned Node version end "works on my machine".

By the end you can

  • Explain what Node adds to JavaScript, and read every important field of a package.json
  • Choose between dependencies, devDependencies and peerDependencies
  • Read semver ranges (^, ~, exact) and predict which version npm will install
  • Use the lockfile and npm ci correctly, and upgrade safely with npm outdated, npm update and npm audit
  • Pin the team's Node version with .nvmrc and engines

The everyday picture

When a Mangrove Market store orders from a supplier, the order form does not only say "rice". It gives an exact item code, and often a rule for substitutes: "Basmati rice 5 kg, any batch from this season is fine, but not a different brand". The supplier picks the newest stock that follows the rule. The goods-received note (GRN) then records exactly which batch arrived, with its lot number.

package.json is the order form: package names, each with a rule for acceptable versions. npm is the supplier, choosing the newest version that follows each rule. And package-lock.json is the GRN: the exact version, download link and fingerprint of every package that arrived. If the next store orders with only the order form, it may get a newer batch. If it orders with the GRN in hand, it gets exactly the same goods.

What Node is, and what package.json says

Node runs JavaScript outside the browser. It is three things together: V8, the same JavaScript engine as Chrome; libuv, a C library that gives Node its event loop and does file and network work in the background (The event loop); and Node's own APIs such as node:fs, node:http and node:crypto. There is no DOM and no window; instead you get files, processes and sockets. Node has an even-numbered line every year; use 22 or 24 for real projects.

Terminal
node -p "const v = process.versions; ({ node: v.node, v8: v.v8, uv: v.uv })"
Output
{ node: '24.21.0', v8: '13.6.233.17-node.53', uv: '1.52.1' }

A project is a folder with a package.json. These are the fields you will read in every review:

  • name, version: matter when you publish a library; for an app, set "private": true so it can never be published by accident.
  • "type": "module": .js files are ES modules (Prototypes, classes and modules). Without it they are CommonJS.
  • scripts: named commands. npm run build runs the build script; npm test and npm start work without run. While a script runs, node_modules/.bin is on the PATH, so "lint": "eslint ." finds the project's own ESLint. Arguments after -- are passed through: npm run report -- D027.
  • dependencies, devDependencies, peerDependencies: the three lists in the next section.
  • engines: which Node versions the project supports, such as {"node": ">=24"}.

npx runs a package's command: the project's own copy if it is installed, otherwise it downloads it to a cache first. That is how you run npx serve or npx @nestjs/cli new without a global install.

Three kinds of dependency

dependencies are needed when the app runs: NestJS, Prisma Client, a CSV parser. devDependencies are needed only to build and test: TypeScript, Vitest, ESLint, @types/node. The difference matters at deploy time, because a production install with npm ci --omit=dev skips the dev list, making the server image smaller and giving attackers less code to work with. Install with npm install pkg or npm install -D pkg.

peerDependencies are for plugins. @nestjs/testing must use the same @nestjs/common as your app, not a private copy, so it declares @nestjs/common as a peer: "I need it, but the project provides it". If the project's version does not fit, npm 7 and later stop with an ERESOLVE error:

Output
npm error code ERESOLVE
npm error ERESOLVE unable to resolve dependency tree
npm error
npm error While resolving: partner-api@1.0.0
npm error Found: @nestjs/common@11.2.7
npm error node_modules/@nestjs/common
npm error   @nestjs/common@"^11.2.7" from the root project
npm error
npm error Could not resolve dependency:
npm error peer @nestjs/common@"^12.0.0" from @nestjs/testing@12.1.2
npm error node_modules/@nestjs/testing
npm error   dev @nestjs/testing@"12" from the root project
npm error

The message is long, but read it like a stack trace. "Found" is what you have (NestJS 11). "Could not resolve" is what the new package wants (12). The fix is to install matching versions, here @nestjs/testing@11, or to upgrade every @nestjs/* package together. --legacy-peer-deps or --force make the error go away and leave a mismatched install behind, so treat them as a red flag in a PR.

Quick check

The stock API uses Vitest only to run its tests. Which list should Vitest go in?

Semver ranges: which version will npm install?

Packages use : MAJOR.MINOR.PATCH, such as 5.6.0. A patch fixes bugs, a minor adds features without breaking anything, a major may break your code. You met it for releases in Branching strategies, releases and versions; here it decides what lands in node_modules. In package.json each dependency has a , and npm installs the highest published version that satisfies it:

  • ^1.4.2 (caret, npm's default): any 1.x.x from 1.4.2 up, so minor and patch updates, never 2.0.0.
  • ~1.4.2 (tilde): 1.4.x from 1.4.2 up, so patch updates only.
  • 1.4.2: exactly that version.
  • 1.x, >=1.5.0 <2.0.0, *: an x-range, explicit comparators, anything.

Two details catch people. Below 1.0.0, a caret is stricter, because the package is still unstable: ^0.3.1 means 0.3.x only. And prerelease versions such as 2.1.0-beta.1 are never picked by a normal range; you only get one if your range names a prerelease itself.

Quick check

Published versions are 2.3.1, 2.3.8, 2.4.0 and 3.0.0, and there is no lockfile. Which one does ~2.3.1 install?

Try the ranges below on a made-up price-format package. Tap ^1.4.2, then ~1.4.2, and watch which versions light up and which one npm picks. Then try ^0.3.1 and *, and look at what happens to 2.1.0-beta.1. Last, tick the lockfile box.

Simulator

Loading the simulator…

  • Caret allows new minors (1.9.3), tilde stops at the patch level (1.4.9). Neither crosses to 2.0.0, and a prerelease is never chosen by accident.
  • With a lockfile, npm ci installs exactly the locked version, whatever newer versions exist. The range only matters when the lock is missing or no longer fits package.json.

The lockfile, npm ci and node_modules

Ranges mean two installs on different days can give different trees. package-lock.json fixes that. After every install, npm writes down the exact version it chose for every package in the tree, including the dependencies of your dependencies, with the download link and an integrity hash so a changed file is rejected. Commit the lockfile, and review changes to it in PRs like any other code.

Two commands use it differently. npm install uses the lock when it still satisfies package.json, adds what is missing, and updates the lock when you add or change a dependency. npm ci ("clean install") is for CI, Docker builds and new laptops: it deletes node_modules, installs exactly what the lock says, never changes it, and fails if the lock and package.json disagree. That failure is a feature: it catches the PR where someone edited package.json and forgot to commit the new lock.

Where do packages go? npm builds the tree as flat as it can. If two packages need ms and the same version fits both, one copy sits at node_modules/ms. If they need versions that do not overlap, one goes to the top and the other is nested inside the package that needs it. When your code does import 'csv-parse', Node looks for node_modules/csv-parse in the current folder, then the parent folder, and so on up to the root. This is why one npm install can produce hundreds of folders, and why node_modules is never committed.

Quick check

The committed lockfile says csv-parse 5.5.0, package.json says ^5.5.0, and 5.6.0 came out yesterday. What does npm ci install on CI today?

package.json the order form csv-parse ^5.5.0 npm registry 5.5.0 5.6.0 6.2.1 7.0.3 ranges versions npm install highest that fits: 5.6.0 writes fills package-lock.json the GRN: exact 5.6.0 + hash node_modules the code itself never committed commit, then npm ci on CI and new laptops: exactly 5.6.0, always
npm install turns ranges into exact versions: it picks the highest published version each range allows, records it in package-lock.json with an integrity hash, and fills node_modules. Later, npm ci reproduces exactly that tree from the lockfile, even after 5.7.0 is published.
your app express 4 debug 2.6.9 send 0.19 needs 2.0.0 needs 2.1.3 ms 2.0.0 node_modules/ms ms 2.1.3 send/node_modules
A real duplicate from Express 4. debug pins ms to 2.0.0 and send needs 2.1.3. Both cannot sit at the top of node_modules, so npm nests the second copy inside send. Where the ranges overlap, npm keeps one shared copy instead ("deduped" in npm ls).

Pinning Node, and the rest of the toolbox

The lockfile pins packages, but not Node itself. If one developer runs Node 24 and another Node 22, code that uses a newer feature works for one and crashes for the other. Two small files fix that. .nvmrc holds the version (just 24), and a reads it. "engines": {"node": ">=24"} in package.json documents the same rule; with engine-strict=true in the project's .npmrc, npm refuses to install on the wrong version instead of only warning. Set the same version in CI (node-version-file: .nvmrc in actions/setup-node, see GitHub Actions: continuous integration) and in the Dockerfile.

Which version manager? fnm works on Windows, macOS and Linux and reads .nvmrc and .node-version. nvm is the classic one for macOS and Linux, and nvm-windows is a separate project for Windows. Volta's own README now says it is unmaintained, so do not start new projects on it.

Monorepos. If partner-api and a shared types package live in one repo, npm link them: list the folders under "workspaces" in the root package.json, and one npm install at the root installs everything and symlinks node_modules/@mangrove/shared to your local folder. npm test --workspaces runs each package's tests; -w apps/api targets one. TypeScript for a JavaScript team uses this to share a type between the API and the app.

pnpm and Yarn are alternative package managers with the same package.json. pnpm stores each version once on disk and links it into projects, which saves a lot of space and is strict about undeclared dependencies; Yarn has its own lockfile and an optional mode without node_modules. Each has its own lockfile, so a team picks one and sticks to it, and the repo should contain only that one lockfile.

Build it

Create a small package with scripts and one dependency, read its lockfile entry, upgrade it safely, then reproduce "works on my machine" and fix it. The outputs below come from Node 24.21 with npm 11; npm 10 on Node 22 prints the same messages. The caret and tilde rules from the semver section are also a Practice exercise below, which you can solve and check in your browser.

  1. Create the package and tidy package.json

    npm pkg set edits package.json from the terminal; --json makes true a boolean instead of a string.

    Terminal
    mkdir sales-report
    cd sales-report
    npm init -y
    npm pkg set description="Daily store sales report" type=module scripts.report="node report.js"
    npm pkg set private=true --json
    npm pkg delete main keywords author license scripts.test
    JSON
    {
      "name": "sales-report",
      "version": "1.0.0",
      "description": "Daily store sales report",
      "scripts": {
        "report": "node report.js"
      },
      "type": "module",
      "private": true
    }
  2. Add a dependency and read what npm wrote

    We install an older version on purpose, so there is something to upgrade later.

    Terminal
    npm install csv-parse@5.5.0
    Output
    
    added 1 package, and audited 2 packages in 1s
    
    1 moderate severity vulnerability
    
    To address all issues (including breaking changes), run:
      npm audit fix --force
    
    Run `npm audit` for details.

    package.json now has the range "csv-parse": "^5.5.0" (npm adds the caret). The lockfile has the exact version, where it came from and its fingerprint:

    JSON
    {
      "name": "sales-report",
      "version": "1.0.0",
      "lockfileVersion": 3,
      "requires": true,
      "packages": {
        "": {
          "name": "sales-report",
          "version": "1.0.0",
          "dependencies": {
            "csv-parse": "^5.5.0"
          }
        },
        "node_modules/csv-parse": {
          "version": "5.5.0",
          "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.0.tgz",
          "integrity": "sha512-RxruSK3M4XgzcD7Trm2wEN+SJ26ChIb903+IWxNOcB5q4jT2Cs+hFr6QP39J05EohshRFEvyzEBoZ/466S2sbw==",
          "license": "MIT"
        }
      }
    }
  3. Write the script and run it

    JavaScript
    // report.js: total sales per store from a CSV, optionally for one product name
    import { readFileSync } from 'node:fs';
    import { parse } from 'csv-parse/sync';
    
    const rows = parse(readFileSync('sales.csv', 'utf8'), { columns: true, cast: true });
    const search = process.argv[2];                                      // e.g. "lentils (red)"
    const match = search ? new RegExp(RegExp.escape(search), 'i') : /.*/;  // RegExp.escape: Node 24+
    
    const totals = {};
    for (const row of rows.filter((r) => match.test(r.name))) {
      totals[row.siteId] = (totals[row.siteId] ?? 0) + row.qty * row.priceCents;  // integer cents
    }
    const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
    for (const [siteId, cents] of Object.entries(totals)) console.log(siteId, usd.format(cents / 100));
    Terminal
    npm run report
    npm run report -- "lentils (red)"
    Output
    
    > sales-report@1.0.0 report
    > node report.js
    
    D027 $43.25
    G104 $25.50
    
    > sales-report@1.0.0 report
    > node report.js lentils (red)
    
    G104 $8.70
    D027 $2.90

    The money stays in integer cents until the very last step, where Intl.NumberFormat turns it into dollars for display (Working with data). RegExp.escape makes the brackets in "(red)" match literally instead of as regex syntax. It is new in Node 24, which matters in step 7.

  4. Commit, then see what the lockfile protects you from

    Make the project a Git repo (What Git really is), with node_modules ignored and the lockfile committed.

    Terminal
    git init
    echo node_modules/ > .gitignore
    git add .
    git commit -m "feat: add daily sales report"

    Now do what people sometimes do to "fix" an install: delete the lock and install again. (rm -r also works in PowerShell, as an alias of Remove-Item -Recurse.)

    Terminal
    rm package-lock.json
    rm -r node_modules
    npm install
    npm ls csv-parse
    git status --short
    Output
    sales-report@1.0.0 /home/you/sales-report
    `-- csv-parse@5.6.0
     M package-lock.json

    Without the lock, the range ^5.5.0 picked a newer version than the one you tested. Put the committed lock back and install from it:

    Terminal
    git restore package-lock.json
    npm ci
    npm ls csv-parse
    Output
    sales-report@1.0.0 /home/you/sales-report
    `-- csv-parse@5.5.0

    Exactly 5.5.0 again, and git status is clean. Last, edit package.json by hand to "csv-parse": "^7.0.3" without updating the lock, and run npm ci:

    Output
    npm error code EUSAGE
    npm error
    npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
    npm error
    npm error Invalid: lock file's csv-parse@5.5.0 does not satisfy csv-parse@7.0.3
    npm error

    That is the error CI shows when a PR forgets the lockfile. Undo the edit with git restore package.json.

  5. See what is out of date, and update within the range

    Terminal
    npm outdated
    Output
    Package    Current  Wanted  Latest  Location                Depended by
    csv-parse    5.5.0   5.6.0   7.0.3  node_modules/csv-parse  sales-report
    exit 1

    Read the three columns: Current is installed, Wanted is the highest version your range ^5.5.0 allows, Latest is the newest published. npm outdated exits with code 1 when anything is behind, so it can fail a CI step. npm update moves to Wanted, never across a major:

    Terminal
    npm update
    npm ls csv-parse
    Output
    sales-report@1.0.0 /home/you/sales-report
    `-- csv-parse@5.6.0

    The lockfile moved to 5.6.0; package.json still says ^5.5.0, which already allows 5.6.0. This time the change is on purpose: run the report again to check it still works, then commit the lock change on its own branch (Branches and merging).

  6. Check for known vulnerabilities, and upgrade a major on purpose

    Terminal
    npm audit
    Output
    # npm audit report
    
    csv-parse  <7.0.2
    Severity: moderate
    node-csv: Prototype replacement still reachable via columns path - https://github.com/advisories/GHSA-8cw4-87c7-c6xx
    fix available via `npm audit fix --force`
    Will install csv-parse@7.0.3, which is a breaking change
    node_modules/csv-parse
    
    1 moderate severity vulnerability
    
    To address all issues (including breaking changes), run:
      npm audit fix --force

    The fix is a new major, so npm audit fix without --force will not apply it, and --force would apply it without you reading anything. Do it deliberately instead: read the package's changelog for 6.0.0 and 7.0.0 (6.0.0 renamed several options; this script uses none of them), install the major, and test.

    Terminal
    npm install csv-parse@7
    npm pkg get dependencies
    npm run report -- "lentils (red)"
    Output
    
    changed 1 package, and audited 2 packages in 702ms
    
    found 0 vulnerabilities
    {
      "csv-parse": "^7.0.3"
    }
    
    > sales-report@1.0.0 report
    > node report.js lentils (red)
    
    G104 $8.70
    D027 $2.90
  7. Reproduce "works on my machine"

    Commit, and let a teammate on Node 22 clone the repo. The lockfile is there, so npm ci gives them exactly the same packages. It still fails:

    Terminal
    node -v
    npm ci
    npm run report -- "lentils (red)"
    Output
    v22.22.0
    
    added 1 package, and audited 2 packages in 607ms
    
    found 0 vulnerabilities
    
    > sales-report@1.0.0 report
    > node report.js lentils (red)
    
    file:///home/you/sales-report/report.js:7
    const match = search ? new RegExp(RegExp.escape(search), 'i') : /.*/;  // RegExp.escape: Node 24+
                                             ^
    
    TypeError: RegExp.escape is not a function
        at file:///home/you/sales-report/report.js:7:42
        at ModuleJob.run (node:internal/modules/esm/module_job:343:25)
        at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:665:26)
        at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5)
    
    Node.js v22.22.0
    exit 1

    Same packages, different Node. Now fix it for everyone:

    Terminal
    echo 24 > .nvmrc
    echo engine-strict=true > .npmrc
    npm pkg set engines.node=">=24"

    On the Node 22 laptop, the install now stops at once with a clear reason instead of a crash at runtime:

    Output
    npm error code EBADENGINE
    npm error engine Unsupported engine
    npm error engine Not compatible with your version of node/npm: sales-report@1.0.0
    npm error notsup Not compatible with your version of node/npm: sales-report@1.0.0
    npm error notsup Required: {"node":">=24"}
    npm error notsup Actual:   {"npm":"10.9.4","node":"v22.22.0"}
    npm error A complete log of this run can be found in: /home/you/.npm/_logs/2026-10-16T17_43_32_473Z-debug-0.log
    exit 1

    The teammate installs the right Node with their version manager. With fnm, run fnm install and fnm use in the project folder; both read .nvmrc. Add eval "$(fnm env --use-on-cd --shell bash)" to ~/.bashrc (or the zsh or PowerShell line from fnm's README) and it switches automatically on cd.

CommandWhat it doesUse it
npm installInstalls from the lock if it fits, updates the lock when package.json changesOn your machine, when adding or changing dependencies
npm ciDeletes node_modules, installs exactly the lock, fails if out of syncCI, Docker builds, new laptops
npm outdatedCurrent, wanted (by range) and latest versionsBefore planning upgrades
npm updateMoves to the newest version each range allowsRoutine minor and patch upgrades, on a branch
npm auditChecks the tree against known advisoriesIn CI, and before releases
npm ls pkg / npm explain pkgWho depends on a package, and which copies existFinding duplicates and why something is installed

Bring it to your team

Ask these when a PR touches package.json, or when a build breaks "for no reason".

  • Is package-lock.json committed, and does this PR's lock diff match the dependency change it describes?
  • Do CI and the Dockerfile run npm ci, and does the production image use --omit=dev?
  • Where is our Node version written down: .nvmrc, engines, CI and the Dockerfile? Do they agree?
  • This adds a new dependency: who maintains it, how many packages does it pull in, and could we do without it?
  • This is a major upgrade: did someone read the changelog, and which tests cover the code that uses it?
  • Who looks at npm audit or Dependabot alerts, and how fast do we act on a high one?

Red flags

  • package-lock.json in .gitignore, or deleted to make an error go away.
  • npm install in CI or in the Dockerfile.
  • --legacy-peer-deps or --force added to make an install pass, with no explanation.
  • npm audit fix --force run without reading which majors it changed.
  • Two lockfiles in one repo (package-lock.json and pnpm-lock.yaml).

Practice

Match versions against caret and tilde ranges

Write satisfies(version, range) for the three range forms in this lesson: exact (1.4.2), caret (^1.4.2) and tilde (~1.4.2). Versions are plain MAJOR.MINOR.PATCH strings, or carry a prerelease tag such as 2.1.0-beta.1, which these ranges never match. Remember the rule below 1.0.0: ^0.3.1 means 0.3.x from 0.3.1 up. pick is written for you: it returns the highest version that satisfies the range, the way npm chooses.

JavaScript exerciseRuns in your browser with tests
function parse(v) {
  const [major, minor, patch] = v.split('.').map(Number);
  return { major, minor, patch };
}

function satisfies(version, range) {
  if (version.includes('-')) return false;     // prereleases never match a normal range
  // TODO: handle '^1.4.2', '~1.4.2' and an exact '1.4.2'
  return version === range;
}

function pick(versions, range) {               // written for you: the highest match
  const ok = versions.filter((v) => satisfies(v, range));
  ok.sort((a, b) => {
    const x = parse(a), y = parse(b);
    return x.major - y.major || x.minor - y.minor || x.patch - y.patch;
  });
  return ok.at(-1) ?? null;
}
Ctrl + Enter runs
Show a solution
JavaScript
function parse(v) {
  const [major, minor, patch] = v.split('.').map(Number);
  return { major, minor, patch };
}

function satisfies(version, range) {
  if (version.includes('-')) return false;
  const op = range[0] === '^' || range[0] === '~' ? range[0] : '';
  const v = parse(version);
  const min = parse(op ? range.slice(1) : range);
  const cmp = v.major - min.major || v.minor - min.minor || v.patch - min.patch;
  if (op === '') return cmp === 0;                          // exact
  if (cmp < 0) return false;                                // below the minimum
  if (op === '~') return v.major === min.major && v.minor === min.minor;
  if (min.major > 0) return v.major === min.major;          // caret from 1.0.0 up
  if (min.minor > 0) return v.major === 0 && v.minor === min.minor;   // ^0.3.1
  return v.major === 0 && v.minor === 0 && v.patch === min.patch;      // ^0.0.3
}

function pick(versions, range) {
  const ok = versions.filter((v) => satisfies(v, range));
  ok.sort((a, b) => {
    const x = parse(a), y = parse(b);
    return x.major - y.major || x.minor - y.minor || x.patch - y.patch;
  });
  return ok.at(-1) ?? null;
}
Pass the exercise to finish this task.

Review a dependency pull request

A teammate's PR for partner-api is titled "Fix install error". The diff changes four things: package.json moves @nestjs/testing from ^11.1.0 to ^12.1.2 while every other @nestjs/* package stays on 11; the CI workflow changes npm ci to npm install --legacy-peer-deps; package-lock.json is deleted; and .gitignore gains the line package-lock.json. Write the review comment you would leave: what is wrong, and what you would ask for instead.

  • You name the real problem: a NestJS 12 testing package against NestJS 11, the peer conflict that ERESOLVE reported
  • You ask for matching versions: the testing package on 11, or every @nestjs/* package upgraded together
  • You ask to keep npm ci in CI and drop --legacy-peer-deps
  • You ask for the lockfile to be committed again, taken out of .gitignore, and its diff reviewed
Show a model answer

"Thanks for chasing this. The install error is the peer conflict: @nestjs/testing@12 needs @nestjs/common@12, and we are on 11. The flag and the deleted lock hide the conflict instead of fixing it, and without a lock every CI build installs whatever is newest that day. Could you install @nestjs/testing@11 for now (or open a separate PR that moves all @nestjs/* packages to 12 together), run npm install so the lock updates, put package-lock.json back in Git and out of .gitignore, and keep plain npm ci in CI? Then the lock diff should show only the testing package changing."

Check yourself

The published versions are 1.4.2, 1.4.9, 1.5.0, 1.9.3 and 2.0.0, and there is no lockfile. What does "^1.4.2" install?

CI runs npm ci and fails with "package.json and package-lock.json are not in sync". What most likely happened?

Where does TypeScript belong in a NestJS API's package.json?

Flashcards

Open my review

Key takeaways

  • Node is V8 plus libuv plus system APIs. package.json is the order form: scripts, ranges, engines.
  • Caret allows new minors, tilde only new patches, and npm installs the highest version that fits. Below 1.0.0 the caret is stricter.
  • The lockfile is the GRN: commit it, review it, and use npm ci in CI and Docker.
  • Upgrade in steps: npm outdated, npm update for in-range changes, majors one at a time after reading the changelog.
  • Pin Node with .nvmrc, engines and engine-strict, and use the same version in CI and production.

Sources

Episode transcriptThe order form, the GRN and the caret · about 12 minutes · tap any line to play from there
  1. NadiaImran, Thursday the nightly sales report passes every check. Friday morning it crashes on the server. Nobody changed the code. What's your first guess?
  2. ImranSomeone changed the code and didn't tell anyone. That's usually the answer.
  3. NadiaFair. But here nobody did. The code was the same, the packages were the same, and it still broke. Today is about the stuff around your code: Node, npm, versions and lockfiles. By the end you'll know exactly what happened on Friday.
  4. NadiaStart with ordering from a supplier. When a Mangrove store orders rice, the form doesn't just say rice. It has an item code and a rule for substitutes. Basmati, five kilo bags, any batch from this season, but not another brand.
  5. ImranAnd the supplier sends the newest stock that follows the rule. Then the GRN records exactly which batch arrived, with the lot number.
  6. NadiaThat's npm. Package dot json is the order form: package names, each with a rule for acceptable versions. npm is the supplier, picking the newest version that follows each rule. And the lockfile is the GRN: the exact version, where it came from and a fingerprint.
  7. ImranSo if the next store orders with only the form, it may get a newer batch. With the GRN, it gets the same goods.
  8. NadiaExactly the point. Let's go piece by piece. First, what is Node, really?
  9. ImranJavaScript outside the browser.
  10. NadiaYes, built from three parts. V8, the same JavaScript engine Chrome uses. Libuv, a C library that gives Node its event loop and does file and network work in the background. And Node's own modules for files, HTTP and crypto. No DOM, no window. Use an even-numbered long-term support line, twenty-two or twenty-four.
  11. ImranAnd package dot json. What should I actually read in it during a review?
  12. NadiaPrivate set to true for apps, so nobody publishes it by accident. Type module, so files are ES modules. Scripts, the named commands like build, test and start. Then the dependency lists. And engines, which Node versions the project supports.
  13. ImranThere are three dependency lists. I always mix up the third.
  14. NadiaDependencies are needed when the app runs: NestJS, Prisma client, a CSV parser. Dev dependencies are needed only to build and test: TypeScript, Vitest, ESLint. A production install can skip the dev list, which makes the server image smaller.
  15. NadiaPeer dependencies are for plugins. The NestJS testing package must use the same NestJS common package as your app, not its own copy. So it says, I need it, but you provide it.
  16. ImranAnd if my app is on NestJS eleven and I install the testing package for twelve?
  17. Nadianpm stops with a long error called E R E S O L V E. Read it like a stack trace. Found says what you have, eleven. Could not resolve says what the new package wants, twelve. The fix is to match versions. The flags that make the error go away, legacy peer deps or force, leave a broken install behind.
  18. ImranSo in a PR, those flags are a red flag.
  19. NadiaYes. Now versions. Packages use semantic versioning: major, minor, patch, like five point six point zero. Patch fixes bugs. Minor adds features without breaking anything. Major may break your code.
  20. NadiaIn package dot json each dependency has a range. The default one starts with a caret, the little hat symbol. Caret one point four point two means any one point something from one point four point two up, but never two point zero.
  21. ImranAnd the tilde, the little wave?
  22. NadiaTilde one point four point two means patches only: one point four point anything from two up. And a plain number with no symbol means exactly that version.
  23. ImranLet me test myself. Published versions go up to one point nine point three and two point zero. With caret one point four point two, npm installs one point four point two, because that's what's written.
  24. NadiaThat's the most common wrong guess. npm installs the highest version the range allows. So with the caret, one point nine point three. With the tilde, one point four point nine.
  25. ImranSo the number written in the file is the minimum, not the version.
  26. NadiaRight. Two more details. Below one point zero, the caret is stricter, because the package is still unstable. And beta versions are never picked by a normal range, only if you ask for a beta yourself.
  27. NadiaThe lesson has a simulator for this, with a made-up price format package. Later, tap caret, then tilde, and watch which versions light up and which one npm would install. Try caret zero point three point one, and the star. Then tick the lockfile box.
  28. ImranAnd the lockfile box changes everything, I'm guessing.
  29. NadiaIt does. If you can see the screen, the first diagram shows the flow. Package dot json and the npm registry at the top both feed into npm install.
  30. ImranAnd npm install is the supplier choosing the batch.
  31. NadiaYes. It picks the highest version that fits, writes the exact version into the lockfile, and fills the node modules folder. At the bottom, npm ci reads only the lockfile.
  32. ImranWhat's the difference between npm install and npm ci?
  33. NadiaNpm install uses the lock if it still fits, and updates it when you add or change a dependency. Npm ci, clean install, deletes node modules, installs exactly what the lock says, never changes the lock, and fails if the lock and package dot json disagree.
  34. ImranSo npm ci in CI, in the Docker build, and on a new laptop. And the lockfile is always committed.
  35. NadiaAlways. And that failure when the two files disagree is a feature. It catches the PR where someone changed package dot json and forgot to commit the new lock.
  36. NadiaOne more picture. The second diagram is a real example from Express four. Two of its packages both use a tiny package called ms, but they need different versions, two point zero point zero and two point one point three.
  37. NadiaOnly one can sit at the top of node modules, so npm nests the other copy inside the package that needs it.
  38. ImranThat's why node modules has hundreds of folders.
  39. NadiaYes. When versions overlap, npm shares one copy. When they don't, it duplicates. That's also why node modules never goes into Git.
  40. ImranOkay, back to Friday. The packages were locked. What broke?
  41. NadiaNode itself. The lockfile pins packages, not Node. The report used a function called RegExp escape, which is new in Node twenty-four. The developer and CI were on twenty-four. The old server still ran twenty-two, where that function doesn't exist.
  42. ImranSo nobody did anything wrong. The Node version just wasn't written down anywhere.
  43. NadiaExactly. Two small files fix it. A dot nvmrc file that says twenty-four, which version managers like fnm read. And engines in package dot json saying twenty-four or higher. Add engine strict in the project's npm config, and npm refuses to install on the wrong version instead of only warning.
  44. ImranAnd CI and the Dockerfile read the same version.
  45. NadiaYes. Then the Friday crash becomes an install error on the first wrong machine, with a clear message.
  46. ImranWhich version manager should my team use?
  47. NadiaThe lesson suggests fnm, because it works on Windows, macOS and Linux and reads the nvmrc file. Nvm is the classic one for Mac and Linux. And Volta's own page now says it's unmaintained, so don't start new projects on it.
  48. NadiaThe Build it section walks the whole story. You create a small sales report package, set its fields from the terminal, and install an older CSV parser on purpose. You read the lockfile entry: exact version, download link, fingerprint.
  49. NadiaYou write a report that totals sales per store, in whole cents, and only turns them into dollars when it prints.
  50. NadiaThen you commit, delete the lockfile, and reinstall. The CSV parser quietly moves from five point five to five point six. Restore the lock, run npm ci, and you're back on five point five exactly.
  51. ImranThen the upgrades.
  52. NadiaNpm outdated shows three columns: current, wanted by your range, and latest. Npm update moves to wanted, never across a major. Npm audit then reports a known security advisory that's only fixed in a new major. So you read the changelog first, install the major on purpose, and test.
  53. ImranNot audit fix with force.
  54. NadiaNot without reading what it changes. Last, a teammate on Node twenty-two clones it, runs npm ci, gets the same packages, and the report still crashes. You add the nvmrc, engines and engine strict, and now their install stops at once with a clear reason.
  55. ImranIs there a way to practise the caret and tilde rules, apart from the simulator?
  56. NadiaThe Practice section has you write them as a small function that answers, does this version fit this range. The page tests it with the same versions we just used, including the zero point three and beta traps. And there's a pull request to review that deletes the lockfile to fix an install error.
  57. NadiaSo, what will you ask when a PR touches package dot json?
  58. ImranFirst: is the lockfile committed, and does its diff match the change the PR describes? Second: do CI and the Dockerfile run npm ci? Third: where is our Node version written down, and do the nvmrc, engines, CI and Dockerfile all agree?
  59. NadiaI'd add one for new packages: who maintains it, and how many other packages does it pull in?
  60. NadiaOne to think about. CI fails with an error saying package dot json and the lockfile are not in sync. A teammate suggests switching CI to npm install so it passes. What do you say? Take a second.
  61. ImranNo. Someone changed package dot json and didn't commit the new lock. Run npm install locally, commit the lockfile, and keep npm ci in CI.
  62. NadiaExactly. Switching would hide the problem and make every build a little different.
  63. ImranLet me sum up. One: package dot json is the order form with ranges, and npm installs the highest version each range allows. The caret allows new minors, the tilde only patches.
  64. ImranTwo: the lockfile is the GRN. Commit it and use npm ci everywhere except when you're changing dependencies. Three: pin Node with the nvmrc and engines, because the lockfile doesn't pin Node.
  65. NadiaAll right. One nuance on two: review lockfile changes in PRs too. A one-line change in package dot json can move dozens of packages in the lock.
  66. ImranNext time?
  67. NadiaTypeScript: product codes on every carton, so the wrong item can't be shelved, and why the types disappear the moment your code runs.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Utility script

A small script with its options at the top, input checks, a summary line and tests.

Open in the Code Lab
Next stopTypeScript for a JavaScript team
Was this lesson helpful?
Questions and discussion

Loading the discussion…