Skip to main content
Node.js · lesson 3 of 20
Node.js beginner Lesson 3 of 20

package.json, npm Scripts, and node --watch

Run npm scripts, install a dependency and read the caret it adds to package.json, then watch node --watch detect a real file change and restart.

By SkillByExample · Published

nodejs npm

Every Node project starts with the same file: package.json. Node is 26.12.1 and npm is 11.20.0 for every command below (Docker’s node:26 image).

scripts Are Just Shell Commands With Names

// package.json
{
  "name": "npm-watch-demo",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  }
}
// server.js
import { createServer } from "node:http";

const GREETING = "Hello from v1";

const server = createServer((req, res) => {
  res.writeHead(200, { "content-type": "text/plain" });
  res.end(GREETING);
});

server.listen(3000, () => console.log(`listening on http://localhost:3000 — ${GREETING}`));
$ npm run start

> npm-watch-demo@1.0.0 start
> node server.js

listening on http://localhost:3000 — Hello from v1

npm run start (or just npm start, for that one specific name) looked up the start entry and ran node server.js — no different from typing the command directly, except now it’s documented in one place and the same for every machine that runs it.

node —watch Restarts on Every Save

dev runs the same server through --watch, a flag built into node itself.

$ npm run dev

> npm-watch-demo@1.0.0 dev
> node --watch server.js

listening on http://localhost:3000 — Hello from v1

Editing GREETING in server.js from "Hello from v1" to "Hello from v2" while that process is still running, and saving the file, produces this — with no restart of the npm command itself:

Change detected in '/root/node-api/server.js'
Restarting 'server.js'
listening on http://localhost:3000 — Hello from v2

--watch watches the files Node has actually loaded (here, just server.js) and re-runs the whole process on a change, printing which file triggered it before the server comes back up with the new code already in effect.

Installing a Dependency and Reading What Changed

$ npm install express

added 65 packages, and audited 66 packages in 3s

29 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
// package.json (after install)
{
  "name": "npm-watch-demo",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  },
  "dependencies": {
    "express": "^5.3.0"
  }
}

The install added one line under dependencies, not 5.3.0 but ^5.3.0. The caret is a semver range: it allows npm to install any 5.x.y release later, but never 6.0.0, under the assumption that a semver-respecting package only makes breaking changes on a major version bump. npm install also wrote (and would commit alongside package.json) a package-lock.json pinning the exact resolved version and every transitive dependency underneath it — that file, not the caret range, is what makes a second npm install on another machine reproducible.

Practice

1. Add a test script that runs node --test, then run npm test in a directory with no test files yet and read what Node prints.

Solution
{
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js",
    "test": "node --test"
  }
}
$ npm test

> npm-watch-demo@1.0.0 test
> node --test

ℹ tests 0
ℹ suites 0
ℹ pass 0
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 19.535675

node --test runs successfully with zero tests found rather than erroring — it only fails the process if a test it does find fails. (duration_ms varies run to run; the rest of the summary doesn’t.)

2. Change the dev script to pass --watch-path=. explicitly instead of relying on the default, and confirm npm run dev still starts the same way.

Solution
{
  "scripts": {
    "dev": "node --watch-path=. server.js"
  }
}
$ npm run dev

> npm-watch-demo@1.0.0 dev
> node --watch-path=. server.js

listening on http://localhost:3000 — Hello from v2

--watch-path doesn’t widen what’s watched on top of the default — it replaces it. Without --watch-path, Node watches the files it actually imported; with it, Node watches only the path(s) given, even if an imported file lives outside them. That matters when a change to a file that’s read but never imported (a config file, a template) should also trigger a restart: naming that file’s directory with --watch-path is how to add it, but doing so means also naming every directory holding an imported module, or those changes stop triggering a restart at all.

3. Run npm install express --save-exact instead, and compare the dependencies entry it writes.

Solution
$ npm install express --save-exact
...
found 0 vulnerabilities
{
  "dependencies": {
    "express": "5.3.0"
  }
}

No caret: --save-exact pins the dependency to the exact version installed right now, so a later npm install on a fresh clone installs precisely 5.3.0 even without reading the lock file.

4. Run npm ls express after installing it, and explain the tree it prints.

Solution
$ npm ls express
npm-watch-demo@1.0.0 /root/node-api
`-- express@5.3.0

npm ls reads the installed tree in node_modules (backed by package-lock.json), not just the range in package.json — it shows the one concrete version that got resolved and installed.

Frequently Asked Questions

What does the caret in "express": "^5.3.0" mean?
Caret ranges allow any version that doesn't change the leftmost nonzero digit, so ^5.3.0 matches anything from 5.3.0 up to but excluding 6.0.0. npm install writes this by default, which is why a fresh install months later can pull in a newer patch or minor version than the one you tested with.
Why does npm print a version both in package.json and package-lock.json?
package.json records the allowed range (^5.3.0); package-lock.json records the exact version npm actually resolved and installed within that range, plus the exact versions of every dependency underneath it. Committing the lock file is what makes installs reproducible across machines.
Do I need nodemon if I have node --watch?
Not for a plain Node process. node --watch has shipped in Node itself since Node 18.11 (stable since Node 20) and restarts the process when a watched file changes, which covers the same core use case nodemon was built for.
Why run "node server.js" through an npm script instead of just typing it?
A script in package.json documents the exact command for everyone on the project and for CI, and scripts can chain (pre/post hooks) or reference local binaries in node_modules/.bin without needing them on the PATH. start and dev are conventional names; test and build are two more.
What versions does this lesson use?
Node 26.12.1 and npm 11.20.0, both from Docker's node:26 image, confirmed by node --version and npm --version.