12k
All articles

Manipulação Moderna de Arquivos em Node.js

Tratamento moderno de arquivos no Node.js com fs/promises, streams, file handles, códigos de erro, concorrência e proteção contra path traversal.

OpenReplay Team
OpenReplay Team
Manipulação Moderna de Arquivos em Node.js

No Node.js moderno, a escolha padrão para I/O de arquivos é o módulo node:fs/promises baseado em promises com async/await; recorra a streams (pipeline de node:stream/promises) quando os arquivos forem grandes ou de tamanho desconhecido, e a file handles (open/read/close) quando precisar de controle posicional em nível de bytes. O módulo fs ainda disponibiliza três APIs paralelas — síncrona, callback e promise — e a maioria dos tutoriais mais antigos apresenta a API de callback com require() do CommonJS. Essa abordagem está desatualizada: para novo código em um runtime atual, node:fs/promises com import de módulos ES é o ponto de partida correto.

Este artigo demonstra como ler, escrever e processar arquivos com essa API moderna: comportamento de encoding, semântica de sobrescrita, tratamento de erros por code, concorrência com Promise.all versus Promise.allSettled, os limites de memória que comprometem o readFile, streams e file handles para dados volumosos, operações em diretórios e mitigação de path traversal. Os exemplos têm como alvo o Node 24, a linha Active LTS a partir de 2026 (cronograma de releases do Node.js). Todos os recursos demonstrados estão bem abaixo dessa baseline, portanto o código executa sem alterações no Node 24, e todos os exemplos utilizam import ESM com o prefixo node: e await em nível de módulo.

Principais Conclusões

  • Use node:fs/promises com async/await como padrão para I/O de arquivos; métodos síncronos como readFileSync bloqueiam o event loop e devem ser usados apenas em scripts CLI de execução única, nunca em servidores.
  • readFile armazena o arquivo inteiro em memória e lança ERR_FS_FILE_TOO_LARGE para qualquer arquivo maior que 2 GiB — um limite fixo de I/O do libuv, separado dos limites de tamanho de Buffer e string — portanto, acima de algumas centenas de MiB, você já deveria estar usando streaming.
  • pipeline() de node:stream/promises (estável desde o Node 15) conecta streams e gerencia automaticamente a propagação de erros e a limpeza de recursos.
  • Use Promise.all quando todos os arquivos precisam ser processados com sucesso; use Promise.allSettled quando falhas parciais são aceitáveis e você deseja processar o que foi bem-sucedido.
  • Nunca passe entradas de usuário não validadas para funções do fs: resolva o caminho com path.resolve e verifique se ele permanece dentro do diretório base pretendido para bloquear path traversal.

As três APIs do fs, e por que fs/promises é o padrão

O Node.js expõe as mesmas operações de arquivo por meio de três APIs: síncrona (readFileSync), callback (readFile(path, cb)) e promise (node:fs/promises). A API promise tornou-se estável no Node 14.0.0 (notas de release do Node.js 14) e é o padrão moderno porque se integra de forma limpa com async/await e nunca bloqueia o event loop. A API de callback é legada — anterior às promises e propensa a código com aninhamento excessivo — e a API síncrona bloqueia o event loop durante toda a duração da operação de I/O. Use a API promise para novo código.

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

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

O prefixo node: marca o import como um builtin do Node.js e não pode ser sobrescrito por um pacote npm de mesmo nome; ele identifica explicitamente o módulo como parte do core do Node.js. Este exemplo também utiliza await em nível de módulo, disponível em módulos ES sem necessidade de flag desde o Node 14.8.0 (notas de release do Node.js 14.8.0). Não é mais necessário encapsular leituras de arquivo em uma IIFE async no escopo do módulo.

Qual API você deve usar?

A escolha depende do tamanho do arquivo, do tipo de acesso necessário e de se o processo lida com trabalho concorrente. Use esta tabela como regra de decisão:

CenárioAPI RecomendadaMotivo
Arquivos pequenos, aproximadamente abaixo de 100 MBnode:fs/promises readFile/writeFileAPI mais simples; o arquivo inteiro cabe confortavelmente em memória e um único await resolve o trabalho
Arquivos grandes ou de tamanho desconhecidoStreams: createReadStream/createWriteStream com pipelineMemória constante independente do tamanho, e você evita completamente o limite de 2 GiB do readFile
Acesso em nível de bytes ou posicional (leitura/escrita em offsets específicos)File handles: open/read/closeApenas a API de handle permite ler ou escrever intervalos de bytes específicos em posições escolhidas
Scripts CLI de execução única e ferramentas de build (sem concorrência)Métodos síncronos são aceitáveis: readFileSync/writeFileSyncBloquear o event loop é inofensivo quando nada mais está em execução
Servidores HTTP ou qualquer código concorrenteSomente a API assíncrona de promises — nunca a síncronaUm event loop bloqueado paralisa todas as requisições pendentes de uma só vez

O valor de ~100 MB é uma regra prática para quando migrar para streams, não um limite rígido; o ponto de falha definitivo do readFile é o limite de 2 GiB do libuv. Em caso de dúvida entre fs/promises e streams, o streaming é o padrão mais seguro para qualquer coisa cujo tamanho você não controla.

Lendo arquivos com fs/promises

readFile() carrega o arquivo inteiro em memória e retorna uma string ou um Buffer. Passe um encoding como 'utf8' para obter uma string decodificada; omita o encoding para obter os bytes brutos como um Buffer. Para arquivos de configuração e dados, leia como string e faça o parse:

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

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

Sem um encoding, o valor de retorno é um Buffer — a escolha correta para imagens, áudio ou qualquer payload não textual:

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

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

JSON.parse lança um SyntaxError em entradas malformadas, portanto um try/catch em torno do parse trata tanto falhas de I/O quanto JSON inválido. Consulte a documentação de fs.readFile para a superfície completa de opções.

Escrevendo arquivos com fs/promises

writeFile() cria o arquivo se ele não existir, ou o sobrescreve completamente caso exista — da perspectiva do chamador, um único await substitui todo o conteúdo do arquivo. Para adicionar conteúdo a um arquivo em vez de substituí-lo, use appendFile(), que também cria o arquivo quando ele está ausente. Para reduzir um arquivo a um tamanho fixo, use truncate().

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

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

// Cria user.json ou substitui completamente seu conteúdo
await writeFile('user.json', JSON.stringify(user, null, 2), 'utf8')

// Adiciona uma linha sem reescrever o arquivo; cria-o se ausente
await appendFile('events.log', `${new Date().toISOString()} user-created\n`, 'utf8')

truncate(path, n) mantém os primeiros n bytes do arquivo e descarta o restante — o argumento é a contagem de bytes a manter, não o número de bytes a remover, o que é o oposto do que o nome sugere. Truncar 1234567890 para 5 resulta em 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'

Tratamento de erros: capture por error.code

Operações de arquivo falham com erros de sistema que carregam uma propriedade code; ramifique por error.code em vez de analisar mensagens. Os códigos tratados com mais frequência estão documentados na referência de erros comuns de sistema do Node.js:

error.codeSignificado
ENOENTArquivo ou diretório não existe
EACCESPermissão negada
EISDIRTentativa de ler um diretório como arquivo
ENOSPCSem espaço disponível no dispositivo (disco cheio)
EMFILEMuitos file descriptors abertos

Um único try/catch cobre a falha de I/O e, para JSON, a falha de parse:

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 está ausente; usando valores padrão')
  } else if (error.code === 'EACCES') {
    console.error('sem permissão para ler config.json')
  } else {
    throw error // inclui SyntaxError do JSON.parse e códigos inesperados
  }
}

Relance os códigos que você não trata especificamente em vez de suprimi-los silenciosamente — erros ENOSPC ou EMFILE capturados silenciosamente são um modo de falha comum em produção que mascara a causa real.

Síncrono vs assíncrono: quando métodos síncronos são aceitáveis

Métodos fs síncronos (readFileSync, writeFileSync) bloqueiam o event loop do Node.js durante toda a duração da operação de I/O — nenhum outro JavaScript é executado nesse intervalo. Eles são aceitáveis em scripts CLI de execução única e ferramentas de build onde não há concorrência, mas nunca em servidores HTTP ou em qualquer código que trate requisições concorrentes, pois um event loop bloqueado paralisa todas as requisições pendentes de uma só vez (guia do event loop do Node.js).

// Aceitável: um script de execução única que roda e encerra
import { readFileSync } from 'node:fs'

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

A regra de decisão: se o processo serve mais de uma coisa ao mesmo tempo, use a API assíncrona de promises.

Concorrência: Promise.all vs Promise.allSettled

Use Promise.all quando toda leitura de arquivo deve ter sucesso e uma única falha deve abortar o lote; use Promise.allSettled quando falhas parciais são aceitáveis e você deseja processar o que foi bem-sucedido. Promise.allSettled sempre resolve, retornando um array onde cada entrada é { status: 'fulfilled', value } ou { status: 'rejected', reason }.

Mapear nomes de arquivo para promises sem usar await dentro do loop executa as leituras de forma concorrente:

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

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

// Tudo ou nada: um arquivo ausente rejeita o lote inteiro
const all = await Promise.all(
  files.map((f) => readFile(f, 'utf8').then(JSON.parse)),
)

Quando você prefere carregar o que existe e reportar o restante, inspecione os resultados resolvidos:

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) // ex: 'ENOENT'

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

Promises do fs e arquivos grandes: os limites de memória que comprometem o readFile

readFile armazena o arquivo inteiro em memória e lança ERR_FS_FILE_TOO_LARGE para qualquer arquivo maior que 2 GiB — um limite fixo de I/O do libuv, não o limite de tamanho do Buffer (node#55864, ERR_FS_FILE_TOO_LARGE). Muito antes desse limite rígido, carregar centenas de megabytes em um único buffer causa pressão de memória e respostas lentas, portanto acima de algumas centenas de MiB você já deveria estar usando streaming.

Três limites distintos são frequentemente confundidos, e a maioria dos tutoriais os trata como equivalentes:

LimiteValor (64 bits)Aplica-se a
Limite de leitura de arquivo do libuv2 GiBreadFile em qualquer arquivo; lança ERR_FS_FILE_TOO_LARGE
buffer.constants.MAX_STRING_LENGTH536.870.888 bytes (~512 MiB)strings, ou seja, readFile com encoding
buffer.constants.MAX_LENGTH2⁵³−1 bytes (~8 PiB)alocação máxima de Buffer

Quando você passa um encoding para readFile (retornando uma string), o limite operacional é buffer.constants.MAX_STRING_LENGTH — 536.870.888 bytes em plataformas de 64 bits, reduzido de aproximadamente 1 GB no Node 14.4.0 (node#33960). Ultrapassá-lo lança um erro “Cannot create a string longer than…”, não ERR_FS_FILE_TOO_LARGE. O valor de buffer.constants.MAX_LENGTH é a alocação máxima de Buffer e é um limite separado; ele não restringe o readFile, que falha em 2 GiB independentemente. A conclusão prática: não raciocine sobre o readFile a partir do limite do Buffer — o limite de 2 GiB do libuv é o que falha primeiro.

Streams: createReadStream, createWriteStream e pipeline

Streams processam dados de arquivo em chunks em vez de armazenar o arquivo inteiro em memória, o que as torna a ferramenta adequada para arquivos grandes ou de tamanho desconhecido. createReadStream e createWriteStream produzem streams legíveis e graváveis; conecte-os com pipeline de node:stream/promises, estável desde o Node 15. pipeline() conecta uma stream legível a uma gravável e gerencia automaticamente a propagação de erros e a limpeza de recursos. Enquanto pipe() já gerencia o backpressure, pipeline() oferece tratamento de erros e limpeza mais seguros.

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

// Copia um arquivo de qualquer tamanho usando memória constante
await pipeline(
  createReadStream('huge-input.log'),
  createWriteStream('huge-output.log'),
)

Como pipeline() retorna uma promise que rejeita em qualquer erro de stream e destrói as streams em caso de falha, um único try/catch é suficiente — não há listeners de 'error' para configurar manualmente.

Backpressure é o mecanismo que impede um leitor rápido de sobrecarregar um escritor lento: quando o buffer interno do gravável enche, o legível pausa até que ele esvazie. O limite do buffer é o highWaterMark. Streams de leitura de arquivo têm como padrão um highWaterMark de 64 KiB, diferente do padrão de 16 KiB de um stream.Readable genérico; a documentação do fs menciona explicitamente o valor de 64 KiB como padrão para streams de arquivo. Ajuste-o quando o profiling indicar que isso é benéfico:

import { createReadStream } from 'node:fs'

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

Para formatos delimitados por linha como CSV, passe a stream de leitura por um parser como csv-parser em vez de fazer o parse manualmente.

File handles: acesso em nível de bytes e posicional

Use um file handle de open() quando precisar ler ou escrever intervalos de bytes específicos em offsets específicos — algo que readFile e streams não expõem. Um FileHandle é um recurso de baixo nível: você gerencia o buffer e a posição manualmente, e deve sempre liberá-lo com close() em um bloco finally, ou o descriptor vaza (e vazamentos suficientes produzem 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
      // processar buffer.subarray(0, bytesRead)
      position += bytesRead
    }
  } finally {
    await handle?.close()
  }
}

handle.read(buffer, offset, length, position) preenche o buffer a partir de uma determinada position no arquivo e reporta bytesRead; rastrear position manualmente é o que torna o acesso posicional possível. Para a maioria dos trabalhos de cópia e transformação, streams são a ferramenta mais adequada — file handles valem a verbosidade apenas quando você genuinamente precisa de controle em nível de bytes.

AbortSignal é suportado por readFile e fs.watch, portanto uma leitura lenta pode ser cancelada — por exemplo, quando uma requisição expira por timeout. Uma leitura abortada rejeita com um erro cujo name é '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('leitura cancelada')
  else throw error
}

Diretórios e caminhos

Crie diretórios aninhados com mkdir({ recursive: true }) (sem erro se já existirem, como mkdir -p), liste entradas com readdir({ withFileTypes: true }) para obter objetos Dirent, e remova uma árvore com rm({ recursive: true }) — tudo do mesmo módulo node:fs/promises. Construa caminhos com path.join para que os separadores sejam corretos entre sistemas operacionais.

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('arquivo:', full)
  else if (entry.isDirectory()) console.log('diretório:', full)
}

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

Itere o array com for...of, não for...infor...in sobre um array produz índices como strings, não valores, um bug frequente em guias mais antigos. rm (com recursive: true) é a forma atual de excluir árvores de diretório; rmdir com a opção recursiva está depreciado.

Para referenciar arquivos relativos ao módulo atual em vez do diretório de trabalho do processo, use import.meta.dirname. Disponível desde o Node 20.11.0 (import.meta.dirname), ele fornece aos módulos ESM a mesma referência de diretório atual que __dirname oferecia no CommonJS:

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

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

Funções de permissão e propriedade (chmod, chown) e funções de link (symlink, link) também existem na API promise, mas se aplicam a sistemas Unix-like e se comportam de forma inesperada ou geram erros no Windows; trate-as como específicas de plataforma.

Segurança: previna path traversal com entradas de usuário

Nunca passe strings fornecidas por usuários diretamente para readFile, writeFile ou qualquer função do fs. Normalize o caminho com path.resolve e verifique se ele começa com o diretório base pretendido antes de prosseguir — uma entrada bruta como ../../../etc/passwd irá, de outra forma, atravessar qualquer caminho relativo que você pretendia restringir. Um simples join(baseDir, userInput) não oferece proteção, pois segmentos .. resolvem para cima na hierarquia.

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 primeiro e depois verificar o prefixo em relação a baseDir + sep mantém a requisição dentro do diretório permitido, independentemente de quantos segmentos .. a entrada contenha. A proteção com + sep impede que um diretório irmão como uploads-private passe em uma verificação simples de startsWith('uploads').

Conclusão

Para novo código Node.js, comece com node:fs/promises e async/await, escale para streams com pipeline() quando os arquivos ultrapassarem algumas centenas de megabytes ou tiverem tamanho desconhecido, e recorra a file handles apenas quando precisar de acesso posicional em nível de bytes. Associe cada operação à sua restrição real — limite de memória, concorrência e entradas não confiáveis sendo as três que causam mais problemas — e consulte a documentação oficial do fs quando precisar da superfície completa de opções. O próximo passo é auditar qualquer código fs baseado em callbacks ou síncrono existente em seus servidores e migrá-lo para a API promise.

Perguntas Frequentes

Qual é a diferença entre fs e fs/promises no Node.js?

Ambos se referem às mesmas operações de arquivo, mas expõem interfaces diferentes. O módulo base node:fs fornece métodos síncronos (readFileSync) e métodos assíncronos baseados em callback (readFile com um argumento de callback), enquanto node:fs/promises fornece versões das mesmas operações que retornam promises e funcionam com async e await. A API promise tornou-se estável no Node 14.0.0 e é o padrão recomendado para novo código, pois evita o aninhamento de callbacks e nunca bloqueia o event loop.

Posso usar require com fs/promises, ou preciso de módulos ES?

Você pode usar qualquer um dos dois. Em módulos ES, escreva import { readFile } from 'node:fs/promises'. No CommonJS, escreva const { readFile } = require('node:fs/promises'). A API promise em si não requer ESM; apenas o await em nível de módulo e import.meta.dirname precisam de um contexto de módulo ES. O prefixo node: funciona em ambos os formatos e marca o import como um builtin do Node.js que nenhum pacote npm pode sobrescrever.

Por que readFile lança ERR_FS_FILE_TOO_LARGE antes de atingir o limite de tamanho do Buffer?

ERR_FS_FILE_TOO_LARGE é um limite fixo de I/O de 2 GiB do libuv em uma única operação de leitura, independente do limite de alocação do Buffer. Um arquivo com pouco mais de 2 GiB falha mesmo que buffer.constants.MAX_LENGTH seja muito maior, porque o caminho da syscall de leitura subjacente impõe o limite de 2 GiB independentemente de quanta memória um Buffer poderia alocar. Para processar arquivos maiores, use streams com pipeline em vez de armazenar o arquivo inteiro em memória.

Como leio parte de um arquivo em um offset de byte específico?

Abra o arquivo com open() de node:fs/promises para obter um FileHandle, depois chame handle.read(buffer, offset, length, position), que preenche o buffer a partir da posição especificada no arquivo e reporta bytesRead. Rastreie a posição manualmente entre as leituras para percorrer o arquivo. Sempre libere o handle com handle.close() em um bloco finally, ou o descriptor vaza e vazamentos suficientes produzem EMFILE. O readFile simples e as streams não expõem acesso 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.