Node.js I/O

Node.js File System

The fs module reads, writes and watches files. Use the promise API, handle paths safely, and stream anything that might be large.

intermediate14 min readUpdated Sep 15, 2026
config.js
js
// config.js
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";

const file = path.join(import.meta.dirname, "config.json");

const config = JSON.parse(await readFile(file, "utf8"));
config.updatedAt = new Date().toISOString();

await writeFile(file, JSON.stringify(config, null, 2));
Module
node:fs/promises
Paths
node:path
Encoding
utf8 for text
Directories
mkdir, readdir, rm
Large files
Streams
Security
Guard path traversal

Why it matters

Why file handling matters

Persistent storage

Files hold configuration, uploads, logs and generated output that must survive a restart.

Safety matters

Untrusted paths and unbounded reads are two of the most common server-side vulnerabilities.

Streaming keeps memory flat

Stream large files instead of buffering them, so memory does not grow with file size.

The big picture

The three parts of file work

Read and write content, manage paths and directories safely, and stream anything that might be large.

Content

Read and write

Read, write and append files, with an encoding for text.

Paths

Locate

The path module joins, resolves and normalises paths across platforms.

Streaming

Scale

Use streams for large files and pipelines for processing.

The file system at a glance

The core APIs

readFile and writeFile

Read and write whole files as a string or buffer.

mkdir and readdir

Create and list directories.

path module

join, resolve, basename, extname and normalize.

stat

Check existence, size, type and timestamps.

watch

React to file changes for dev servers and tooling.

Streams

createReadStream and createWriteStream for large data.

A short history

From callback fs to promise-based I/O

  1. 2009

    Callback fs

    Node exposes file access through error-first callbacks.

    09
  2. 2014

    fs.promises proposed

    A promise-based API is designed for modern JavaScript.

    14
  3. 2019

    fs/promises stable

    Promise methods become the recommended way to use fs.

    19
  4. 2023

    Glob support

    Built-in glob patterns remove a common dependency.

    23
  5. Today

    Promises first

    Async file access is the default; sync APIs are for scripts and startup.

    Today

The complete guide

Node.js File System: Everything you need to know

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/promises in application code.
  • Read and write with an explicit encoding, or expect a Buffer.
  • Build paths with node:path and 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 readFileSync in 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.

Reading a file

Use the promise API in servers so the event loop stays free. Sync calls block every other request.

Prefer
import { readFile } from "node:fs/promises";

const text = await readFile("data.json", "utf8");
Avoid
import { readFileSync } from "node:fs";

// blocks the whole server
const text = readFileSync("data.json", "utf8");

Building a path

Resolve user input against a fixed base and reject anything that escapes it. String concatenation invites path traversal.

Prefer
import path from "node:path";

const base = "/srv/uploads";
const target = path.resolve(base, name);

if (!target.startsWith(base + path.sep)) {
  throw new Error("Invalid path");
}
Avoid
// "../../etc/passwd" escapes
// the intended directory
const target = "/srv/uploads/" + name;

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning File System?

Our interactive tutorial walks you through File System step by step — with quizzes and real code you can run in the browser.