Three flavours of every operation
import fs from "node:fs";
import fsp from "node:fs/promises";
const text = fs.readFileSync("doors.json", "utf8"); // blocks everything
fs.readFile("doors.json", "utf8", (err, text) => { … }); // callback, older style
const text = await fsp.readFile("doors.json", "utf8"); // what to write today
Sync is for scripts, not servers
A synchronous read stops the single thread: no other request is served until the disk answers. It is fine in a build script or at startup, and wrong in a request handler.
Reading
Forget "utf8" and you get a Buffer, which prints as hex and fails every string comparison you try afterwards.
const text = await fsp.readFile("notes.txt", "utf8"); // a string
const buf = await fsp.readFile("logo.png"); // a Buffer — no encoding
const doors = JSON.parse(await fsp.readFile("doors.json", "utf8"));
// or, since Node 22
import doors from "./doors.json" with { type: "json" };
Writing
await fsp.writeFile("doors.json", JSON.stringify(doors, null, 2)); // replaces
await fsp.appendFile("app.log", line + "\n"); // adds
// a crash mid-write leaves a truncated file; write beside it, then rename
await fsp.writeFile("doors.json.tmp", data);
await fsp.rename("doors.json.tmp", "doors.json"); // atomic on the same volume
Does it exist?
err.code is the stable part of a Node error: ENOENT (no such file), EACCES (permission), EISDIR (it is a directory), EEXIST. Match on the code, never on the message.
// a race: the file can disappear between the two lines
if (fs.existsSync(file)) { const t = await fsp.readFile(file, "utf8"); }
// ask for what you want, and handle the failure
try {
const text = await fsp.readFile(file, "utf8");
} catch (err) {
if (err.code !== "ENOENT") throw err; // only "not found" is expected
return null;
}
Paths
import path from "node:path";
path.join("src", "data", "doors.json") // "src/data/doors.json"
path.resolve("data", "doors.json") // absolute, from the working directory
path.extname("doors.json") // ".json"
path.basename("/a/b/doors.json", ".json") // "doors"
path.dirname("/a/b/doors.json") // "/a/b"
// where is this file? (ESM)
import.meta.dirname // CommonJS: __dirname
The working directory is not the script directory
"./data.json" is relative to wherever node was run from. Build paths from import.meta.dirname when you mean "next to this file".
Folders and listing
await fsp.mkdir("data/exports", { recursive: true }); // no error if it exists
const names = await fsp.readdir("data");
const entries = await fsp.readdir("data", { withFileTypes: true });
entries.filter((e) => e.isDirectory());
const { size, mtime } = await fsp.stat("doors.json");
await fsp.rm("data/exports", { recursive: true, force: true });
A file is not a database
- Two requests writing the same file at the same time will lose data — there is no transaction and no lock.
- Reading a 400 MB file to change one line uses 400 MB of memory. That is what streams are for.
- Files are right for configuration, logs, uploads, exports and caches. For anything a user edits concurrently, use a database.
And never trust a path from a user
// GET /files?name=../../etc/passwd
const file = path.join(UPLOADS, req.query.name); // escapes UPLOADS
const file = path.resolve(UPLOADS, req.query.name);
if (!file.startsWith(UPLOADS + path.sep)) throw new Error("Nope");