Node.js From Zero
Files, Paths and Buffers
fs and fs/promises, paths, buffers and encodings, reading and writing safely, and watching files.
On this page
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
fsmodule talks to it. - Path is the text that names a file, like
/srv/app/data.jsonornotes/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 rannode. - 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) orEEXIST(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.
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. promiseI 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:
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());"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 falseWhat each call does:
mkdtempmakes a new folder with a random name. It is the safe way to get a scratch folder in tests.writeFilecreates the file, or replaces everything if it already exists. This is a common surprise.appendFileadds 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.6cis the letterl,0ais the newline. This is the first sign that a file is bytes, and "text" is only one way to read them.statgives information about the file: size, type, times.st.sizeis 18, matching the 18 bytes.
Errors have codes, so check the code
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>")); }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
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));[ 'a/', 'notes.txt' ][ 'a', 'a/b', 'a/b/c', 'a/b/c/deep.txt', 'a/top.txt', 'notes.txt' ]ENOTEMPTYexists after rm? falsemkdirwith{ recursive: true }creates all missing parent folders, and does not fail if the folder already exists. Without it,mkdir("a/b/c")fails whena/bis missing.readdirlists names. With{ withFileTypes: true }you get small objects that know if each is a file or a folder, so you avoid onestatcall per entry. With{ recursive: true }you get the whole tree as relative paths (the order is not guaranteed, so I sorted it).rmdironly removes an empty folder, henceENOTEMPTY.rmwith{ recursive: true, force: true }deletes a whole tree, likerm -rf.forcealso makes it quiet when the path does not exist.- The last line uses
accessas 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:
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")));/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.joinglues pieces together and cleans..and double slashes.uploads/..cancelled out.path.resolveworks likecdthrough each piece from left to right and returns an absolute path. If no piece is absolute, it starts from the current working directory. The linepath.resolve("rel.txt") === process.cwd() + "/rel.txt"wastrue.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,dirnamesplit a path.parsereturns all the parts at once. Noteextname("archive.tar.gz")is only.gz, andextname(".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.sepis the separator for the current system.path.win32.join("a", "b")gavea\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:
$ 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: xreadFile("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?
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);}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.sepstops the sneaky last case./srv/app/public-evil/xstarts with the text/srv/app/publicbut is a different folder. Without the separator in the check, it would pass. - An absolute name like
/etc/passwdis a trap forresolve(it throws the root away, as we saw above). Withjoinit is harmless but wrong; withresolvethe 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:
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");}lexical check only reads through the link: "secret\n"with realpath: outside root (symlink): /tmp/lab-nodejs/l2/secret.txtWith 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
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("😀"));<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 4This one example holds three facts:
- 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. - A JavaScript string's
.lengthcounts UTF-16 units, not characters and not bytes. The emoji counts as 2, so.lengthis 9 while there are 8 characters. - So when you need a size on disk or a
Content-Lengthheader, useBuffer.byteLength(text), nottext.length.
Other encodings turn bytes into text in other ways:
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"));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
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));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
256wraps to0,-1wraps to255(ff), and3.9is cut to3. There is no error. Buffer.alloc(n)gives younzeros.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, usealloc.- The
readUInt32BE,writeUInt16LEand friends read and write numbers. BE and LE mean big-endian and little-endian: which end of the number goes first. In the output,deadbeefwas written big-endian (de ad be ef), while 513 written little-endian is01 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
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")));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 originalbtoo (it now starts withH).subarrayis a view on the same memory. If you keep a smallsubarrayof a large buffer, you keep the large buffer alive in memory. Copy withBuffer.from(buf)when you need independence. (The older methodslicealso shares memory, which is whysubarrayis the clearer name.) Buffer.concatjoins a list of buffers.Buffer.compareand.equalscompare contents.indexOfsearches.JSON.stringifyof a buffer gives an object with adataarray of numbers. If you want to send bytes in JSON, convert withtoString("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:
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));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:
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:
readFileSync: took 1330 ms, heartbeat was late by 1325 msawait readFile: took 411 ms, heartbeat was late by 1 msreadFileSync 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:
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");one after another: 903 msPromise.all : 439 msStarting 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).
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));{"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:
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:
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();FileHandle read 6 bytes: {"versfh.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:
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);}rename a.txtchange a.txtchange a.txtrename a.txtwatch stoppedI 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:
renamereally means "a name appeared or disappeared" (creation, deletion, move), andchangemeans "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.watchis not 100% consistent across platforms. Thefilenameargument 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 offersnode --watchfor restarting a script (lesson 0). - The
AbortController(thesignal) is how you stop a watch loop cleanly. Aborting makes the loop throw anAbortError, 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:
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):
209715200 blob.bin 1802 buf1.mjs 1372 fs1.mjs 917 atomic.mjs 872 syncblock.mjsYour 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
- Write
copyFile.mjsthat copies a file by reading it and writing it, with the file names fromprocess.argv. Then replace the code with a single call tofs.copyFile. Which is better and why? - Write a function
readJsonOr(file, fallback)that returns the parsed JSON, orfallbackif the file does not exist, but rethrows any other error (like invalid JSON orEACCES). - Using
Buffer, print the byte length of the string"naïve café"in UTF-8, UTF-16 ("utf16le") andlatin1. Why do they differ? - Make a
safeJoin(root, userPath)function. Test it with"a/b.txt","../x","/etc/passwd"and"a/../../x". - Write
x.binwith the bytes00 ff 10 20, then read it back as aBufferand print it in hex. Then read it with"utf8"and describe what you see. - In a folder, run a
watchloop, then create, edit and delete a file by hand in another terminal. How many events do you see per action?
Answers
- A single
fs.copyFileis 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). - Catch the error, compare
err.code === "ENOENT"and return the fallback, otherwisethrow err. Do not catch everything. Hiding a permission error or a corrupt JSON file as "use the default" is a quiet source of bugs. - 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). UseBuffer.byteLength(str, "utf16le")to check. - The
safefunction in this lesson is the answer:resolvethen checkstartsWith(root + path.sep). Expected:a/b.txtallowed, the other three blocked (/etc/passwdanda/../../xboth resolve outside the root). - In hex:
00ff1020. Read as UTF-8,00and10and20are control characters or a space that you may not see, andffis not valid UTF-8 at all, so it turns into the replacement character�. That shows binary data must not be read as text. - It depends on your operating system and editor. Expect two or more events for a single save, and a
renamefor 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/promisesin anything that serves requests. Sync calls freeze the loop: a 10 ms heartbeat was 1325 ms late during a 302 MBreadFileSync. - Handle errors by
e.code(ENOENT,EEXIST,EACCES). Do not check withaccessfirst. Use flags like"wx"for create-only-if-new. - Use
path.join,path.resolveand friends, never string glue. Anchor toimport.meta.dirname, not the cwd. - Stop path traversal with
resolve, astartsWith(root + path.sep)check andrealpathfor symlinks. - Buffers hold raw bytes outside the heap.
subarrayshares memory,allocUnsafecan 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 fromfs.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
- Security in Depth for Full-Stack Developers · Full-Stack Questions
- Node.js Internals Interviewers Ask About · Full-Stack Questions
- Node.js & Express Interview Questions · Full-Stack Questions
Part of the study guide
Related series