12k
All articles

Manejo Moderno de Archivos en Node.js

Manejo moderno de archivos en Node.js con fs/promises, streams, file handles, códigos de error, concurrencia y protección contra path traversal.

OpenReplay Team
OpenReplay Team
Manejo Moderno de Archivos en Node.js

En el Node.js moderno, la opción predeterminada para I/O de archivos es el módulo node:fs/promises basado en promesas con async/await; recurre a streams (pipeline de node:stream/promises) cuando los archivos son grandes o de tamaño desconocido, y a file handles (open/read/close) cuando necesitas control posicional a nivel de bytes. El módulo fs aún incluye tres APIs paralelas — síncrona, por callbacks y basada en promesas — y la mayoría de los tutoriales más antiguos presentan la API de callbacks junto con require() de CommonJS. Ese enfoque está desactualizado: para código nuevo en un runtime actual, node:fs/promises con import de ES modules es el punto de partida correcto.

Este artículo muestra cómo leer, escribir y procesar archivos con esa API moderna: comportamiento de codificación, semántica de sobreescritura, manejo de errores por code, concurrencia con Promise.all versus Promise.allSettled, los límites de memoria que hacen fallar a readFile, streams y file handles para datos de gran tamaño, operaciones con directorios y mitigación de path traversal. Los ejemplos están orientados a Node 24, la línea Active LTS a partir de 2026 (calendario de versiones de Node.js). Todas las funcionalidades mostradas están bien por debajo de esa línea base, por lo que el código se ejecuta sin cambios en Node 24, y todos los ejemplos usan import de ESM con el prefijo node: y await a nivel superior.

Puntos Clave

  • Usa node:fs/promises con async/await como opción predeterminada para I/O de archivos; los métodos síncronos como readFileSync bloquean el event loop y solo tienen cabida en scripts CLI de ejecución única, nunca en servidores.
  • readFile almacena el archivo completo en memoria y lanza ERR_FS_FILE_TOO_LARGE para cualquier archivo mayor de 2 GiB — un límite fijo de I/O de libuv, independiente de los límites de Buffer y longitud de strings — por lo que a partir de unos pocos cientos de MiB ya deberías usar streaming.
  • pipeline() de node:stream/promises (estable desde Node 15) conecta streams y gestiona automáticamente la propagación de errores y la limpieza de recursos.
  • Usa Promise.all cuando todos los archivos deben procesarse con éxito; usa Promise.allSettled cuando el fallo parcial es aceptable y quieres procesar los que sí tuvieron éxito.
  • Nunca pases input de usuario sin validar a funciones de fs: resuelve la ruta con path.resolve y verifica que permanezca dentro del directorio base previsto para bloquear el path traversal.

Las tres APIs de fs, y por qué fs/promises es la opción predeterminada

Node.js expone las mismas operaciones de archivos a través de tres APIs: síncrona (readFileSync), por callbacks (readFile(path, cb)) y basada en promesas (node:fs/promises). La API de promesas se estabilizó en Node 14.0.0 (notas de versión de Node.js 14) y es el estándar moderno porque se integra limpiamente con async/await y nunca bloquea el event loop. La API de callbacks es legacy — es anterior a las promesas y conduce a código profundamente anidado — y la API síncrona bloquea el event loop durante toda la duración del I/O. Usa la API de promesas para código nuevo.

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

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

El prefijo node: marca el import como un builtin de Node.js y no puede ser eclipsado por un paquete npm con el mismo nombre; identifica explícitamente el módulo como un módulo core de Node.js. Este ejemplo también usa await a nivel superior, disponible en ES modules sin ningún flag desde Node 14.8.0 (notas de versión de Node.js 14.8.0). Ya no es necesario envolver las lecturas de archivos en una IIFE async en el ámbito del módulo.

¿Qué API deberías usar?

La elección depende del tamaño del archivo, el tipo de acceso que necesitas y si el proceso maneja trabajo concurrente. Usa esta tabla como regla de decisión:

EscenarioAPI recomendadaPor qué
Archivos pequeños, aproximadamente menos de 100 MBnode:fs/promises readFile/writeFileAPI más sencilla; el archivo completo cabe cómodamente en memoria y un solo await hace el trabajo
Archivos grandes o de tamaño desconocidoStreams: createReadStream/createWriteStream con pipelineMemoria constante independientemente del tamaño, y evitas por completo el límite de 2 GiB de readFile
Acceso a nivel de bytes o posicional (leer/escribir offsets específicos)File handles: open/read/closeSolo la API de handles permite leer o escribir rangos de bytes específicos en posiciones elegidas
Scripts CLI de ejecución única y herramientas de build (sin concurrencia)Los métodos síncronos son aceptables: readFileSync/writeFileSyncBloquear el event loop es inofensivo cuando no hay nada más ejecutándose
Servidores HTTP o cualquier código concurrenteSolo la API asíncrona de promesas — nunca la síncronaUn event loop bloqueado paraliza todas las solicitudes pendientes a la vez

La cifra de ~100 MB es una regla práctica para saber cuándo cambiar a streams, no un límite estricto; el punto de fallo definitivo para readFile es el límite de 2 GiB de libuv. Ante la duda entre fs/promises y streams, el streaming es la opción más segura para cualquier archivo cuyo tamaño no controles.

Lectura de archivos con fs/promises

readFile() carga el archivo completo en memoria y devuelve un string o un Buffer. Pasa una codificación como 'utf8' para obtener un string decodificado; omite la codificación para obtener los bytes en crudo como un Buffer. Para archivos de configuración y datos, lee como string y parsea:

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

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

Sin codificación, el valor de retorno es un Buffer — la opción correcta para imágenes, audio o cualquier payload que no sea texto:

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

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

JSON.parse lanza un SyntaxError con input malformado, por lo que un try/catch alrededor del parseo gestiona tanto los fallos de I/O como el JSON inválido. Consulta la documentación de fs.readFile para ver la superficie completa de opciones.

Escritura de archivos con fs/promises

writeFile() crea el archivo si no existe, o lo sobreescribe completamente si ya existe — desde la perspectiva del llamador, un solo await reemplaza todo el contenido del archivo. Para añadir contenido en lugar de reemplazarlo, usa appendFile(), que también crea el archivo si no existe. Para reducir un archivo a una longitud fija, usa truncate().

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

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

// Crea user.json o reemplaza completamente su contenido
await writeFile('user.json', JSON.stringify(user, null, 2), 'utf8')

// Añade una línea sin reescribir el archivo; lo crea si no existe
await appendFile('events.log', `${new Date().toISOString()} user-created\n`, 'utf8')

truncate(path, n) conserva los primeros n bytes del archivo y descarta el resto — el argumento es la cantidad de bytes a conservar, no la cantidad de bytes a eliminar, que es lo contrario de lo que sugiere el nombre. Truncar 1234567890 a 5 deja 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'

Manejo de errores: captura por error.code

Las operaciones de archivos fallan con errores del sistema que incluyen una propiedad code; ramifica por error.code en lugar de parsear los mensajes. Los códigos que se manejan con más frecuencia están documentados en la referencia de errores comunes del sistema de Node.js:

error.codeSignificado
ENOENTEl archivo o directorio no existe
EACCESPermiso denegado
EISDIRSe intentó leer un directorio como si fuera un archivo
ENOSPCNo queda espacio en el dispositivo (disco lleno)
EMFILEDemasiados descriptores de archivo abiertos

Un único try/catch cubre tanto el fallo de I/O como, en el caso de JSON, el fallo de parseo:

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 no existe; usando valores predeterminados')
  } else if (error.code === 'EACCES') {
    console.error('sin permiso para leer config.json')
  } else {
    throw error // incluye SyntaxError de JSON.parse y códigos inesperados
  }
}

Relanza los códigos que no manejas específicamente en lugar de ignorarlos — los errores ENOSPC o EMFILE capturados silenciosamente son un modo de fallo habitual en producción que oculta la causa real.

Síncrono vs asíncrono: cuándo son aceptables los métodos síncronos

Los métodos síncronos de fs (readFileSync, writeFileSync) bloquean el event loop de Node.js durante toda la duración de la operación de I/O — mientras tanto, no se ejecuta ningún otro código JavaScript. Son aceptables en scripts CLI de ejecución única y herramientas de build donde no existe concurrencia, pero nunca en servidores HTTP ni en código que gestione solicitudes concurrentes, porque un event loop bloqueado paraliza todas las solicitudes pendientes a la vez (guía del event loop de Node.js).

// Aceptable: un script de ejecución única que arranca y termina
import { readFileSync } from 'node:fs'

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

La regla de decisión: si el proceso sirve más de una cosa a la vez, usa la API asíncrona de promesas.

Concurrencia: Promise.all vs Promise.allSettled

Usa Promise.all cuando todas las lecturas de archivos deben tener éxito y un único fallo debe abortar el lote; usa Promise.allSettled cuando el fallo parcial es aceptable y quieres procesar los que sí tuvieron éxito. Promise.allSettled siempre se resuelve, devolviendo un array donde cada entrada es { status: 'fulfilled', value } o { status: 'rejected', reason }.

Mapear los nombres de archivo a promesas sin usar await dentro del bucle ejecuta las lecturas de forma concurrente:

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

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

// Todo o nada: un archivo faltante rechaza todo el lote
const all = await Promise.all(
  files.map((f) => readFile(f, 'utf8').then(JSON.parse)),
)

Cuando prefieres cargar lo que exista e informar del resto, inspecciona los resultados resueltos:

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) // p. ej. 'ENOENT'

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

Promesas de fs y archivos grandes: los límites de memoria que hacen fallar a readFile

readFile almacena el archivo completo en memoria y lanza ERR_FS_FILE_TOO_LARGE para cualquier archivo mayor de 2 GiB — un límite fijo de I/O de libuv, no el límite de tamaño de Buffer (node#55864, ERR_FS_FILE_TOO_LARGE). Mucho antes de ese límite estricto, cargar cientos de megabytes en un único buffer genera presión de memoria y respuestas lentas, por lo que a partir de unos pocos cientos de MiB ya deberías usar streaming.

Tres límites distintos son fáciles de confundir, y la mayoría de los tutoriales los mezclan:

LímiteValor (64 bits)Se aplica a
Límite de lectura de archivos de libuv2 GiBreadFile en cualquier archivo; lanza ERR_FS_FILE_TOO_LARGE
buffer.constants.MAX_STRING_LENGTH536.870.888 bytes (~512 MiB)strings, es decir, readFile con codificación
buffer.constants.MAX_LENGTH2⁵³−1 bytes (~8 PiB)asignación máxima de Buffer

Cuando pasas una codificación a readFile (devolviendo un string), el límite operativo es buffer.constants.MAX_STRING_LENGTH — 536.870.888 bytes en plataformas de 64 bits, reducido desde aproximadamente 1 GB en Node 14.4.0 (node#33960). Superarlo lanza un error “Cannot create a string longer than…”, no ERR_FS_FILE_TOO_LARGE. El valor buffer.constants.MAX_LENGTH es la asignación máxima de Buffer y es un límite independiente; no acota readFile, que falla a los 2 GiB independientemente. La conclusión práctica: no razonar sobre readFile a partir del límite de Buffer — el límite de 2 GiB de libuv es el que falla primero.

Streams: createReadStream, createWriteStream y pipeline

Los streams procesan los datos de archivos en fragmentos en lugar de almacenar el archivo completo en memoria, lo que los convierte en la herramienta adecuada para archivos grandes o de tamaño desconocido. createReadStream y createWriteStream producen streams legibles y escribibles; conéctalos con pipeline de node:stream/promises, estable desde Node 15. pipeline() conecta un stream legible a uno escribible y gestiona automáticamente la propagación de errores y la limpieza de recursos. Aunque pipe() ya gestiona el backpressure, pipeline() proporciona un manejo de errores y una limpieza más seguros.

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

// Copia un archivo de cualquier tamaño usando memoria constante
await pipeline(
  createReadStream('huge-input.log'),
  createWriteStream('huge-output.log'),
)

Dado que pipeline() devuelve una promesa que se rechaza ante cualquier error de stream y destruye los streams en caso de fallo, un único try/catch es suficiente — no hay listeners de 'error' que configurar manualmente.

El backpressure es el mecanismo que evita que un lector rápido sature a un escritor lento: cuando el buffer interno del stream escribible se llena, el stream legible se pausa hasta que se vacía. El umbral del buffer es el highWaterMark. Los streams de lectura de archivos tienen por defecto un highWaterMark de 64 KiB, a diferencia de los 16 KiB predeterminados de un stream.Readable genérico; la documentación de fs menciona explícitamente el valor de 64 KiB como el predeterminado para streams de archivos. Ajústalo cuando el profiling indique que es beneficioso:

import { createReadStream } from 'node:fs'

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

Para formatos delimitados por líneas como CSV, conecta el stream de lectura a través de un parser como csv-parser en lugar de parsear manualmente.

File handles: acceso posicional y a nivel de bytes

Usa un file handle de open() cuando necesites leer o escribir rangos de bytes específicos en offsets concretos — algo que readFile y los streams no exponen. Un FileHandle es un recurso de bajo nivel: gestionas el buffer y la posición manualmente, y siempre debes liberarlo con close() en un bloque finally, o el descriptor quedará abierto (y suficientes fugas producen 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
      // procesar buffer.subarray(0, bytesRead)
      position += bytesRead
    }
  } finally {
    await handle?.close()
  }
}

handle.read(buffer, offset, length, position) rellena buffer desde una position dada en el archivo e informa de bytesRead; rastrear position manualmente es lo que hace posible el acceso posicional. Para la mayoría del trabajo de copia y transformación, los streams son la herramienta más adecuada — los file handles solo valen la verbosidad cuando genuinamente necesitas control a nivel de bytes.

AbortSignal es compatible con readFile y fs.watch, por lo que una lectura lenta puede cancelarse — por ejemplo, cuando una solicitud expira. Una lectura abortada se rechaza con un error cuyo name es '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('lectura cancelada')
  else throw error
}

Directorios y rutas

Crea directorios anidados con mkdir({ recursive: true }) (sin error si ya existen, como mkdir -p), lista las entradas con readdir({ withFileTypes: true }) para obtener objetos Dirent, y elimina un árbol de directorios con rm({ recursive: true }) — todo desde el mismo módulo node:fs/promises. Construye rutas con path.join para que los separadores sean correctos en todos los sistemas operativos.

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('archivo:', full)
  else if (entry.isDirectory()) console.log('dir:    ', full)
}

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

Itera el array con for...of, no con for...infor...in sobre un array devuelve índices como strings, no los valores, un bug frecuente en guías más antiguas. rm (con recursive: true) es la forma actual de eliminar árboles de directorios; rmdir con la opción recursiva está deprecado.

Para referenciar archivos relativos al módulo actual en lugar del directorio de trabajo del proceso, usa import.meta.dirname. Disponible desde Node 20.11.0 (import.meta.dirname), proporciona a los módulos ESM la misma referencia al directorio actual que __dirname ofrecía en CommonJS:

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

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

Las funciones de permisos y propietario (chmod, chown) y las funciones de enlaces (symlink, link) también existen en la API de promesas, pero se aplican a sistemas similares a Unix y tienen un comportamiento inesperado o producen errores en Windows; trátelas como específicas de plataforma.

Seguridad: prevenir path traversal con input de usuario

Nunca pases strings proporcionados por el usuario directamente a readFile, writeFile ni a ninguna función de fs. Normaliza la ruta con path.resolve y verifica que comience con tu directorio base previsto antes de continuar — un input como ../../../etc/passwd sin procesar atravesará cualquier ruta relativa que intentaras restringir. Un join(baseDir, userInput) sin más no te protege, porque los segmentos .. se resuelven hacia arriba.

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 bloqueado')
  }
  return readFile(target, 'utf8')
}

Resolver primero y luego verificar el prefijo contra baseDir + sep contiene la solicitud dentro del directorio permitido independientemente de cuántos segmentos .. contenga el input. La guarda + sep evita que un directorio hermano como uploads-private supere una comprobación básica de startsWith('uploads').

Conclusión

Para código nuevo en Node.js, comienza con node:fs/promises y async/await, escala a streams con pipeline() una vez que los archivos superen unos pocos cientos de megabytes o tengan un tamaño desconocido, y desciende a file handles solo cuando necesites acceso posicional a nivel de bytes. Adapta cada operación a su restricción real — el límite de memoria, la concurrencia y el input no confiable son los tres que más problemas causan — y consulta la documentación oficial de fs cuando necesites la superficie completa de opciones. El siguiente paso es auditar cualquier código fs existente basado en callbacks o síncrono en tus servidores y migrarlo a la API de promesas.

Preguntas Frecuentes

¿Cuál es la diferencia entre fs y fs/promises en Node.js?

Ambos hacen referencia a las mismas operaciones de archivos, pero exponen interfaces diferentes. El módulo base node:fs proporciona métodos síncronos (readFileSync) y métodos asíncronos basados en callbacks (readFile con un argumento callback), mientras que node:fs/promises proporciona versiones de las mismas operaciones que devuelven promesas y funcionan con async y await. La API de promesas se estabilizó en Node 14.0.0 y es la opción predeterminada recomendada para código nuevo porque evita el anidamiento de callbacks y nunca bloquea el event loop.

¿Puedo usar require con fs/promises, o necesito ES modules?

Puedes usar cualquiera de los dos. En ES modules, escribe import { readFile } from 'node:fs/promises'. En CommonJS, escribe const { readFile } = require('node:fs/promises'). La API de promesas en sí no requiere ESM; solo el await a nivel superior e import.meta.dirname necesitan un contexto de ES module. El prefijo node: funciona en ambos formatos y marca el import como un builtin de Node.js que ningún paquete npm puede eclipsar.

¿Por qué readFile lanza ERR_FS_FILE_TOO_LARGE antes de alcanzar el límite de tamaño de Buffer?

ERR_FS_FILE_TOO_LARGE es un límite fijo de I/O de libuv de 2 GiB en una única operación de lectura, independiente del límite de asignación de Buffer. Un archivo justo por encima de 2 GiB falla aunque buffer.constants.MAX_LENGTH sea mucho mayor, porque la ruta subyacente de la syscall de lectura impone el límite de 2 GiB independientemente de cuánta memoria pueda contener un Buffer. Para procesar archivos más grandes, usa streams con pipeline en lugar de almacenar el archivo completo en memoria.

¿Cómo leo una parte de un archivo en un offset de bytes específico?

Abre el archivo con open() de node:fs/promises para obtener un FileHandle, luego llama a handle.read(buffer, offset, length, position), que rellena buffer comenzando desde la posición dada en el archivo e informa de bytesRead. Rastrea position manualmente entre lecturas para avanzar por el archivo. Siempre libera el handle con handle.close() en un bloque finally, o el descriptor quedará abierto y suficientes fugas producen EMFILE. El readFile simple y los streams no exponen acceso posicional.

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.