Files and paths

Read and write files without blocking the server, and build paths that work on any machine.

04of 18
≈ 11Min read
8Parts

Three flavours of every operation

pick the promise one
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.

text, JSON and binary
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

replace, append, and doing it safely
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.

do not ask — just try
// 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

never build one with +
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

the everyday four
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

path traversal, the oldest bug there is
// 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");

Practice