12k
All articles

Modern File Handling in Node.js

Modern Node.js file handling with fs/promises, streams, file handles, error codes, concurrency, and path traversal protection.

OpenReplay Team
OpenReplay Team
Modern File Handling in Node.js

In modern Node.js, the default choice for file I/O is the promise-based node:fs/promises module with async/await; reach for streams (pipeline from node:stream/promises) when files are large or of unknown size, and for file handles (open/read/close) when you need byte-level positional control. The fs module still ships three parallel APIs — synchronous, callback, and promise — and most older tutorials lead with the callback API and CommonJS require(). That framing is dated: for new code on a current runtime, node:fs/promises with ES module import is the right starting point.

This article shows how to read, write, and process files with that modern API: encoding behavior, overwrite semantics, error handling by code, concurrency with Promise.all versus Promise.allSettled, the memory limits that break readFile, streams and file handles for large data, directory operations, and path-traversal mitigation. Examples target Node 24, the Active LTS line as of 2026 (Node.js release schedule). Every feature shown is well below that baseline, so the code runs unchanged on Node 24, and all examples use ESM import with the node: prefix and top-level await.

Key Takeaways

  • Use node:fs/promises with async/await as the default for file I/O; synchronous methods like readFileSync block the event loop and belong only in single-run CLI scripts, never in servers.
  • readFile buffers the whole file in memory and throws ERR_FS_FILE_TOO_LARGE for any file larger than 2 GiB — a fixed libuv I/O limit, separate from the Buffer and string-length caps — so above a few hundred MiB you should already be streaming.
  • pipeline() from node:stream/promises (stable since Node 15) connects streams and handles error propagation and cleanup automatically.
  • Use Promise.all when every file must succeed; use Promise.allSettled when partial failure is acceptable and you want to process what succeeded.
  • Never pass unvalidated user input to fs functions: resolve the path with path.resolve and verify it stays inside your intended base directory to block path traversal.

The three fs APIs, and why fs/promises is the default

Node.js exposes the same file operations through three APIs: synchronous (readFileSync), callback (readFile(path, cb)), and promise (node:fs/promises). The promise API became stable in Node 14.0.0 (Node.js 14 release notes) and is the modern default because it integrates cleanly with async/await and never blocks the event loop. The callback API is legacy — it predates promises and leads to deeply nested code — and the synchronous API blocks the event loop for the entire duration of the I/O. Use the promise API for new code.

import { readFile } from 'node:fs/promises'

const data = await readFile('config.json', 'utf8')
console.log(data.length)

The node: prefix marks the import as a Node.js builtin and cannot be shadowed by an npm package of the same name; it explicitly identifies the module as a Node.js core module. This example also uses top-level await, which has been available in ES modules without a flag since Node 14.8.0 (Node.js 14.8.0 release notes). You no longer need to wrap file reads in an async IIFE at module scope.

Which API should you use?

The choice comes down to file size, the kind of access you need, and whether the process handles concurrent work. Use this table as the decision rule:

ScenarioRecommended APIWhy
Small files, roughly under 100 MBnode:fs/promises readFile/writeFileSimplest API; the whole file fits comfortably in memory and one await does the job
Large files, or files of unknown sizeStreams: createReadStream/createWriteStream with pipelineConstant memory regardless of size, and you avoid the 2 GiB readFile ceiling entirely
Byte-level or positional access (read/write specific offsets)File handles: open/read/closeOnly the handle API lets you read or write specific byte ranges at chosen positions
One-off CLI scripts and build tools (no concurrency)Synchronous methods are acceptable: readFileSync/writeFileSyncBlocking the event loop is harmless when nothing else is running
HTTP servers or any concurrent codeAsync promise API only — never syncA blocked event loop stalls every pending request at once

The ~100 MB figure is a practical rule of thumb for when to switch to streams, not a hard limit; the hard failure point for readFile is the 2 GiB libuv cap. When in doubt between fs/promises and streams, streaming is the safer default for anything whose size you do not control.

Reading files with fs/promises

readFile() loads the entire file into memory and returns either a string or a Buffer. Pass an encoding such as 'utf8' to get a decoded string; omit the encoding to get raw bytes as a Buffer. For configuration and data files, read as a string and parse:

import { readFile } from 'node:fs/promises'

const raw = await readFile('config.json', 'utf8')
const config = JSON.parse(raw)
console.log(config.port)

Without an encoding, the return value is a Buffer — the correct choice for images, audio, or any non-text payload:

import { readFile } from 'node:fs/promises'

const bytes = await readFile('logo.png') // Buffer
console.log(bytes.length, 'bytes')

JSON.parse throws a SyntaxError on malformed input, so a try/catch around the parse handles both I/O failures and bad JSON. See the fs.readFile docs for the full options surface.

Writing files with fs/promises

writeFile() creates the file if it does not exist, or completely overwrites it if it does — from the caller’s perspective, one await replaces the entire file contents. To add to a file instead of replacing it, use appendFile(), which also creates the file when it is missing. To shorten a file to a fixed length, use truncate().

import { writeFile, appendFile } from 'node:fs/promises'

const user = { name: 'Ada', email: 'ada@example.com' }

// Creates user.json or fully replaces its contents
await writeFile('user.json', JSON.stringify(user, null, 2), 'utf8')

// Adds a line without rewriting the file; creates it if absent
await appendFile('events.log', `${new Date().toISOString()} user-created\n`, 'utf8')

truncate(path, n) retains the first n bytes of the file and discards the rest — the argument is the byte count to keep, not the number of bytes to remove, which is the opposite of what the name suggests. Truncating 1234567890 to 5 leaves 12345:

import { writeFile, truncate, readFile } from 'node:fs/promises'

await writeFile('data.txt', '1234567890')
await truncate('data.txt', 5)
console.log(await readFile('data.txt', 'utf8')) // '12345'

Error handling: catch by error.code

File operations fail with system errors that carry a code property; branch on error.code rather than parsing messages. The codes you handle most often are documented in the Node.js common system errors reference:

error.codeMeaning
ENOENTFile or directory does not exist
EACCESPermission denied
EISDIRTried to read a directory as a file
ENOSPCNo space left on device (disk full)
EMFILEToo many open file descriptors

A single try/catch covers the I/O failure and, for JSON, the parse failure:

import { readFile } from 'node:fs/promises'

try {
  const config = JSON.parse(await readFile('config.json', 'utf8'))
  console.log('loaded', config)
} catch (error) {
  if (error.code === 'ENOENT') {
    console.error('config.json is missing; using defaults')
  } else if (error.code === 'EACCES') {
    console.error('no permission to read config.json')
  } else {
    throw error // includes SyntaxError from JSON.parse and unexpected codes
  }
}

Re-throw codes you do not specifically handle rather than swallowing them — silently caught ENOSPC or EMFILE errors are a common production failure mode that masks the real cause.

Sync vs async: when synchronous methods are acceptable

Synchronous fs methods (readFileSync, writeFileSync) block the Node.js event loop for the entire duration of the I/O operation — no other JavaScript runs meanwhile. They are acceptable in single-run CLI scripts and build tools where no concurrency exists, but never in HTTP servers or any code that handles concurrent requests, because a blocked event loop stalls every pending request at once (Node.js event loop guide).

// Acceptable: a one-off script that runs and exits
import { readFileSync } from 'node:fs'

const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
console.log(pkg.version)

The decision rule: if the process serves more than one thing at a time, use the async promise API.

Concurrency: Promise.all vs Promise.allSettled

Use Promise.all when every file read must succeed and a single failure should abort the batch; use Promise.allSettled when partial failure is acceptable and you want to process what succeeded. Promise.allSettled always resolves, returning an array where each entry is either { status: 'fulfilled', value } or { status: 'rejected', reason }.

Mapping filenames to promises without awaiting inside the loop runs the reads concurrently:

import { readFile } from 'node:fs/promises'

const files = ['a.json', 'b.json', 'c.json']

// All-or-nothing: one missing file rejects the whole batch
const all = await Promise.all(
  files.map((f) => readFile(f, 'utf8').then(JSON.parse)),
)

When you would rather load whatever exists and report the rest, inspect the settled results:

import { readFile } from 'node:fs/promises'

const files = ['a.json', 'b.json', 'missing.json']

const results = await Promise.allSettled(
  files.map((f) => readFile(f, 'utf8')),
)

const loaded = results.filter((r) => r.status === 'fulfilled').map((r) => r.value)
const failed = results
  .filter((r) => r.status === 'rejected')
  .map((r) => r.reason.code) // e.g. 'ENOENT'

console.log(`loaded ${loaded.length}, failed:`, failed)

fs promises and large files: the memory limits that break readFile

readFile buffers the whole file in memory and throws ERR_FS_FILE_TOO_LARGE for any file larger than 2 GiB — a fixed libuv I/O limit, not the Buffer size limit (node#55864, ERR_FS_FILE_TOO_LARGE). Long before that hard cap, loading hundreds of megabytes into a single buffer causes memory pressure and slow responses, so above a few hundred MiB you should already be streaming.

Three distinct ceilings are easy to confuse, and most tutorials conflate them:

LimitValue (64-bit)Applies to
libuv file-read cap2 GiBreadFile on any file; throws ERR_FS_FILE_TOO_LARGE
buffer.constants.MAX_STRING_LENGTH536,870,888 bytes (~512 MiB)strings, i.e. readFile with an encoding
buffer.constants.MAX_LENGTH2⁵³−1 bytes (~8 PiB)maximum Buffer allocation

When you pass an encoding to readFile (returning a string), the operative ceiling is buffer.constants.MAX_STRING_LENGTH — 536,870,888 bytes on 64-bit platforms, lowered from roughly 1 GB in Node 14.4.0 (node#33960). Overflowing it throws a “Cannot create a string longer than…” error, not ERR_FS_FILE_TOO_LARGE. The buffer.constants.MAX_LENGTH value is the maximum Buffer allocation and is a separate limit again; it does not bound readFile, which fails at 2 GiB regardless. The practical takeaway: don’t reason about readFile from the Buffer cap — the 2 GiB libuv limit is the one that fails first.

Streams: createReadStream, createWriteStream, and pipeline

Streams process file data in chunks instead of buffering the whole file in memory, which makes them the right tool for large or unknown-size files. createReadStream and createWriteStream produce readable and writable streams; connect them with pipeline from node:stream/promises, stable since Node 15. pipeline() connects a readable to a writable stream and automatically handles error propagation and stream cleanup. While pipe() already manages backpressure, pipeline() provides safer error handling and cleanup.

import { createReadStream, createWriteStream } from 'node:fs'
import { pipeline } from 'node:stream/promises'

// Copies a file of any size using constant memory
await pipeline(
  createReadStream('huge-input.log'),
  createWriteStream('huge-output.log'),
)

Because pipeline() returns a promise that rejects on any stream error and destroys the streams on failure, a single try/catch is enough — there are no 'error' listeners to wire up by hand.

Backpressure is the mechanism that keeps a fast reader from overwhelming a slow writer: when the writable’s internal buffer fills, the readable pauses until it drains. The buffer threshold is the highWaterMark. File read streams default to a 64 KiB highWaterMark, unlike the 16 KiB default of a generic stream.Readable; the fs docs call out the 64 KiB value explicitly as the file-stream default. Tune it when profiling shows it helps:

import { createReadStream } from 'node:fs'

const stream = createReadStream('data.bin', { highWaterMark: 128 * 1024 })

For line-delimited formats like CSV, pipe the read stream through a parser such as csv-parser rather than parsing manually.

File handles: byte-level and positional access

Use a file handle from open() when you need to read or write specific byte ranges at specific offsets — something readFile and streams do not expose. A FileHandle is a low-level resource: you manage the buffer and position yourself, and you must always release it with close() in a finally block, or the descriptor leaks (and enough leaks produce EMFILE).

import { open } from 'node:fs/promises'

async function readInChunks(path) {
  let handle
  try {
    handle = await open(path, 'r')
    const chunkSize = 64 * 1024
    const buffer = Buffer.alloc(chunkSize)
    let position = 0
    while (true) {
      const { bytesRead } = await handle.read(buffer, 0, chunkSize, position)
      if (bytesRead === 0) break
      // process buffer.subarray(0, bytesRead)
      position += bytesRead
    }
  } finally {
    await handle?.close()
  }
}

handle.read(buffer, offset, length, position) fills buffer from a given file position and reports bytesRead; tracking position manually is what makes positional access possible. For most copy-and-transform work, streams are the better tool — file handles are worth the verbosity only when you genuinely need byte-level control.

AbortSignal is supported by readFile and fs.watch, so a slow read can be cancelled — for example when a request times out. An aborted read rejects with an error whose name is 'AbortError':

import { readFile } from 'node:fs/promises'

const controller = new AbortController()
setTimeout(() => controller.abort(), 1000)

try {
  await readFile('slow-source.bin', { signal: controller.signal })
} catch (error) {
  if (error.name === 'AbortError') console.error('read cancelled')
  else throw error
}

Directories and paths

Create nested directories with mkdir({ recursive: true }) (no error if they already exist, like mkdir -p), list entries with readdir({ withFileTypes: true }) to get Dirent objects, and remove a tree with rm({ recursive: true }) — all from the same node:fs/promises module. Build paths with path.join so separators are correct across operating systems.

import { mkdir, readdir, rm } from 'node:fs/promises'
import { join } from 'node:path'

await mkdir('output/reports', { recursive: true })

const entries = await readdir('output', { withFileTypes: true })
for (const entry of entries) {
  const full = join('output', entry.name)
  if (entry.isFile()) console.log('file:', full)
  else if (entry.isDirectory()) console.log('dir: ', full)
}

await rm('output/reports', { recursive: true, force: true })

Iterate the array with for...of, not for...infor...in over an array yields string indices, not values, a frequent bug in older guides. rm (with recursive: true) is the current way to delete directory trees; rmdir with the recursive option is deprecated.

To reference files relative to the current module rather than the process working directory, use import.meta.dirname. Available since Node 20.11.0 (import.meta.dirname), it gives ESM modules the same current-directory reference that __dirname provided in CommonJS:

import { readFile } from 'node:fs/promises'
import { join } from 'node:path'

const config = await readFile(join(import.meta.dirname, 'config.json'), 'utf8')

Permission and ownership functions (chmod, chown) and link functions (symlink, link) exist on the promise API too, but they apply to Unix-like systems and behave unexpectedly or error on Windows; treat them as platform-specific.

Security: prevent path traversal with user input

Never pass user-supplied strings directly to readFile, writeFile, or any fs function. Normalize the path with path.resolve and verify it starts with your intended base directory before proceeding — a raw ../../../etc/passwd input will otherwise traverse out of any relative path you intended to constrain. A naive join(baseDir, userInput) does not protect you, because .. segments resolve upward.

import { resolve, sep } from 'node:path'
import { readFile } from 'node:fs/promises'

const baseDir = resolve('uploads')

async function readUserFile(userPath) {
  const target = resolve(baseDir, userPath)
  if (target !== baseDir && !target.startsWith(baseDir + sep)) {
    throw new Error('path traversal blocked')
  }
  return readFile(target, 'utf8')
}

Resolving first, then checking the prefix against baseDir + sep, contains the request inside the allowed directory regardless of how many .. segments the input contains. The + sep guard prevents a sibling directory like uploads-private from passing a bare startsWith('uploads') check.

Conclusion

For new Node.js code, start with node:fs/promises and async/await, escalate to streams with pipeline() once files grow past a few hundred megabytes or have unknown size, and drop to file handles only when you need byte-level positional access. Match each operation to its actual constraint — memory ceiling, concurrency, and untrusted input being the three that bite hardest — and reach for the official fs documentation when you need the full option surface. The next step is to audit any existing callback-or-sync fs code in your servers and migrate it to the promise API.

FAQs

What is the difference between fs and fs/promises in Node.js?

Both refer to the same file operations, but they expose different interfaces. The base node:fs module provides synchronous methods (readFileSync) and callback-based asynchronous methods (readFile with a callback argument), while node:fs/promises provides promise-returning versions of the same operations that work with async and await. The promise API became stable in Node 14.0.0 and is the recommended default for new code because it avoids callback nesting and never blocks the event loop.

Can I use require with fs/promises, or do I need ES modules?

You can use either. In ES modules, write import { readFile } from 'node:fs/promises'. In CommonJS, write const { readFile } = require('node:fs/promises'). The promise API itself does not require ESM; only top-level await and import.meta.dirname need an ES module context. The node: prefix works in both formats and marks the import as a Node.js builtin that no npm package can shadow.

Why does readFile throw ERR_FS_FILE_TOO_LARGE before reaching the Buffer size limit?

ERR_FS_FILE_TOO_LARGE is a fixed 2 GiB libuv I/O cap on a single read operation, independent of the Buffer allocation limit. A file just over 2 GiB fails even though buffer.constants.MAX_LENGTH is far higher, because the underlying read syscall path enforces the 2 GiB ceiling regardless of how much memory a Buffer could hold. To process larger files, use streams with pipeline instead of buffering the whole file.

How do I read part of a file at a specific byte offset?

Open the file with open() from node:fs/promises to get a FileHandle, then call handle.read(buffer, offset, length, position), which fills buffer starting at the given file position and reports bytesRead. Track position manually across reads to move through the file. Always release the handle with handle.close() in a finally block, or the descriptor leaks and enough leaks produce EMFILE. Plain readFile and streams do not expose positional access.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.