Why file handling matters
Servers read and write files constantly: configuration, uploads, logs, caches, generated reports and build output. Node’s node:fs module is the interface, and how you use it decides whether your server stays responsive and your data stays safe.
Three things matter most. Use the promise API so the event loop stays free. Build paths safely so user input cannot escape a directory. And stream large files so memory does not grow with file size.
Reading and writing files
The promise API is the default for application code.
// files.js
import { readFile, writeFile, appendFile } from "node:fs/promises";
// text
const text = await readFile("notes.md", "utf8");
await writeFile("notes.md", text + "\nnew line\n", "utf8");
await appendFile("app.log", "event\n", "utf8");
// binary
const image = await readFile("photo.png"); // Buffer
await writeFile("copy.png", image);
Pass an encoding like "utf8" to get a string; omit it to get a Buffer of raw bytes. writeFile replaces the file, while appendFile adds to it. Neither creates missing directories, so call mkdir first when needed.
Directories
// dirs.js
import { mkdir, readdir, rm, rename, stat } from "node:fs/promises";
await mkdir("data/cache", { recursive: true });
const entries = await readdir("data", { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) console.log("dir", entry.name);
}
const info = await stat("data/cache");
console.log(info.size, info.mtime);
await rename("data/tmp.json", "data/final.json");
await rm("data/old", { recursive: true, force: true });
recursive: true makes mkdir and rm operate on whole trees, and withFileTypes returns entries you can test without extra stat calls. Use stat to check existence, size and timestamps, but be aware it follows symlinks; lstat does not.
Paths
The node:path module handles path manipulation portably.
// paths.js
import path from "node:path";
const file = path.join("uploads", "avatars", "ada.png");
const absolute = path.resolve("uploads", "ada.png");
const name = path.basename(file); // ada.png
const ext = path.extname(file); // .png
const dir = path.dirname(file); // uploads/avatars
Use path.join and path.resolve instead of string concatenation, and import.meta.dirname (or import.meta.url on older versions) to locate files relative to the current module. On Windows, path uses backslashes, which is why manual concatenation breaks.
Security: path traversal
The most common file vulnerability is letting user input escape the intended directory.
// safe-path.js
import path from "node:path";
const BASE = "/srv/uploads";
function safePath(name) {
const target = path.resolve(BASE, name);
if (target !== BASE && !target.startsWith(BASE + path.sep)) {
throw new Error("Invalid path");
}
return target;
}
Resolve the input against a fixed base, then verify the result is still inside it. Also validate filenames, reject null bytes, and never expose raw filesystem errors to clients. See the Web Security guide.
Atomic writes
If a crash happens mid-write, a reader can see a half-written file. For state and configuration, write to a temporary file and rename it into place.
// atomic.js
import { writeFile, rename } from "node:fs/promises";
import { randomUUID } from "node:crypto";
const tmp = `/srv/state/.${randomUUID()}.tmp`;
await writeFile(tmp, JSON.stringify(state), "utf8");
await rename(tmp, "/srv/state/current.json");
rename is atomic on the same filesystem, so readers always see either the old file or the complete new one.
Streaming large files
For anything that might be large, stream instead of buffering.
// stream.js
import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
await pipeline(
createReadStream("big.csv"),
createWriteStream("copy.csv"),
);
Streaming keeps memory flat and lets you start processing before the whole file is read. The Streams guide covers transforms, backpressure and object mode.
Watching files
// watch.js
import { watch } from "node:fs";
const watcher = watch("./config", { recursive: true }, (event, filename) => {
console.log(event, filename);
});
process.on("SIGINT", () => watcher.close());
Watchers are ideal for dev servers and config reloads. Debounce bursts of events, handle deletes and renames, and always close the watcher so the process can exit.
Best practices
- Use
node:fs/promisesin application code. - Read and write with an explicit encoding, or expect a Buffer.
- Build paths with
node:pathand resolve relative to a known base. - Guard against path traversal on any user-supplied filename.
- Stream large files; buffer only what you need whole.
- Write state files atomically with a temp file and rename.
- Close watchers and file handles so processes can exit cleanly.
Common mistakes
- Using
readFileSyncin a request handler. - Concatenating user input into paths.
- Assuming a directory exists before writing.
- Reading a huge file into memory and crashing under load.
- Exposing raw filesystem errors and leaking paths.
- Forgetting to close watchers or streams.
Where to go next
File handling is a daily task on the server. Go deeper with Streams, understand the scheduling in the Event Loop guide, and harden your paths with the Web Security guide.