Ler e escrever arquivo no Node com fs/promises e path
readFile, writeFile, appendFile e mkdir com async/await, o caminho certo com path.join e o erro ENOENT quando a pasta ainda não existe.
São duas linhas: await readFile(caminho, 'utf8') traz o conteúdo do arquivo
como texto, e await writeFile(caminho, texto) grava. As duas funções vêm de
node:fs/promises. O que dá trabalho não é a chamada — é acertar o caminho e
lidar com a pasta que ainda não existe.
Os exemplos desta lição são de uma livraria de bairro, a Livraria Aurora: o catálogo em JSON, os recibos do dia numa pasta e a exportação de vendas do ano.
As funções são diretas; a parte que mais confunde é a resolução de caminho. Em palavras simples, o Node precisa transformar o texto do caminho num endereço concreto, e um caminho relativo parte do diretório atual do processo.
O endereço do arquivo depende de onde você está
Uma instrução como “ande duas quadras e vire à direita” só funciona quando você
sabe o ponto de partida. ./dados/catalogo.json é esse tipo de instrução: o
resultado muda conforme o diretório em que o processo foi iniciado. Um caminho
absoluto é o endereço completo. path.resolve e import.meta.dirname ajudam a
deixar explícito qual ponto de partida o programa usa.
Antes da primeira leitura, anote process.cwd(), o caminho recebido e o caminho
absoluto esperado. Depois imprima a resolução e confira se o arquivo está ali.
Repita iniciando o mesmo script de outra pasta; a diferença confirma qual parte
dependia do diretório atual.
O mesmo módulo de arquivos aparece em três formatos, e a confusão entre eles é a primeira pedra no caminho. Este script chama a mesma leitura nos três:
import { readFile, readFileSync } from 'node:fs';
import { readFile as readFilePromise } from 'node:fs/promises';
// 1. síncrono: devolve o conteúdo e trava a thread até terminar
console.log('sync ->', readFileSync('horario.txt', 'utf8').split('\n')[0]);
// 2. promise: devolve uma Promise, para usar com await
console.log('promise ->', readFilePromise('horario.txt', 'utf8'));
// 3. callback: não devolve nada, entrega o conteúdo na função do final
console.log('callback ->', readFile('horario.txt', 'utf8', (erro, texto) => {
console.log('dentro do callback ->', texto.split('\n')[0]);
}));Três coisas diferentes saíram da mesma leitura. O readFileSync devolveu a
string na hora. O de fs/promises devolveu uma Promise { <pending> } — é ela
que o await desembrulha. E o de callback devolveu undefined, porque o
conteúdo dele chega depois, na função do último argumento.
Repare também na ordem: a linha do callback foi impressa antes do conteúdo. A leitura foi para a fila e o resto do script continuou. Se isso ainda soa estranho, o event loop do Node explica por quê.
Para código de projeto, use node:fs/promises com await. O prefixo node:
não é enfeite: ele diz ao Node que você quer o módulo interno, e não um pacote
chamado fs que alguém instalou por engano.
Ler texto: o Buffer e o utf8
Sem o segundo argumento, readFile não devolve string — devolve um Buffer,
que é a sequência crua de bytes do arquivo.
import { readFile } from 'node:fs/promises';
const bruto = await readFile('horario.txt');
const texto = await readFile('horario.txt', 'utf8');
console.log(bruto.subarray(0, 16));
console.log('bruto ->', bruto.constructor.name, bruto.length);
console.log('texto ->', typeof texto, texto.length);
console.log('sábado ->', texto.trimEnd().split('\n')[2]);4c 69 76 é L, i, v em hexadecimal. E olhe a diferença de tamanho: 82
bytes contra 79 caracteres. O arquivo tem três letras acentuadas, e cada uma
ocupa dois bytes em UTF-8. Bytes e caracteres não são a mesma unidade — por isso
'utf8' importa.
Quando o arquivo é imagem, PDF ou ZIP, é o contrário: você quer o Buffer e
não passa encoding nenhum, senão o Node tenta interpretar bytes binários como
letras e corrompe o conteúdo.
Ler JSON — e por que o import engana
Arquivo de configuração e banco pequeno quase sempre são JSON. O padrão é ler
como texto e entregar para o JSON.parse:
import { readFile } from 'node:fs/promises';
const catalogo = JSON.parse(await readFile('catalogo.json', 'utf8'));
console.log(catalogo.loja, '—', catalogo.livros.length, 'títulos');
const emFalta = catalogo.livros.filter((livro) => livro.estoque === 0);
console.log('em falta:', emFalta.map((livro) => livro.titulo));
const valorEmEstoque = catalogo.livros.reduce(
(soma, livro) => soma + livro.preco * livro.estoque,
0,
);
console.log('valor em estoque: R$', valorEmEstoque.toFixed(2));Depois do JSON.parse você tem um objeto JavaScript comum, com filter e
reduce funcionando normalmente. Se a conversão entre texto e objeto ainda não
está clara, vale revisar JSON.parse e JSON.stringify.
O Node 24 também deixa importar JSON direto, com import ... with { type: 'json' }.
Parece mais limpo, e é uma armadilha em qualquer arquivo que muda:
import { readFile, writeFile } from 'node:fs/promises';
const primeira = await import('./catalogo.json', { with: { type: 'json' } });
console.log('import (1ª vez) ->', primeira.default.livros[0].estoque);
// a loja vende um exemplar e o arquivo muda no disco
const catalogo = JSON.parse(await readFile('catalogo.json', 'utf8'));
catalogo.livros[0].estoque -= 1;
await writeFile('catalogo.json', JSON.stringify(catalogo, null, 2) + '\n');
const segunda = await import('./catalogo.json', { with: { type: 'json' } });
console.log('import (2ª vez) ->', segunda.default.livros[0].estoque);
console.log('readFile agora ->', JSON.parse(await readFile('catalogo.json', 'utf8')).livros[0].estoque);O arquivo no disco já dizia 3, e o segundo import continuou devolvendo 4. Todo
módulo importado fica em cache até o processo morrer, e JSON importado é módulo.
Use import só para arquivo que nunca muda em produção; para dado vivo, sempre
readFile.
Escrever, sobrescrever e acrescentar
writeFile cria o arquivo se ele não existir — e apaga tudo se existir. É o
comportamento certo para salvar um estado inteiro, e o errado para um log:
import { writeFile, appendFile, readFile } from 'node:fs/promises';
await writeFile('vendas.log', '09:14 Torto Arado 59.90\n');
await writeFile('vendas.log', '10:02 Vidas Secas 42.50\n');
console.log('--- depois de dois writeFile ---');
console.log(await readFile('vendas.log', 'utf8'));
await appendFile('vendas.log', '11:47 Grande Sertão: Veredas 89.90\n');
await appendFile('vendas.log', '12:30 Torto Arado 59.90\n');
console.log('--- depois de dois appendFile ---');
console.log(await readFile('vendas.log', 'utf8'));— depois de dois appendFile — 10:02 Vidas Secas 42.50 11:47 Grande Sertão: Veredas 89.90 12:30 Torto Arado 59.90
A venda das 09:14 desapareceu: o segundo writeFile truncou o arquivo antes de
gravar. As duas linhas seguintes, com appendFile, entraram sem tocar no que já
estava lá.
path.join, path.resolve e import.meta.dirname
Caminho montado na mão com + é fonte garantida de barra dobrada e barra
faltando ('dados' + '/' + arquivo, e o dia em que arquivo já vem com barra na
frente). path.join resolve isso e ainda usa o separador do sistema, o que
importa no Windows. E import.meta.dirname responde a pergunta que quase todo
mundo erra: onde o script está, e não de onde ele foi chamado.
import path from 'node:path';
console.log('cwd ->', process.cwd());
console.log('pasta do script ->', import.meta.dirname);
console.log('join ->', path.join('dados', 'vendas.log'));
console.log('resolve ->', path.resolve('dados', 'vendas.log'));
console.log('ao lado do script ->', path.join(import.meta.dirname, '..', 'catalogo.json'));Agora o mesmo arquivo, rodado de dois lugares diferentes:
cd /private/tmp/livraria-aurora
node scripts/caminhos.mjs
cd /private/tmp
node livraria-aurora/scripts/caminhos.mjsO resolve mudou de resposta entre as duas execuções, porque ele parte do
process.cwd() — a pasta em que você digitou node. O import.meta.dirname
não mudou: ele é a pasta do próprio arquivo.
A regra prática: se o arquivo pertence ao projeto (um template, um seed, um
JSON de configuração), monte o caminho a partir de import.meta.dirname. Se o
caminho vem do usuário ou de uma variável de ambiente, aí sim ele é relativo ao
cwd.
ENOENT: o arquivo (ou a pasta) que ainda não existe
ENOENT é o código POSIX de no such file or directory: o sistema operacional
procurou naquele caminho e não achou nada. Ele é o erro mais comum de quem
começa com arquivos, e aparece em dois sabores bem diferentes.
O primeiro é o caminho relativo lido do lugar errado. Este script funciona quando você roda de dentro da livraria:
import { readFile } from 'node:fs/promises';
const catalogo = JSON.parse(await readFile('catalogo.json', 'utf8'));
console.log(catalogo.livros.length, 'títulos no catálogo');Rodando uma pasta acima, com node livraria-aurora/scripts/estoque.mjs:
Error: ENOENT: no such file or directory, open ‘catalogo.json’ at async open (node:internal/fs/promises:640:25) at async readFile (node:internal/fs/promises:1287:14) at async file:///private/tmp/livraria-aurora/scripts/estoque.mjs:3:29 { errno: -2, code: ‘ENOENT’, syscall: ‘open’, path: ‘catalogo.json’ }
Node.js v24.16.0
Repare no campo path do fim: 'catalogo.json', sem pasta nenhuma. É a pista
de que o caminho é relativo ao cwd. A correção é path.join(import.meta.dirname, '..', 'catalogo.json').
O segundo sabor é na escrita, e pega quase todo mundo: o Node não cria a pasta sozinho.
import { writeFile } from 'node:fs/promises';
const recibo = { numero: 1042, titulo: 'Torto Arado', total: 59.9 };
await writeFile('dados/recibos/1042.json', JSON.stringify(recibo, null, 2));
console.log('recibo salvo');Error: ENOENT: no such file or directory, open ‘dados/recibos/1042.json’ at async open (node:internal/fs/promises:640:25) at async writeFile (node:internal/fs/promises:1257:14) at async file:///private/tmp/livraria-aurora/salvar-recibo.mjs:5:1 { errno: -2, code: ‘ENOENT’, syscall: ‘open’, path: ‘dados/recibos/1042.json’ }
Node.js v24.16.0
A solução é uma linha: mkdir com recursive: true, que cria a árvore inteira
e não reclama se ela já existir.
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
const recibo = { numero: 1042, titulo: 'Torto Arado', total: 59.9 };
const destino = path.join('dados', 'recibos', '1042.json');
await mkdir(path.dirname(destino), { recursive: true });
await writeFile(destino, JSON.stringify(recibo, null, 2));
console.log('recibo salvo em', path.resolve(destino));Rodando duas vezes seguidas:
Sem o recursive: true, a segunda execução morreria com EEXIST. Com ele,
mkdir vira uma operação que você pode repetir à vontade — chame antes de todo
writeFile que grava em subpasta e esqueça o assunto.
Um mini banco em JSON que sobrevive ao restart
Junte o que já apareceu e você tem persistência de verdade, sem instalar banco nenhum. Este arquivo guarda a estante da livraria e continua de onde parou a cada execução:
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import path from 'node:path';
const ARQUIVO = path.join(import.meta.dirname, 'dados', 'estante.json');
async function ler() {
try {
return JSON.parse(await readFile(ARQUIVO, 'utf8'));
} catch (erro) {
if (erro.code !== 'ENOENT') throw erro;
return { livros: [] }; // primeira execução: ainda não existe arquivo
}
}
async function salvar(estante) {
await mkdir(path.dirname(ARQUIVO), { recursive: true });
await writeFile(ARQUIVO, JSON.stringify(estante, null, 2) + '\n');
}
const titulo = process.argv.slice(2).join(' ');
const estante = await ler();
if (titulo) {
estante.livros.push({ titulo, entrada: '2026-05-23' });
await salvar(estante);
}
console.log(`${estante.livros.length} livro(s) na estante`);
for (const livro of estante.livros) console.log(' -', livro.titulo);Três execuções, uma depois da outra:
node estante.mjs "Torto Arado"
node estante.mjs "Vidas Secas"
node estante.mjsO processo morreu três vezes e o dado ficou. O detalhe que faz isso funcionar
está no catch: o ENOENT da primeira execução não é falha, é “ainda não tem
nada” — e vira uma estante vazia. Qualquer outro erro é relançado, porque
engolir EACCES só troca um problema visível por um silencioso.
O buraco dessa abordagem
writeFile grava por cima do arquivo antigo. Se o processo morrer no meio da
gravação — deploy, Ctrl+C, falta de energia — sobra meio JSON no disco. Dá
para simular cortando o arquivo pela metade:
head -c 60 dados/estante.json > meio.json && mv meio.json dados/estante.json
node estante.mjsSyntaxError: Unterminated string in JSON at position 60 (line 5 column 8) at JSON.parse (<anonymous>) at ler (file:///private/tmp/livraria-aurora/estante.mjs:8:17) at async file:///private/tmp/livraria-aurora/estante.mjs:21:17
Node.js v24.16.0
O banco inteiro virou lixo. A defesa clássica cabe em duas linhas: grave num
arquivo temporário e só então renomeie. O rename é atômico no sistema de
arquivos — ou o nome aponta para o arquivo velho, ou para o novo, nunca para
metade de um.
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import path from 'node:path';
const ARQUIVO = path.join(import.meta.dirname, 'dados', 'estante.json');
async function salvar(estante) {
await mkdir(path.dirname(ARQUIVO), { recursive: true });
const rascunho = `${ARQUIVO}.tmp`;
await writeFile(rascunho, JSON.stringify(estante, null, 2) + '\n');
await rename(rascunho, ARQUIVO); // troca instantânea: nunca existe meio arquivo
}
await salvar({ livros: [{ titulo: 'Torto Arado', entrada: '2026-05-23' }] });
const conteudo = await readFile(ARQUIVO, 'utf8');
console.log('lido de volta:', JSON.parse(conteudo).livros[0].titulo);Isso resolve a gravação interrompida, não a concorrência: dois processos escrevendo ao mesmo tempo continuam se atropelando. Quando chegar nesse ponto, o JSON cumpriu o papel dele e é hora de um banco de verdade.
Fechamento do dia: readdir filtrando por extensão
readdir lista o que existe numa pasta. E lista tudo: subpastas, arquivos
ocultos, o .DS_Store que o Finder deixou para trás.
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
const pasta = path.join(import.meta.dirname, 'dados', 'recibos');
const tudo = await readdir(pasta);
console.log('readdir cru ->', tudo);
const entradas = await readdir(pasta, { withFileTypes: true });
const recibos = entradas
.filter((item) => item.isFile() && path.extname(item.name) === '.json')
.map((item) => path.join(pasta, item.name));
console.log('só os .json ->', recibos.map((caminho) => path.basename(caminho)));
const lidos = await Promise.all(
recibos.map(async (caminho) => JSON.parse(await readFile(caminho, 'utf8'))),
);
const total = lidos.reduce((soma, recibo) => soma + recibo.total, 0);
console.log(`fechamento do dia: ${lidos.length} vendas, R$ ${total.toFixed(2)}`);Dois detalhes valem o hábito. withFileTypes: true faz o readdir devolver
objetos com isFile() e isDirectory(), e assim você não precisa de um stat
por item só para saber se 2025 é pasta. E readdir devolve nomes, não
caminhos: sem o path.join, o readFile procuraria 1042.json no cwd e você
cairia de novo no ENOENT da seção anterior.
As três leituras acontecem em paralelo dentro do Promise.all — com dez mil
recibos isso derruba o disco, e aí você lê em lotes. O comportamento de
Promise.all, allSettled e race é o que decide o
que acontece se um dos arquivos estiver corrompido.
Arquivo grande: quando readFile deixa de servir
Até aqui, todo arquivo cabia na memória. A exportação anual de vendas da livraria não cabe. Antes de decidir como ler, pergunte o tamanho:
import { stat } from 'node:fs/promises';
const info = await stat('exportacao.csv');
console.log('é arquivo? ->', info.isFile());
console.log('é pasta? ->', info.isDirectory());
console.log('tamanho ->', (info.size / 1024 / 1024).toFixed(1), 'MB');
if (info.size > 50 * 1024 * 1024) {
console.log('grande demais para readFile: use stream');
}Existe até um teto absoluto: uma string em JavaScript não passa de meio gigabyte.
import { constants } from 'node:buffer';
const limite = constants.MAX_STRING_LENGTH;
console.log('maior string do V8 ->', limite, 'caracteres');
console.log('em MiB ->', (limite / 1024 / 1024).toFixed(0));Passou disso, readFile com 'utf8' morre com Cannot create a string longer than 0x1fffffe8 characters e não tem contorno — 0x1fffffe8 é o mesmo
536.870.888 de cima, em hexadecimal. Mas o problema aparece muito antes do teto:
o readFile já é caro bem antes de ser impossível. Vamos somar o faturamento
do CSV de 300 MB (5.774.706 linhas) das duas maneiras. Primeiro, a ingênua:
import { readFile } from 'node:fs/promises';
const inicio = performance.now();
const texto = await readFile('exportacao.csv', 'utf8');
const linhas = texto.split('\n');
const total = linhas.reduce((soma, linha) => {
const preco = linha.split(';')[3];
return preco ? soma + Number(preco) : soma;
}, 0);
console.log('vendas ->', linhas.length - 1);
console.log('faturamento -> R$', total.toFixed(2));
console.log('tempo ->', (performance.now() - inicio).toFixed(0), 'ms');Agora a mesma conta lendo em pedaços, linha a linha, com createReadStream e o
módulo readline:
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
const inicio = performance.now();
const leitor = createInterface({
input: createReadStream('exportacao.csv'),
crlfDelay: Infinity,
});
let vendas = 0;
let total = 0;
for await (const linha of leitor) {
const preco = linha.split(';')[3];
if (!preco) continue;
total += Number(preco);
vendas += 1;
}
console.log('vendas ->', vendas);
console.log('faturamento -> R$', total.toFixed(2));
console.log('tempo ->', (performance.now() - inicio).toFixed(0), 'ms');Para medir o pico de memória de verdade, quem conta é o sistema operacional, não o processo:
/usr/bin/time -l node contar-readfile.mjs
/usr/bin/time -l node contar-stream.mjsvendas -> 5774706 faturamento -> R$ 370158654.59 tempo -> 1579 ms 1.62 real 1.53 user 0.12 sys 117538816 maximum resident set size
O resultado é idêntico, o custo não. Média de 5 execuções num MacBook, Node 24.16.0, com o arquivo já no cache do sistema:
| abordagem | pico de memória | tempo médio | teto de tamanho |
|---|---|---|---|
readFile + split |
936 MB | 1921 ms | 512 MiB de texto |
createReadStream + readline |
112 MB | 1581 ms | nenhum |
Oito vezes menos memória para ler o mesmo arquivo, e nem foi mais lento. O
motivo é simples: readFile precisa do arquivo inteiro em RAM, mais a cópia em
string UTF-16, mais os quase seis milhões de pedaços que o split cria. O
stream mantém só um pedaço por vez na memória e joga fora o que já processou.
Nos 300 MB da livraria, a diferença é entre um processo confortável e um que
morre num container com 512 MB de limite. A linha de corte que uso na prática:
até uns 10 MB, readFile e siga a vida; acima disso, stream.
O que vem depois
Você já sabe guardar dado em disco. O passo seguinte da
trilha de Node é tirar do código o que não pertence a ele:
senha, chave de API e caminho de pasta viram
variáveis de ambiente, lidas de um
.env que nunca entra no Git. Depois disso, esses arquivos passam a ser servidos
por uma API — e o guia completo de Node mostra a ordem inteira
até lá.
Perguntas frequentes
Ainda vale usar readFileSync em algum lugar?
Como verificar se o arquivo existe antes de ler?
Dá para o Node avisar quando um arquivo mudar?
Onde o Node grava, se eu passar um caminho relativo?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- Node.js — File system (fs/promises) — nodejs.org
- Node.js — Path — nodejs.org


