Watch Files in Node Project with Native Features
Since Node.js v18.11.0, you can automatically restart your scripts when files change, without installing any extra packages. This addition finally brings Node's watch mode closer to what external tools like Nodemon have offered for years. But should you switch to it right away?
To find out, I decided to rely solely on Node's built-in watch mode to explore how far it can go. In this post, I won't spend much time on the basic --watch flag, since it only monitors changes to the script being executed. What really interests me is the --watch-path parameter, which lets you watch entire directories or specific files in the file system. I will also introduce the fs.watch method from Node's core fs module, which provides more flexibility and can be used to build scalable custom watch solutions.
For this post, I set up a small demo project that builds a progress bar web component. I use Rollup to bundle the modules into a single production-ready JavaScript file. The project is built by running rollup --config, as defined in the following package.json snippet:
package.json
View on GitHub
{
"scripts": {
"build:js": "rollup --config"
},
...
}
To set up this project locally for further testing, run the following commands:
git clone --single-branch --branch fttl-progress-bar https://github.com/sangafabrice/2025-10-22.git fttl-progress-bar
cd fttl-progress-bar
npm install
These commands clone the project and install all its dependencies, allowing you to observe how file changes trigger the watch automation. Later in this post, I will introduce another project located on the fttl-yt-thumb branch, which you can set up in the same way by replacing the branch name in the command above.
With the exception of the fifth section, I've kept the scripts simple, with no error handling or output, to emphasize the core algorithm. In the fifth section, however, I aim to make the implementation more complete to show what a real-world project might look like.
1. Restart Build Scripts with Embedded Commands
With the current project setup, I can run the command below, which behaves just like npm run build:js and completes successfully:
node --run=build:js
I initially assumed that I could restart the build:js command whenever a file under the src directory changed, using the following command node --watch-path=src --run=build:js. However, this throws an error because the --watch-path flag requires a script file as its input. When both --run and --watch-path are used together with a file, only one of them executes.
To work around this limitation, I created a small Node script named build.js that runs the Rollup command programmatically:
build.js
View on GitHub
require("child_process").exec("npx rollup --config");
The Rollup command can be replaced with the equivalent NPM build command npm run build:js. You can also omit npx if Rollup is installed globally (npm install --global rollup).
Now, I can run:
node --watch-path=src build.js
With this command, Node watches for any changes inside the src directory and its subdirectories, and rebuilds the production script automatically.
At this stage, the package.json file looks like this:
package.json
View on GitHub
{
"scripts": {
"build": "node build",
"build:js": "rollup --config",
"watch": "node --watch-path=src build"
},
...
}
2. Use a Whitelist to Filter the Watched Files
It is currently not possible to filter the watched files using glob patterns (for example, --watch-path=src/**/*.js to watch only JavaScript files). There is also no dedicated flag for excluding specific files, such as minified scripts or other binary assets that are typically ignored during development.
However, you can specify the --watch-path flag multiple times to define a whitelist of files or directories that should be monitored. Let's see how this works with the demo project.
The structure of the src directory is as follows:
src
View on GitHub
src
templates
progressBar
assets
bin
shadow.min.css
shadow.css
progressBar.css.js
progressBar.event.js
utils
memo.util.js
reflection.util.js
shadowCSS.util.js
fttl-progress-bar.js
In this setup, I use a few PostCSS plugins to unnest nested CSS selectors in the shadow.css stylesheet and then minify the result before embedding it into the bundled progress bar component.
To organize this process, I create a new directory named bin inside assets, where I store the production-ready stylesheet shadow.min.css. The minified file is generated by running PostCSS.
Here's how I define the corresponding build script in package.json:
package.json
View on GitHub
{
"scripts": {
"build:css": "cd src/templates/progressBar/assets&& postcss *.css --dir bin --ext min.css",
...
},
...
}
I then update the build.js script as follows:
build.js
View on GitHub
import { exec, execSync } from "child_process";
execSync("npm run build:css");
exec("npm run build:js");
This setup ensures that the CSS is processed and minified before the JavaScript bundle is built.
Next, I need to make sure that the minified stylesheet is not included in the whitelist of watched files. To do this, I replace the previous src watch path with a more specific list of files and directories:
src/fttl-progress-bar.js
src/templates/progressBar/progressBar.css.js
src/templates/progressBar/progressBar.event.js
src/templates/progressBar/assets/shadow.css
src/utils
Finally, I run the following updated watch command, which ignores the minified stylesheet and monitors only the relevant source files:
PowerShell
node `
--watch-path=src/fttl-progress-bar.js `
--watch-path=src/templates/progressBar/progressBar.css.js `
--watch-path=src/templates/progressBar/progressBar.event.js `
--watch-path=src/templates/progressBar/assets/shadow.css `
--watch-path=src/utils `
build.js
I ran this command in PowerShell, which uses backticks (`) for line continuation. In Bash, the equivalent character is a backslash (\).
With this configuration, any change in the listed source files triggers the build process while excluding the generated shadow.min.css file from the watch list.
At this stage, the package.json file looks like this:
package.json
View on GitHub
{
"scripts": {
"build": "node build",
"build:js": "rollup --config",
"build:css": "cd src/templates/progressBar/assets&& postcss *.css --dir bin --ext min.css",
"watch": "node --watch-path=src/fttl-progress-bar.js --watch-path=src/templates/progressBar/progressBar.css.js --watch-path=src/templates/progressBar/progressBar.event.js --watch-path=src/templates/progressBar/assets/shadow.css --watch-path=src/utils build"
},
...
}
3. Simplify the watch Script with an Ignore List
Manually listing each file to watch quickly becomes unmanageable as the project grows. A more scalable approach is to generate the watch command programmatically by traversing the file tree and filtering out unwanted files. I named the script responsible for this task watch.js.
In this version, the script builds the command line dynamically. The function listFileRecursive() lists all files under the root directory (src in this case), while getWatchedArgv() appends the --watch-path= qualifier to each file to enable monitoring. To exclude files or directories, the script uses an ignore list defined with regular expressions (RegExp).
build.js
View on GitHub
import { argv } from "process";
import { execSync } from "child_process";
const { root, ignoreList, script } = parseArgv(argv.slice(2));
const files = listFileRecursive(root);
const watchedFiles = files.filter(file => !ignoreList.test(file));
execSync(`node ${getWatchedArgv(watchedFiles)} "${script}"`);
...
With this setup, you can run the watch script as follows:
node watch.js --root=src --ignore=bin build.js
This command watches all files under the src directory, except those matching the ignore pattern (in this case, files inside bin).
Here's the updated package.json snippet:
package.json
View on GitHub
{
"scripts": {
"build": "node build",
"build:js": "rollup --config",
"build:css": "cd src/templates/progressBar/assets&& postcss *.css --dir bin --ext min.css",
"watch": "node watch --root=src --ignore=bin build"
},
...
}
4. Use the File System API: fs.watch
In the previous example, removing or adding a file to the watched directory required restarting the process for the changes to take effect. The fs.watch API helps make this process seamless by automatically detecting modifications within the file tree.
Unlike the relatively new --watch flag, the fs.watch API has existed since the early versions of Node.js. It also comes with a promisified variant, fsPromises.watch, and a specialized version, fs.watchFile, for monitoring individual files.
The script below is a slight modification of the earlier watch.js implementation. Its core logic looks like this:
watch.js
View on GitHub
import { argv } from "process";
import { watch } from "fs";
const { root, ignoreList, script } = parseArgv(argv.slice(2));
execCommand(script);
watch(
root,
{ recursive: true },
function (_, filename) {
if (ignoreList.test(filename)) return;
execCommand(script, filename);
}
);
...
In the callback function of the fs.watch method, the second argument provides the relative path of the modified file. (The first argument represents the type of change, which I ignore here since all change events are handled the same way.) With the name of the modified file, I can check whether it should be ignored before executing the build script again.
The package.json configuration remains unchanged.
Conditional Build Execution
Being able to retrieve the name of the modified file that triggered the build allows for more flexibility in the build process. In the build script, I decided to implement a conditional workflow:
if the modified file is a stylesheet (or if no file name is provided), the build starts by running the build:css script. Otherwise, it skips the CSS build step.
build.js
View on GitHub
import { exec, execSync } from "child_process";
import { argv } from "process";
const filename = argv[2];
/.css$/i.test(filename ?? ".css") &&
execSync("npm run build:css");
exec("npm run build:js");
In the watch.js script, the modified filename is passed to the build.js script using the call execCommand(script, filename) inside the fs.watch callback.
This argument is then accessed in the build script via argv[2] from Node's process core module.
5. Batch File Change Detection with the File System API
I'll admit that this section wasn't originally planned for this post. I intended to cover it later when discussing Nodemon, but I was surprised to find that Nodemon doesn't seem to support batching multiple modified files correctly. According to the documentation, the callback of the nodemon.on method should receive a files argument (plural), but in practice, only the last modified file is returned when multiple files are changed simultaneously. I suspect this is a bug.
In this section, I'll show how to implement a custom watch mode that aggregates multiple modified files over a short delay (half a second) and passes them all at once to the build script. While I could have continued using the same demo project, its file diversity is limited. Instead, I'll demonstrate this feature using the YouTube thumbnail web component project, which includes different file types (HTML, CSS, and JavaScript).
The project's src directory structure looks like this:
src
View on GitHub
src
templates
thumbnail
assets
bin
shadow.min.css
template.min.html
shadow.css
template.html
yt-play-icon.svg
thumbnail.css.js
thumbnail.dom.js
thumbnail.event.js
thumbnail.html.js
utils
_memo.http.js
_thumbUrl.http.js
_videoIdValidation.http.js
domMap.util.js
parseHTML.util.js
reflection.util.js
resolution.util.js
shadowCSS.util.js
thumbUrl.http.js
thumbUrl.util.js
videoIdValidation.http.js
videoUrl.util.js
fttl-yt-thumb.js
As before, the bin directory contains production-ready files that we want to exclude from watching: specifically shadow.min.css and template.min.html.
The relevant scripts in the package.json are defined as follows:
package.json
View on GitHub
{
"scripts": {
"build": "node build.js",
"build:js": "rollup --config",
"build:css": "cd src/templates/thumbnail/assets&& postcss *.css --dir bin --ext min.css --use postcss-preset-env --use cssnano --no-map",
"build:html": "cd src/templates/thumbnail/assets&& (dir bin > nul 2>&1 || mkdir bin)&& html-minifier-terser template.html --output bin/template.min.html --collapse-whitespace",
...
},
...
}
The dir command is used to list directories because I'm running on Windows. If you're on a Unix-like system (e.g., macOS or Linux), replace dir with ls.
The build.js file implements conditional build execution, similar to the earlier example:
build.js
View on GitHub
import { exec } from "child_process";
import { argv } from "process";
const filenames = argv.slice(2);
const testExt = ext => filenames.length ? filenames.some(name => name.endsWith("." + ext)) : true;
const runExt = (ext, cb = stdio.bind(null, ...new Array(2).fill(undefined))) => exec("npm run build:" + ext, cb);
const extExec = ext => testExt(ext)
? new Promise(function () { runExt(ext, stdio.bind(null, ...arguments)) })
: Promise.resolve([]);
await Promise.all([ extExec("css"), extExec("html") ].flat());
runExt("js");
function stdio(resolve, reject, exception, out, err) {
if (exception) {
reject?.(exception);
throw exception;
}
console.log(out, err);
resolve?.();
}
In this setup, the build:html script runs only when an HTML file changes, and build:css runs only for CSS updates. The Promise.all call ensures that both tasks finish before the JavaScript bundler executes.
With selective rebuilds in place, I created an on-restart.js module to manage the watcher logic.
For demonstration, I used the promisified File System API (fs/promises) and implemented an asynchronous generator that yields batches of modified files detected within a brief time window.
on-restart.js
View on GitHub
import { sep } from "path";
import { watch } from "fs/promises";
import fs from "fs";
const DELAY = 500; // milliseconds
async function* registerWatch(root, ignoreList) {
const files = new Set();
const sleep = delay => new Promise(r => setTimeout(r, delay));
(async () => {
for await (const { filename } of watch(root, { recursive: true })) {
const rootedFilename = root.concat(sep).concat(filename);
if (
!fs.existsSync(rootedFilename) ||
fs.statSync(rootedFilename).isDirectory() ||
ignoreList.test(filename)
) continue;
files.add(filename);
}
})();
while (true) {
if (files.size) yield sleep(DELAY)
.then(() => {
const filesArray = [...files];
files.clear();
return filesArray;
});
await sleep(0);
}
}
export default async function onRestart(root, ignoreList, callback) {
for await (const files of registerWatch(root, ignoreList)) {
callback(files);
}
}
The watch.js script that consumes this module looks like this:
watch.js
View on GitHub
import onRestart from "./auto/on-restart.js";
import { argv } from "process";
const { root, ignoreList, script } = parseArgv(argv.slice(2));
execCommand(script);
onRestart(root, ignoreList, execCommand.bind(null, script));
...
As before, the execCommand(script, files) function builds the command line with any modified filenames passed as additional arguments. Finally, the watch script is declared in the package.json just like before
package.json
View on GitHub
{
"scripts": {
"watch": "node watch --root=src --ignore=bin build",
...
},
...
}
This setup now supports batched file changes, allowing multiple modified files to trigger a single, optimized rebuild instead of several redundant restarts. Of course, testing this with a half-second delay is practically impossible unless you are as fast as Flash the superhero. To make testing easier, increase the delay to a larger interval such as 10 seconds (10_000 milliseconds). In the default configuration, where the delay is half a second, you can still simulate batched changes by deleting and restoring multiple files at once using command line tools.
For example, to delete the dist folder (which stores the bundled JavaScript) and all files inside the assets directory, run:
PowerShell
Remove-Item ".\dist\",".\src\templates\thumbnail\assets\*" -Recurse
Or, in a Bash shell:
Bash
rm --recursive dist/ src/templates/thumbnail/assets/*
Then, restore them using Git:
git restore "*"
After restoring the files, you'll notice that the build runs only once, even though three files were modified. You'll also see that all build tasks are executed, since both the HTML and CSS source file changes were captured. To test selective rebuilds, try deleting and restoring only the CSS or HTML file. You'll observe that either build:css or build:html runs depending on which file was restored before the bundler task executes. If you modify the SVG or any other file type, only the build:js task runs by default.
6. Conclusion
With the introduction of the native watch mode, Node has finally entered the territory of live-reloading tools from the command line. While it still lacks advanced filtering and exclusion features, combining it with small helper scripts like build.js and watch.js makes it surprisingly capable.
By progressively enhancing the setup, from static command lines to dynamic file discovery and finally to the fs.watch API, it is possible to build a fully autonomous watch-and-rebuild system using only Node's core features. This approach removes external dependencies, simplifies maintenance, and provides full control over what triggers each build step.
Although external tools still offer more convenience out of the box, Node's native features are powerful enough for lightweight automation workflows, especially when you aim for minimalism and portability in modern JavaScript projects.
Comments
Post a Comment