The fs module
Browsers cannot touch your files. Node can, through the built-in fs (file system) module.
const fs = require("node:fs"); fs.writeFileSync("notes.txt", "hello"); // create or overwrite const text = fs.readFileSync("notes.txt", "utf8"); // read as text
The Sync suffix means blocking: the whole thread stops until the disk answers. Fine for a startup script, bad inside a busy server (lesson 2-2 told you why). The non-blocking versions take a callback:
fs.readFile("notes.txt", "utf8", (err, data) => { if (err) return console.error("could not read"); console.log(data); });
Note the error-first callback: Node convention puts a possible error as the first argument, the result second. Always check err before touching data.
A visit counter on disk
This writes a file, reads it back, adds one, and saves it again, which is the simplest form of persistence, meaning data that survives after the code finishes.
const fs = require("node:fs"); fs.writeFileSync("visits.txt", "42"); const raw = fs.readFileSync("visits.txt", "utf8"); const visits = Number(raw) + 1; fs.writeFileSync("visits.txt", String(visits)); console.log("visits is now " + fs.readFileSync("visits.txt", "utf8"));
Output
visits is now 43readFileSync returns a string, and Number(...) converts it so + 1 does arithmetic instead of gluing text. Without the conversion the result would be "421", which is the classic string-plus-number bug from JavaScript Language.
The "utf8" argument is what makes the read return a string at all. Omitting it returns a Buffer, meaning raw bytes, and Number(buffer) gives NaN, so the encoding is not optional decoration.
String(visits) on the way back is required for the same reason in reverse, since writeFileSync wants a string or a buffer. Passing the number 43 directly throws rather than silently converting, which is one of the few places Node is strict.
This read-modify-write cycle is also the textbook race condition. Two processes doing it at once can both read 42 and both write 43, losing a visit, which is why real counters live in a database that can increment atomically.
The process object
Every Node program gets a global process object describing the running program:
process.argv: the command-line arguments (node app.js --port 4000).process.env: environment variables, key-value settings from the outside world. This is how servers receive secrets and config (unit 8 builds on it).process.stdin: the standard input stream, text piped into your program.
process.stdin is a stream that fires callbacks as data arrives, the same event style servers use for incoming requests:
let input = ""; process.stdin.on("data", (chunk) => { input += chunk; }); process.stdin.on("end", () => { // all input has arrived, use it here });
.on(name, callback) means "when the name event happens, run this". You will meet .on again the moment we open a real HTTP server in unit 3.
Reading standard input
The input feeds process.stdin exactly as a terminal pipe would, and the "end" callback fires once all of it has arrived.
let input = ""; process.stdin.on("data", (chunk) => { input += chunk; }); process.stdin.on("end", () => { const name = input.trim(); console.log("Hello, " + name + "!"); });
Input
Ada
Output
Hello, Ada!
trim() removes the invisible newline at the end of the input line. Skipping it produces "Hello, Ada\n!", where the exclamation mark lands on the next line, which is a confusing bug precisely because the offending character cannot be seen.
The two-callback shape is the important part. Data arrives in pieces called chunks, so "data" may fire once for a short input and many times for a long one, and only "end" knows the input is complete.
That is why the accumulation into a string exists at all. Acting on the first chunk would work in testing with a short line and would silently process half the data the moment the input grew past one buffer.
The same pattern reads an HTTP request body in unit 3, with req.on("data") and req.on("end") in place of process.stdin. Learning it here means the server version is recognition rather than new material.
Why the error comes first
In fs.readFile(path, "utf8", (err, data) => { ... }), the error is the first argument because it is the Node convention, forcing you to face possible failure before using the result.
Error-first callbacks are a deliberate Node-wide convention rather than a quirk of fs. Disk reads, network calls, and database queries can all fail, so the API shape puts the error in front where it cannot be skipped.
The mechanics make the pressure real. Reaching data means typing err first, so ignoring the error is a visible choice in the code rather than an omission a reader has to notice.
Exactly one of the two arguments is meaningful per call. On failure err is an Error and data is undefined, and on success err is null, which is why if (err) return ... is the standard first line.
Any function you write that does async work with callbacks should follow the same (err, result) order. Mixing conventions inside one codebase is worse than either convention alone, since callers cannot tell which shape they are dealing with.
Note that promises and await, coming in lesson 2-4, replace this with try/catch and separate the two paths syntactically. The error-first style is still everywhere in older Node code, so reading it fluently is not optional.
Forwarding an error-first callback
readConfig(path, callback) wraps fs.readFile and passes the outcome along in the same (err, result) shape.
const fs = require("node:fs"); fs.writeFileSync("config.txt", "port=3000"); function readConfig(path, callback) { fs.readFile(path, "utf8", (err, data) => { if (err) return callback(err, null); callback(null, data); }); } readConfig("config.txt", (err, data) => { if (err) return console.log("no config file"); console.log("config: " + data); readConfig("missing.txt", (err2) => { if (err2) return console.log("no config file"); console.log("unexpected success"); }); });
Output
config: port=3000
no config fileInside readConfig, the failure path calls callback(err, null) and the success path calls callback(null, data), so the wrapper honors the same contract it consumes. A wrapper that threw on error instead would break every caller, since a throw inside an async callback cannot be caught by the code that called readConfig.
The return in front of the error callback is load-bearing. Without it, both callback calls would run on the failure path, and the caller would be told about the error and then handed undefined as a success.
The nesting at the bottom is the point worth dwelling on. The second read can only start inside the first one's callback, so two sequential file reads mean two levels of indentation, and five would mean five.
That growth is what people mean by callback hell, and it is a real readability problem rather than an aesthetic complaint. Lesson 2-4 flattens it with await, where the same two reads are two ordinary lines.
Note that the inner callback names its error err2, because reusing err would shadow the outer one. Shadowing works and makes the code harder to reason about, and needing numbered error variables is itself a hint that the nesting has gone too deep.