Node.js From Zero

Files, Paths and Buffers

fs and fs/promises, paths, buffers and encodings, reading and writing safely, and watching files.

Lesson 0213 min read#nodejs#fs#buffers

On this page

  1. Words used in this lesson
  2. Three ways to call the same function
  3. Reading, writing and appending text
  4. Folders
  5. Paths: the path module
  6. Buffers: what is really inside a file read
  7. Reading and writing safely
  8. Watching files
  9. A small tool that uses all of this
  10. Try it
  11. Interview questions
  12. Key takeaways
  13. What's next

In one line: a file is just bytes on a disk. Node gives you fs/promises to move those bytes in and out, path to name them safely, and Buffer to hold raw bytes in memory. Most file bugs come from three mistakes: a wrong path, a wrong text encoding, and reading too much at once.

In lesson 1 we learned that one slow call can freeze the whole process. File work is where beginners meet this first: a script that reads a small config file works fine, then the same code reads a big file on a server and everything stops. In this lesson we learn the file tools in a careful order. We start with reading and writing text, then folders, then paths (including the classic path traversal hole), then buffers, which are the thing under every file read. The next lesson, Streams and Backpressure, solves the "file is too big" problem properly.

What I ran / checked. Every script below ran on Linux with Node.js v22.22.0 (npm 10.9.4), in a scratch folder under /tmp. Output blocks are real pasted output. Paths in the output are from my scratch folders. Timings vary by machine. Sources I read (told in my own words): the Node.js API docs for fs, buffer and path-related sections (raw markdown from the nodejs/node repository, v22.x branch), including the warning against checking a file with fs.access() before opening it, the notes on Buffer.allocUnsafe, and the caveats of fs.watch; the libuv "Design overview" page for how file work uses a thread pool; and the goldbergyoni/nodebestpractices README. Could not read or run: I ran everything on Linux only, so path behaviour on Windows (backslashes, drive letters) is from path.win32 calls and the docs, not from a Windows machine. MDN was not needed. I did not verify any GitHub star counts.

Words used in this lesson

  • File system (fs) is the part of the operating system that stores files and folders. Node's fs module talks to it.
  • Path is the text that names a file, like /srv/app/data.json or notes/today.txt.
  • Absolute path starts from the root (/ on Linux and macOS, C:\ on Windows). **Relative path** starts from some other folder, usually the **current working directory** (cwd), which is the folder you were in when you ran node.
  • Byte is a number from 0 to 255. Everything stored in a file is bytes.
  • Encoding is a rule that turns text into bytes and back. UTF-8 is the most common one. It uses 1 to 4 bytes per character.
  • Buffer is Node's object for a fixed-size chunk of raw bytes.
  • Flag (in fs) is a short string like "r" or "wx" that says how to open a file: read, write, fail if it exists, and so on.
  • Error code is a short stable string on Node errors, such as ENOENT (no such file) or EEXIST (already exists).
  • Path traversal is an attack where ../ in user input makes your server open a file outside the folder you meant.
  • Symlink (symbolic link) is a file that points to another file or folder.
  • Atomic means "all or nothing": the change either fully happens or does not happen at all, and nobody sees a half-finished state.
  • File descriptor is a small number the operating system gives you for an open file. A FileHandle is Node's object that wraps it.

Three ways to call the same function

Every fs function comes in three styles. We saw this in lesson 0.

JavaScript
import fs from "node:fs";import fsp from "node:fs/promises";fs.readFileSync("/etc/hostname", "utf8");                 // 1. sync: holds the threadfs.readFile("/etc/hostname", "utf8", (err, text) => {});  // 2. callbackawait fsp.readFile("/etc/hostname", "utf8");              // 3. promise

I will use the promise style (node:fs/promises) from here on. It reads top to bottom and lets try/catch work. Use the sync style only in startup code and small scripts, where blocking is harmless.

Reading, writing and appending text

Here is a script that does the basic actions in a fresh temporary folder:

JavaScript
import fs from "node:fs/promises";import path from "node:path";import os from "node:os";const dir = await fs.mkdtemp(path.join(os.tmpdir(), "demo-"));const file = path.join(dir, "notes.txt");await fs.writeFile(file, "line one\n");await fs.appendFile(file, "line two\n");console.log(JSON.stringify(await fs.readFile(file, "utf8")));console.log("without encoding:", await fs.readFile(file));const st = await fs.stat(file);console.log("size", st.size, "isFile", st.isFile(), "isDirectory", st.isDirectory());
TEXT
"line one\nline two\n"without encoding: <Buffer 6c 69 6e 65 20 6f 6e 65 0a 6c 69 6e 65 20 74 77 6f 0a>size 18 isFile true isDirectory false

What each call does:

  • mkdtemp makes a new folder with a random name. It is the safe way to get a scratch folder in tests.
  • writeFile creates the file, or replaces everything if it already exists. This is a common surprise.
  • appendFile adds to the end.
  • readFile(file, "utf8") returns a string. If you leave out the encoding, you get a Buffer: the raw bytes. Look at the second output line: 18 bytes, shown in hex. 6c is the letter l, 0a is the newline. This is the first sign that a file is bytes, and "text" is only one way to read them.
  • stat gives information about the file: size, type, times. st.size is 18, matching the 18 bytes.

Errors have codes, so check the code

JavaScript
try { await fs.readFile(path.join(dir, "nope.txt")); }catch (e) { console.log(e.code, "|", e.syscall, "|", e.message); }try { await fs.writeFile(file, "x", { flag: "wx" }); }catch (e) { console.log(e.code, "|", e.message.replace(dir, "<dir>")); }
TEXT
ENOENT | open | ENOENT: no such file or directory, open '/tmp/demo-WlMsJ0/nope.txt'EEXIST | EEXIST: file already exists, open '<dir>/notes.txt'

The first is the most common file error: ENOENT, "error no entry", meaning the file or folder does not exist. The second shows a flag: "wx" means "write, but fail if the file already exists". It gives you a safe "create only if new" without a separate check.

Other codes you will meet: EACCES (permission denied), EISDIR (you tried to read a folder as a file), ENOTEMPTY (folder not empty), EMFILE (too many open files at once). Check e.code, not the message text. The code is stable; the message can change between versions.

Do not check first, then act. The Node docs say not to call fs.access() before open, readFile or writeFile. Another process could change the file between your check and your use, which is a race condition. Open the file directly and handle the error. "Ask forgiveness, not permission" is the safe style here. Use access only when the check itself is the goal.

Folders

JavaScript
await fs.mkdir(path.join(dir, "a/b/c"), { recursive: true });await fs.writeFile(path.join(dir, "a/b/c/deep.txt"), "deep");await fs.writeFile(path.join(dir, "a/top.txt"), "top");const entries = await fs.readdir(dir, { withFileTypes: true });console.log(entries.map(e => `${e.name}${e.isDirectory() ? "/" : ""}`));console.log((await fs.readdir(dir, { recursive: true })).sort());try { await fs.rmdir(path.join(dir, "a")); } catch (e) { console.log(e.code); }await fs.rm(dir, { recursive: true, force: true });console.log("exists after rm?", await fs.access(dir).then(() => true, () => false));
TEXT
[ 'a/', 'notes.txt' ][ 'a', 'a/b', 'a/b/c', 'a/b/c/deep.txt', 'a/top.txt', 'notes.txt' ]ENOTEMPTYexists after rm? false
  • mkdir with { recursive: true } creates all missing parent folders, and does not fail if the folder already exists. Without it, mkdir("a/b/c") fails when a/b is missing.
  • readdir lists names. With { withFileTypes: true } you get small objects that know if each is a file or a folder, so you avoid one stat call per entry. With { recursive: true } you get the whole tree as relative paths (the order is not guaranteed, so I sorted it).
  • rmdir only removes an empty folder, hence ENOTEMPTY.
  • rm with { recursive: true, force: true } deletes a whole tree, like rm -rf. force also makes it quiet when the path does not exist.
  • The last line uses access as a yes/no test, which is fine in a test script because the check is what I wanted.

fs.rm(path, { recursive: true, force: true }) has no undo, and it follows whatever path you give it. Never build that path from user input without checking it (see path traversal below).

Paths: the path module

You could build a path with plain string joining: dir + "/" + name. Do not. Different systems use different separators (/ and \), and .. and extra slashes need cleaning. Use node:path:

JavaScript
import path from "node:path";console.log(path.join("/srv/app", "uploads", "..", "data", "a.txt"));console.log(path.resolve("/srv/app", "x/y.txt"));console.log(path.resolve("rel.txt") === process.cwd() + "/rel.txt");console.log(path.resolve("/a", "/b", "c"));console.log(path.basename("/srv/report.final.pdf"), path.extname("/srv/report.final.pdf"), path.dirname("/srv/report.final.pdf"));console.log(path.parse("/srv/report.final.pdf"));console.log(path.basename("/srv/report.final.pdf", ".pdf"));console.log(path.relative("/srv/app", "/srv/other/file.txt"));console.log(path.sep, path.win32.join("a", "b"));console.log(JSON.stringify(path.extname(".bashrc")), JSON.stringify(path.extname("archive.tar.gz")));
TEXT
/srv/app/data/a.txt/srv/app/x/y.txttrue/b/creport.final.pdf .pdf /srv{  root: '/',  dir: '/srv',  base: 'report.final.pdf',  ext: '.pdf',  name: 'report.final'}report.final../other/file.txt/ a\b"" ".gz"

Line by line:

  • path.join glues pieces together and cleans .. and double slashes. uploads/.. cancelled out.
  • path.resolve works like cd through each piece from left to right and returns an absolute path. If no piece is absolute, it starts from the current working directory. The line path.resolve("rel.txt") === process.cwd() + "/rel.txt" was true.
  • path.resolve("/a", "/b", "c") gave /b/c: when a later piece is absolute, everything before it is dropped. Remember this one. It matters in the security section.
  • basename, extname, dirname split a path. parse returns all the parts at once. Note extname("archive.tar.gz") is only .gz, and extname(".bashrc") is empty because a leading dot is part of the name.
  • path.relative(from, to) gives the path to walk from one to the other.
  • path.sep is the separator for the current system. path.win32.join("a", "b") gave a\b, which shows how the same call differs for Windows.

The cwd trap: relative paths follow where you run node, not where the script lives

I made a script sub/rel.mjs that reads data.txt from its own folder, and ran it from two places:

TEXT
$ cd cwd && node sub/rel.mjscwd: /tmp/lab-nodejs/l2/cwdrelative path: ENOENTanchored: x$ cd sub && node rel.mjscwd: /tmp/lab-nodejs/l2/cwd/subrelative path workedanchored: x

readFile("data.txt") is relative to the current working directory, so it worked only when I ran the script from inside its own folder. The fix is to anchor to the script's location, with path.join(import.meta.dirname, "data.txt") (ES modules) or path.join(__dirname, "data.txt") (CommonJS). The "anchored" line worked from both places.

Path traversal: when user input becomes a path

Suppose a server serves files from a public folder, and the file name comes from the URL. A user sends ../../etc/passwd. What happens if you just join it?

JavaScript
import path from "node:path";const ROOT = "/srv/app/public";function unsafe(name) { return path.join(ROOT, name); }function safe(name) {  const full = path.resolve(ROOT, name);  if (full !== ROOT && !full.startsWith(ROOT + path.sep)) throw new Error("outside root: " + full);  return full;}for (const name of ["index.html", "img/a.png", "../secret.txt", "../../../etc/passwd", "/etc/passwd", "../public-evil/x"]) {  let s; try { s = safe(name); } catch (e) { s = "BLOCKED (" + e.message + ")"; }  console.log(name.padEnd(22), "| join ->", unsafe(name).padEnd(28), "| safe ->", s);}
TEXT
index.html             | join -> /srv/app/public/index.html   | safe -> /srv/app/public/index.htmlimg/a.png              | join -> /srv/app/public/img/a.png    | safe -> /srv/app/public/img/a.png../secret.txt          | join -> /srv/app/secret.txt          | safe -> BLOCKED (outside root: /srv/app/secret.txt)../../../etc/passwd    | join -> /etc/passwd                  | safe -> BLOCKED (outside root: /etc/passwd)/etc/passwd            | join -> /srv/app/public/etc/passwd   | safe -> BLOCKED (outside root: /etc/passwd)../public-evil/x       | join -> /srv/app/public-evil/x       | safe -> BLOCKED (outside root: /srv/app/public-evil/x)

The join column shows the problem: ../../../etc/passwd produced /etc/passwd. The safe function builds the full path with resolve, then checks that the result still starts with the root plus a separator. Two details matter:

  • The + path.sep stops the sneaky last case. /srv/app/public-evil/x starts with the text /srv/app/public but is a different folder. Without the separator in the check, it would pass.
  • An absolute name like /etc/passwd is a trap for resolve (it throws the root away, as we saw above). With join it is harmless but wrong; with resolve the check catches it.

There is one more hole. The check above is only about the text of the path. A symlink inside public that points outside would pass. I made public/link.txt pointing to ../secret.txt and tested:

JavaScript
const ROOT = await fs.realpath("/tmp/lab-nodejs/l2/sl/public");async function safeOpen(name) {  const full = path.resolve(ROOT, name);  if (!full.startsWith(ROOT + path.sep)) throw new Error("outside root (lexical check)");  const real = await fs.realpath(full);               // follow symlinks to the real file  if (!real.startsWith(ROOT + path.sep)) throw new Error("outside root (symlink): " + real);  return fs.readFile(real, "utf8");}
TEXT
lexical check only reads through the link: "secret\n"with realpath: outside root (symlink): /tmp/lab-nodejs/l2/secret.txt

With only the text check, the secret was read through the link. After fs.realpath, the real location is outside the root, and it is blocked. The realpath check still has a tiny gap (the file can change between the check and the read), so for high-risk systems you also restrict who can create symlinks and run the process with minimal file permissions. For web file serving, a well-tested library such as the one used by Express static serving is usually a better choice than hand-written code. This lesson is about understanding the idea. The security series covers server-side attacks more widely.

Buffers: what is really inside a file read

When readFile has no encoding, you get a Buffer. A Buffer is a fixed-size array of bytes (each 0 to 255). It is a subclass of Uint8Array, so it works with the normal typed array tools too. I checked: b instanceof Uint8Array is true.

Text becomes bytes: encodings

JavaScript
const text = "héllo €😀";const b = Buffer.from(text, "utf8");console.log(b);console.log("characters:", [...text].length, "| .length of JS string:", text.length, "| bytes:", b.length);console.log("bytes of each:", Buffer.byteLength("h"), Buffer.byteLength("é"), Buffer.byteLength("€"), Buffer.byteLength("😀"));
TEXT
<Buffer 68 c3 a9 6c 6c 6f 20 e2 82 ac f0 9f 98 80>characters: 8 | .length of JS string: 9 | bytes: 14bytes of each: 1 2 3 4

This one example holds three facts:

  1. In UTF-8, a plain letter is 1 byte, é is 2, € is 3, and the emoji is 4. The string has 8 visible characters but takes 14 bytes.
  2. A JavaScript string's .length counts UTF-16 units, not characters and not bytes. The emoji counts as 2, so .length is 9 while there are 8 characters.
  3. So when you need a size on disk or a Content-Length header, use Buffer.byteLength(text), not text.length.

Other encodings turn bytes into text in other ways:

JavaScript
console.log("hex   :", b.toString("hex"));console.log("base64:", b.toString("base64"));console.log("b64url:", Buffer.from([251, 255, 254]).toString("base64"), Buffer.from([251, 255, 254]).toString("base64url"));console.log("back  :", Buffer.from(b.toString("base64"), "base64").toString("utf8"));console.log("latin1 misread:", b.toString("latin1"));
TEXT
hex   : 68c3a96c6c6f20e282acf09f9880base64: aMOpbGxvIOKCrPCfmIA=b64url: +//+ -__-back  : héllo €😀latin1 misread: héllo â¬ð
  • hex shows each byte as two hex digits. Good for debugging and for hashes.
  • base64 turns bytes into plain letters, digits, +, / and =. It is used to put binary data inside text (JSON, emails, data URLs). It makes the data about a third bigger.
  • base64url is the same but safe in URLs: - and _ replace + and /. The example shows the same three bytes written both ways.
  • The last line is the classic mojibake bug: the bytes were UTF-8, but they were read as latin1, so é became é. If you ever see strange characters like that, the reading encoding is wrong. Always say which encoding you mean, and use UTF-8 for text unless a file format tells you otherwise.

Creating and changing buffers

JavaScript
const z = Buffer.alloc(4); z[0] = 255; z[1] = 256; z[2] = -1; z[3] = 3.9;console.log("alloc + wraparound:", z);console.log("alloc zero-fills:", Buffer.alloc(3));const big = Buffer.alloc(8);big.writeUInt32BE(0xdeadbeef, 0); big.writeUInt16LE(513, 4); big.writeInt16BE(-2, 6);console.log(big, big.readUInt32BE(0).toString(16), big.readUInt16LE(4), big.readInt16BE(6));
TEXT
alloc + wraparound: <Buffer ff 00 ff 03>alloc zero-fills: <Buffer 00 00 00><Buffer de ad be ef 01 02 ff fe> deadbeef 513 -2
  • A buffer slot holds one byte. Setting 256 wraps to 0, -1 wraps to 255 (ff), and 3.9 is cut to 3. There is no error.
  • Buffer.alloc(n) gives you n zeros. Buffer.allocUnsafe(n) is faster but the memory can hold old data from earlier use in the process (the Node docs describe it as not initialised). If you use it, overwrite every byte before you read or send it. Leaking old bytes is a real security bug class. When in doubt, use alloc.
  • The readUInt32BE, writeUInt16LE and friends read and write numbers. BE and LE mean big-endian and little-endian: which end of the number goes first. In the output, deadbeef was written big-endian (de ad be ef), while 513 written little-endian is 01 02. Network protocols and file formats say which one they use. If you get the wrong one, the number comes out wrong.

Slicing shares memory; copying does not

JavaScript
const sub = b.subarray(0, 5); sub[0] = 72;                  // 72 is "H"console.log("subarray shares memory:", b.toString("utf8", 0, 6), "|", sub.toString());const copy = Buffer.from(b); copy[0] = 74;                   // 74 is "J"console.log("Buffer.from(buf) copies:", b.toString("utf8", 0, 1), copy.toString("utf8", 0, 1));console.log(Buffer.concat([Buffer.from("ab"), Buffer.from("cd")]).toString());console.log(Buffer.compare(Buffer.from("a"), Buffer.from("b")), Buffer.from("abc").equals(Buffer.from("abc")));console.log(Buffer.from("hello world").indexOf("world"), Buffer.isBuffer(b), b instanceof Uint8Array);console.log(JSON.stringify(Buffer.from("hi")));
TEXT
subarray shares memory: Héllo | HéllBuffer.from(buf) copies: H Jabcd-1 true6 true true{"type":"Buffer","data":[104,105]}
  • Changing sub[0] changed the original b too (it now starts with H). subarray is a view on the same memory. If you keep a small subarray of a large buffer, you keep the large buffer alive in memory. Copy with Buffer.from(buf) when you need independence. (The older method slice also shares memory, which is why subarray is the clearer name.)
  • Buffer.concat joins a list of buffers. Buffer.compare and .equals compare contents. indexOf searches.
  • JSON.stringify of a buffer gives an object with a data array of numbers. If you want to send bytes in JSON, convert with toString("base64") instead.

The split-character bug

When you read a file in pieces (as streams do in lesson 3), a multi-byte character can be cut between two pieces. If you decode each piece alone, you get garbage:

JavaScript
import { StringDecoder } from "node:string_decoder";const euro = Buffer.from("€");        // 3 bytes: e2 82 acconst part1 = euro.subarray(0, 2), part2 = euro.subarray(2);console.log("naive   :", part1.toString() + part2.toString());const d = new StringDecoder("utf8");console.log("decoder :", d.write(part1) + d.write(part2) + d.end());const td = new TextDecoder();console.log("TextDecoder stream:", td.decode(part1, { stream: true }) + td.decode(part2));
TEXT
naive   : ��decoder : €TextDecoder stream: €

The naive way printed two replacement characters. StringDecoder and TextDecoder with { stream: true } remember the unfinished bytes and wait for the rest. The practical rule: when data arrives in chunks, never call chunk.toString() on each chunk and join the strings. Collect the buffers and decode once, or use a decoder, or set an encoding on the stream. Setting { encoding: "utf8" } on a stream handles it for you.

Buffers live outside the JavaScript heap

How much memory does a big file take? I read a 302 MB file into a buffer:

TEXT
Buffer only, length 302 MB rss 346 MB heapUsed 4 MB arrayBuffers 302 MB

(An empty Node process had a resident memory ("rss") of about 42 MB on the same machine.) The 302 MB shows up in arrayBuffers and in rss, but heapUsed stays at 4 MB. Buffers store their bytes outside the normal JavaScript heap, in memory that Node allocates directly. That has two effects: the JavaScript heap limit (--max-old-space-size) does not limit buffers, and process.memoryUsage().heapUsed will not show them. To see buffer memory, watch rss and arrayBuffers.

In the next lesson we read a 302 MB text file as a string and split it into lines. That needed about 850 MB of resident memory, almost three times the file size, and with a 64 MB heap limit it crashed with "JavaScript heap out of memory". Strings do live on the heap. Keep that in mind when you call readFile(path, "utf8") on something large.

Reading and writing safely

Do not freeze the loop with the sync API

I measured the effect of lesson 1 on a real file. A 10 ms heartbeat timer ran while I read the 302 MB file:

TEXT
readFileSync: took 1330 ms, heartbeat was late by 1325 msawait readFile: took 411 ms, heartbeat was late by 1 ms

readFileSync held the thread for 1.3 seconds, and the heartbeat could not run until it was done. The async read let the heartbeat run on time. (The second read was faster partly because the file was already in the operating system's cache after the first one. Do not read this as "async is 3 times faster". The real point is the lateness column.)

Why does the async version not block? The libuv docs explain that there is no portable file I/O primitive across platforms, so libuv runs the blocking file system calls on its thread pool, and your thread is free in the meantime. That is also why many file reads at once share the four default pool threads:

JavaScript
const files = ["/tmp/lab-nodejs/big.csv", "/tmp/lab-nodejs/big.csv.gz", "/etc/passwd"];let t = performance.now();for (const f of files) await readFile(f);console.log("one after another:", (performance.now() - t).toFixed(0), "ms");t = performance.now();await Promise.all(files.map((f) => readFile(f)));console.log("Promise.all      :", (performance.now() - t).toFixed(0), "ms");
TEXT
one after another: 903 msPromise.all      : 439 ms

Starting the reads together let them overlap on the pool. (Once more, the second run also benefits from the cache, so treat the ratio as rough.) Be careful with Promise.all on thousands of files: you can hit the EMFILE limit on open files. Lesson 7 shows how to limit the number of jobs running at once.

Atomic writes: do not leave a half-written file

writeFile first empties the file and then writes. If the process crashes or the power goes out in the middle, you are left with a half-written file. For a config or data file that is bad. The standard fix is: write to a temporary file in the same folder, then rename it over the real file. A rename on one file system is atomic on Linux and macOS (readers see either the old file or the new file, never a mix).

JavaScript
async function writeAtomic(file, text) {  const tmp = `${file}.${process.pid}.tmp`;  await fs.writeFile(tmp, text);  await fs.rename(tmp, file);}await writeAtomic(target, JSON.stringify({ version: 2 }));console.log(await fs.readFile(target, "utf8"), await fs.readdir(dir));
TEXT
{"version":2} [ 'config.json' ]

Only config.json is left in the folder, with the new content. Two extra notes. The temp file must be on the same file system as the target, or rename will not be atomic (it may even fail). And for the strongest guarantee against a power cut you also call fsync on the file before the rename; the simple version above protects against a crash of your program, not necessarily against sudden power loss.

Parsing JSON files

JSON.parse throws on bad input, and the error message in Node 22 names the position:

TEXT
SyntaxError: Expected property name or '}' in JSON at position 2 (line 1 column 3)

Wrap file reads and parsing in one try, and tell the user which file was bad. For a very large JSON file, JSON.parse blocks the loop (I measured ~1 second for a 74 MB string in lesson 1), so large data belongs in a streamed format such as lines of JSON (one object per line), which lesson 3 shows how to read line by line.

FileHandle: reading part of a file

When you want only part of a big file, open it and read a few bytes, instead of reading it all:

JavaScript
const fh = await fs.open(target, "r");const { bytesRead, buffer } = await fh.read(Buffer.alloc(6), 0, 6, 0);console.log("FileHandle read", bytesRead, "bytes:", buffer.toString());await fh.close();
TEXT
FileHandle read 6 bytes: {"vers

fh.read(buffer, offset, length, position) reads length bytes from position in the file into the buffer. Always close the handle (use try/finally), because an open handle uses an operating system resource. If you forget, Node warns you at garbage collection time, and you can run out of descriptors (EMFILE) in a long-running server.

Watching files

Tools like auto-restart and live reload need to know when a file changes. fs.watch asks the operating system to tell you:

JavaScript
import { watch } from "node:fs/promises";const ac = new AbortController();// ... meanwhile another part of the script writes a.txt, appends to it, then deletes it ...for await (const ev of watch("/tmp/lab-nodejs/l2/watch", { signal: ac.signal })) {  console.log(ev.eventType, ev.filename);}
TEXT
rename a.txtchange a.txtchange a.txtrename a.txtwatch stopped

I created the file, appended text, then deleted it. Notice the events: creating a file reported rename, then a change. Appending reported one more change. Deleting reported rename. So:

  • rename really means "a name appeared or disappeared" (creation, deletion, move), and change means "content changed".
  • One action can send several events. You must tolerate duplicates (a common trick is to wait a short moment after the last event, called debouncing).
  • The Node docs say fs.watch is not 100% consistent across platforms. The filename argument may be missing on some systems, and the recursive mode and network drives behave differently. For serious needs, people use a library built on top of it, and Node itself offers node --watch for restarting a script (lesson 0).
  • The AbortController (the signal) is how you stop a watch loop cleanly. Aborting makes the loop throw an AbortError, which I caught and ignored.

A small tool that uses all of this

Here is a program that walks a folder and prints the five largest files. It uses readdir with recursive, stat, and path, and it never reads file content:

JavaScript
import fs from "node:fs/promises";import path from "node:path";const root = path.resolve(process.argv[2] ?? ".");const names = await fs.readdir(root, { recursive: true });const sizes = await Promise.all(names.map(async (name) => {  const full = path.join(root, name);  try {    const st = await fs.stat(full);    return st.isFile() ? { name, size: st.size } : null;  } catch { return null; }   // a file may vanish between readdir and stat}));sizes.filter(Boolean).sort((a, b) => b.size - a.size).slice(0, 5)  .forEach(({ name, size }) => console.log(String(size).padStart(10), name));

I ran it on my scratch folder (top five shown, sizes in bytes):

TEXT
 209715200 blob.bin      1802 buf1.mjs      1372 fs1.mjs       917 atomic.mjs       872 syncblock.mjs

Your result depends on the folder you pick. The point is the structure: list names, stat in parallel, tolerate a file disappearing, sort, and print. Note the catch for the race between listing and stat: in real folders files appear and vanish.

Try it

  1. Write copyFile.mjs that copies a file by reading it and writing it, with the file names from process.argv. Then replace the code with a single call to fs.copyFile. Which is better and why?
  2. Write a function readJsonOr(file, fallback) that returns the parsed JSON, or fallback if the file does not exist, but rethrows any other error (like invalid JSON or EACCES).
  3. Using Buffer, print the byte length of the string "naïve café" in UTF-8, UTF-16 ("utf16le") and latin1. Why do they differ?
  4. Make a safeJoin(root, userPath) function. Test it with "a/b.txt", "../x", "/etc/passwd" and "a/../../x".
  5. Write x.bin with the bytes 00 ff 10 20, then read it back as a Buffer and print it in hex. Then read it with "utf8" and describe what you see.
  6. In a folder, run a watch loop, then create, edit and delete a file by hand in another terminal. How many events do you see per action?

Answers

  1. A single fs.copyFile is better: it does not load the file into memory, and the operating system may use a fast copy. Reading a whole file just to copy it wastes memory and blocks for large files (see lesson 3).
  2. Catch the error, compare err.code === "ENOENT" and return the fallback, otherwise throw err. Do not catch everything. Hiding a permission error or a corrupt JSON file as "use the default" is a quiet source of bugs.
  3. The strings are 10 characters. UTF-8: 12 bytes (ï is 2 bytes and é is 2). UTF-16 little-endian: 20 bytes (2 per character). latin1: 10 bytes (1 per character, but it cannot hold characters outside its range). Use Buffer.byteLength(str, "utf16le") to check.
  4. The safe function in this lesson is the answer: resolve then check startsWith(root + path.sep). Expected: a/b.txt allowed, the other three blocked (/etc/passwd and a/../../x both resolve outside the root).
  5. In hex: 00ff1020. Read as UTF-8, 00 and 10 and 20 are control characters or a space that you may not see, and ff is not valid UTF-8 at all, so it turns into the replacement character �. That shows binary data must not be read as text.
  6. It depends on your operating system and editor. Expect two or more events for a single save, and a rename for create and delete. Debounce if you act on them.

Interview questions

What is the difference between fs.readFileSync, fs.readFile with a callback and fs.promises.readFile? (Must know)

All three read a whole file. readFileSync blocks the only JavaScript thread until the read finishes, so nothing else runs. The callback and promise versions hand the work to libuv's thread pool and return at once, so the loop stays free. The promise version is the same job with await and normal try/catch. I use the sync form in startup code or one-time scripts, and the promise form in anything that runs while serving requests. In a test, a 10 ms timer was late by 1325 ms with the sync read and by 1 ms with the async read.

What happens when you call fs.readFile on a 2 GB file, and what do you do instead? (Must know)

The whole file is loaded into memory as one buffer. That uses 2 GB of resident memory, may hit limits for a single buffer or a string, and with a string conversion it is even worse (I measured about 850 MB of resident memory for a 302 MB text file read as a string and split into lines). Instead, I process the file as a stream in small chunks (lesson 3), or read only the part I need with a FileHandle.

What is a Buffer and how is it different from a string? (Must know)

A Buffer is a fixed-size block of raw bytes. A string is text, a sequence of characters. They convert through an encoding. The same string can have different byte lengths in different encodings: "€" is 3 bytes in UTF-8 and 2 in UTF-16. Buffers store their bytes outside the JavaScript heap, so they do not count in heapUsed but they do count in rss and arrayBuffers. I use buffers for binary data like images and network packets, and strings for text.

Why can chunk.toString() on each chunk of a stream produce broken text? (Senior signal)

A multi-byte UTF-8 character can be split across two chunks. Decoding each chunk alone turns the broken pieces into replacement characters. In my test the 3-byte € split in 2 and 1 bytes decoded as two �. I fix it by setting an encoding on the stream, by using StringDecoder or TextDecoder with stream: true, or by collecting all buffers with Buffer.concat and decoding once.

What is the difference between path.join and path.resolve? (Must know)

join just glues the pieces together with the right separator and cleans ... The result may be relative. resolve goes through the pieces like cd and returns an absolute path, using the current working directory if none of the pieces is absolute. A later absolute piece throws away what came before: path.resolve("/a", "/b", "c") gave /b/c. That is why I use resolve and then a check in security code, because a user-supplied absolute path would otherwise slip out of the root.

How do you protect a file-serving endpoint from path traversal? (Must know)

I never trust the user's path. I resolve it against the root with path.resolve(root, userPath), and then check that the result starts with root + path.sep (the separator matters, so /srv/public-evil does not match /srv/public). Then I also call fs.realpath and check again, because a symlink inside the root could point outside it. I also give the process only the file permissions it needs, and I prefer a maintained static-file library over my own code. In my test, ../../../etc/passwd became /etc/passwd with plain join and was blocked with the check.

Why is calling fs.access before readFile a bad idea? (Trap)

It creates a race condition (TOCTOU, time of check to time of use). The file can be deleted or changed between the two calls, so the check proves nothing, and my read can still fail. The Node docs say not to do it. I call readFile directly and handle ENOENT or EACCES in the catch. It also saves one system call.

How would you write a config file so that a crash never leaves it half-written? (Senior signal)

I write the new content to a temporary file in the same folder, then rename it over the real file. A rename within one file system is atomic on POSIX systems, so a reader sees either the full old file or the full new file. The temp file must be in the same folder to be on the same file system. For protection against power loss I would also fsync the temp file before the rename. A plain writeFile on the real file truncates it first, so a crash in the middle leaves a broken file.

What are the problems with fs.watch? (Senior signal)

It depends on operating system features, so it is not the same everywhere. The docs say it is not 100% consistent across platforms: the filename can be missing, and behaviour on network drives and recursive watching differs. One action can produce several events, and rename is used for both creation and deletion. So I debounce events, re-check the file state after an event instead of trusting the event type, and for a build tool I would use a library built for the job. For simply restarting a script, node --watch is enough.

What is the danger of Buffer.allocUnsafe? (Trap)

It returns memory that is not zero-filled, so it can contain leftover bytes from earlier use in the process, maybe from another user's request. If I send those bytes to a client before overwriting them all, I leak data. It is faster, which matters in hot paths, but I use Buffer.alloc unless I measured a need and I overwrite the full buffer before using it. Buffer.from(string) and Buffer.concat are safe because they fill the buffer.

Does buf.subarray() copy the data? (Trap)

No. It returns a view on the same memory. In my test, setting a byte on the sub-buffer changed the original, which then began with H. It is cheap, but it also keeps the whole original buffer in memory as long as the small view is alive. When I need independence, I make a copy with Buffer.from(buf).

A file path works on your laptop but fails in Docker or on the server with ENOENT. What do you check first? (Senior signal)

I check whether the path is relative to the current working directory. A relative path like "data.txt" depends on where the process was started, and my test showed it working in one folder and failing with ENOENT in another. I anchor paths to the file's own location with import.meta.dirname or to a configured base path, and I also check case sensitivity (Linux paths are case-sensitive, macOS and Windows usually are not by default) and that the file is copied into the image.

Key takeaways

  • A file is bytes. Text is bytes plus an encoding. Use UTF-8 and say so: readFile(file, "utf8").
  • Use node:fs/promises in anything that serves requests. Sync calls freeze the loop: a 10 ms heartbeat was 1325 ms late during a 302 MB readFileSync.
  • Handle errors by e.code (ENOENT, EEXIST, EACCES). Do not check with access first. Use flags like "wx" for create-only-if-new.
  • Use path.join, path.resolve and friends, never string glue. Anchor to import.meta.dirname, not the cwd.
  • Stop path traversal with resolve, a startsWith(root + path.sep) check and realpath for symlinks.
  • Buffers hold raw bytes outside the heap. subarray shares memory, allocUnsafe can leak old data, and decoding chunk by chunk can break multi-byte characters.
  • Write files atomically with a temp file and rename. Expect duplicate events from fs.watch.
  • Never read a huge file into memory. That is the job of streams, next.

What's next

Next: Streams and Backpressure, Lesson 3. You will see a 302 MB file read two ways with real memory numbers (about 850 MB versus about 80 MB), learn pipeline, and watch what happens when a fast writer meets a slow reader and nobody pays attention to write()'s return value. Practise now: pick a large file on your disk and try readFile(path, "utf8") on it with --max-old-space-size=64. See what error you get.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series