Entenda o que são Buffers e qual o seu papel no Node.js

No desenvolvimento de aplicações modernas, especialmente aquelas que lidam com manipulação de dados em baixo nível, como streaming de arquivos, comunicação em rede ou processamento de dados binários, o conceito de buffers é fundamental.
No Node.js, os buffers são uma ferramenta poderosa que permite trabalhar diretamente com sequências de bytes, oferecendo controle preciso sobre como os dados são armazenados, manipulados e transmitidos.
Um exemplo disso é o trabalho com streaming, que não está necessariamente ligado ao conceito de transmissão de vídeos, mas sim à transmissão contínua de informações sem a necessidade de carregar todo o dado na memória previamente.
Um dos conceitos fundamentais para que essa transmissão, leitura e escrita de dados seja feita de forma tão eficiente são os buffers. Com eles nossa aplicação não precisa consumir uma grande quantidade de memória e processamento e pode transmitir vários GB ou TB de dados com facilidade.
Neste artigo, exploraremos o que são buffers, como manipulá-los, sua importância dentro das streams e como podemos usá-los de maneira eficiente.
O que são Buffers?
Um buffer é um espaço na memória que temporariamente armazena dados binários. No Node.js temos uma classe interna chamada Buffer
que nos permite acessar e manipular esses espaços.
Ao contrário de arrays, não é possível alterar o tamanho de um buffer depois que ele é criado.
Em aplicações Node.js podemos usar buffers sem nem perceber, já que eles podem ser criados de forma implícita, por exemplo, solicitações HTTP, que retornam dados armazenados temporariamente em um buffer interno quando o cliente não pode acessar o fluxo de uma só vez.
Também são utilizados quando estamos lendo arquivos com o fs.readFile()
.
Diferentemente de uma string, buffers são sequências de bytes puros que armazenam e acessam informações direto na memória.
Isso torna a leitura e escrita de dados extremamente rápida e eficiente comparando-os a strings tradicionais.

Como os Buffers funcionam?
Sendo os buffers bytes alocados estaticamente e com um tamanho fixo, caso uma alteração de tamanho seja necessária, também é necessário criar um novo buffer.
Em Node.js existem várias formas de criar buffers. Vamos ver algumas delas:
Buffer.alloc(tamanho)
Este método cria um buffer de tamanho específico, preenchido com zeros por padrão. É uma maneira segura de se criar um buffer, pois garantimos que a memória alocada esteja limpa.
const buf = Buffer.alloc(10); // Cria um buffer de 10 bytes preenchido com zeros
console.log(buf); // <Buffer 00 00 00 00 00 00 00 00 00 00>
Buffer.from(data)
Este método cria um buffer a partir de dados existentes, como uma string, um array ou outro buffer. Ele é amplamente utilizado para converter strings em buffers.
const bufferFromData = Buffer.from("Hello World");
console.log(bufferFromData); // <Buffer 48 65 6c 6c 6f 20 57 6f 72 6c 64>
O método Buffer.from()
também aceita arrays:
const bufferFromArray = Buffer.from([1, 2, 3, 4, 5]);
console.log(bufferFromArray); // <Buffer 01 02 03 04 05>
Caso deseje passar um objeto, é necessário primeiro transformá-lo em uma string:
const bufferFromJson = Buffer.from(JSON.stringify({ name: "Thiago" }));
console.log(bufferFromJson); // <Buffer 7b 22 6e 61 6d 65 22 3a 22 54 68 69 61 67 6f 22 7d>
Temos também o método Buffer.allocUnsafe(tamanho)
, ele é usado para criar um novo buffer de um determinado tamanho, porém, sem inicializar os seus dados. Isso significa que o buffer pode conter dados antigos ou sensíveis da memória:
const buf2 = Buffer.allocUnsafe(10); // Aloca 10 bytes sem limpar
console.log(buf2); // Pode conter valores aleatórios
Como o
allocUnsafe
pode trazer dados antigos da memória, para garantir a segurança dos dados, use oBuffer.alloc()
.
Caso queira garantir que os dados no allocUnsafe
sejam zerados, você ainda poderá usar o método .fill(0)
após criar o buffer:
const bufSafe = Buffer.allocUnsafe(10);
console.log(bufSafe); // <Buffer 00 00 00 00 00 00 00 00 00 00>
O Buffer.allocUnsafe
deve ser usado em casos extremamente específicos, como quando a performance for mais importante do que a segurança, se todos os bytes vão ser reescritos antes de serem usados, ou para otimizar buffers temporários dentro de sistemas de alto desempenho.
Não o utilize ao acaso, a não ser que o uso se justifique pela situação, sempre tenha preferência pelo Buffer.alloc()
ou pelo Buffer.from()
.
Como o Buffer funciona internamente?
Podemos dizer que o Buffer no Node.js é essencialmente um wrapper em torno de um Uint8Array, que faz parte da API TypedArray do JavaScript.

Vamos entender melhor cada ponto dessa definição:
Um wrapper é um objeto que encapsula outra estrutura de dados ou funcionalidade, fornecendo uma interface adicional para facilitar seu uso.
O Buffer no Node.js é um wrapper pois encapsula um Uint8Array
e adiciona métodos extras para manipular buffers de forma mais conveniente.
Uint8Array
é um tipo específico de TypedArray
no JavaScript, que representa uma sequência de números inteiros de 8 bits sem sinal (valores entre 0 e 255). Ele é eficiente para manipular dados binários, pois cada elemento ocupa exatamente 1 byte de memória.
Se você quiser se aprofundar no tema das Uint8Arrays, acesse este conteúdo no site do Mozilla. O texto está em inglês.
A API TypedArray é um conjunto de classes do JavaScript projetadas justamente para manipulação de dados binários e se difere dos arrays comuns do JavaScript por armazenarem valores diretamente na memória, com um tamanho fixo e sem sobrecarga de objetos JavaScript.
Uint8Array
→ Inteiros sem sinal de 8 bits (valores de0
a255
)Int8Array
→ Inteiros com sinal de 8 bits (valores de-128
a127
)Uint16Array
→ Inteiros sem sinal de 16 bits (0
a65535
)Float32Array
→ Números de ponto flutuante de 32 bits
Existem vários outros TypedArrays, caso tenha interesse você pode consultar todos os tipos no site da Mozilla. Em inglês.
E como o Buffer usa o Uint8Array?
Como dito anteriormente, o Buffer do Node.js é essencialmente um wrapper em torno de um Uint8Array
. Ele usa Uint8Array
internamente, mas adiciona métodos extras para facilitar a manipulação de dados binários.
Aqui vai uma comparação entre o Uint8Array
e o Buffer
const uintArray = new Uint8Array([79, 73]);
console.log(uintArray); // Uint8Array(2) [79, 73]
const buffer = Buffer.from([79, 73]);
console.log(buffer); // <Buffer 4f 49> (4f e 49 são os códigos hexadecimais de 'O' e 'I')
console.log(buffer.toString()); // OI
Embora ambos armazenem os mesmos dados, o Buffer adiciona funcionalidades e extras como .toString()
, .copy()
, .slice()
, .write()
entre outras funções.
Mas calma! Vamos ver algumas dessas funções na sequência.
Manipulação de Buffers
Escrever dados em um buffer
Para Buffers que já foram criados, podemos escrever utilizando o método .write()
const bufferWrite = Buffer.from("Hello");
console.log(bufferWrite); // <Buffer 48 65 6c 6c 6f>
console.log(bufferWrite.toString()); // Hello
bufferWrite.write("World");
console.log(bufferWrite); // <Buffer 48 65 6c 6c 6f>
console.log(bufferWrite.toString()); // Hello
Nesse caso, como ambas as palavras têm 5 letras não precisamos passar nenhum delimitador. Caso fosse necessário editar apenas um ou mais caracteres, a chamada do .write()
precisaria ser alterada:
bufferWrite.write("A", 0, 1);
console.log(bufferWrite); // <Buffer 41 65 6c 6c 6f>
console.log(bufferWrite.toString()); // Aello
Concatenar dados usando buffers
Também é possível concatenar o conteúdo de dois buffers, gerando um novo que já comporta o tamanho dos dois outros utilizados como base:
const buf1 = Buffer.from("Ola ");
const buf2 = Buffer.from("Amigos");
const bufConcat = Buffer.concat([buf1, buf2]);
console.log(bufConcat.toString()); // "Ola Amigos"
Copiar conteúdo de um buffer para outro
Podemos copiar o conteúdo de um buffer, mas é importante lembrar que o tamanho do novo deve ser igual ou maior. Caso contrário, ele irá cortar os bytes que estão “sobrando”:
const bufOrigem = Buffer.from("Thiago");
const bufDestino = Buffer.alloc(5);
bufOrigem.copy(bufDestino);
console.log(bufDestino.toString()); // "Thiag"
Essa regra também se aplica para a escrita: tentar escrever num buffer um conjunto de dados maior que o tamanho original dele causará um “corte” nos dados excedentes.
Alterar dados em um buffer
Também podemos alterar uma informação de forma mais precisa em um buffer passando sua posição no array.
O Buffer é basicamente um array de bytes. Assim, podemos criar um buffer a partir de uma string e modificar seus valores diretamente, acessando os índices como em um array normal.
const bufferASCII = Buffer.from("EU");
bufferASCII[0] = 77; // caracter para M em ASCII
console.log(bufferASCII.toString()); // "MU"
const bufferHex = Buffer.from("EU");
bufferHex[0] = 0x4d; // caracter para M em hexadecimal
console.log(bufferHex.toString()); // "MU"
Cada caractere da string inicial ocupa um byte no buffer. Quando acessamos buffer[0] = 77;
, estamos alterando diretamente o primeiro byte.
O Buffer
não se comporta como uma string imutável. Se alterarmos um índice específico, ele muda apenas esse byte, mantendo os outros inalterados.
Cuidados ao modificar um Buffer
Cada posição do Buffer
representa um único byte (0-255
). Se você definir um valor maior que 255
, o Node.js aplicará um & 255
, mantendo apenas os 8 bits menos significativos:
const buffer = Buffer.from("EU");
buffer[0] = 301; // 301 em binário: 100101101 -> Apenas os últimos 8 bits são usados: 00101101 (45)
console.log(buffer.toString()); // "-U"
Quando lidamos com textos em UTF-8 (como caracteres acentuados), um caractere pode ocupar mais de um byte. Alterar um byte isolado pode corromper o texto:
const buffer = Buffer.from("ÉU"); // "É" ocupa mais de 1 byte
console.log(buffer); // <Buffer c3 89 55>
buffer[0] = 0x4d; // Tentando alterar "É" para "M"
console.log(buffer.toString()); // M�U
Então para textos UTF-8, use Buffer.alloc()
e evite alterar diretamente bytes individuais.
Importância dos Buffers em Streams
As streams são uma abstração para manipulação eficiente de dados de forma assíncrona. Buffers são cruciais para streams, pois permitem manipular fragmentos de dados que chegam aos poucos, sem precisar carregá-los completamente na memória.
Ao ler um arquivo grande, por exemplo, em vez de carregar tudo de uma vez, usamos streams que processam pequenos blocos de dados armazenados temporariamente em buffers. Isso reduz o consumo de memória e melhora a performance.
Exemplo de uso de Buffers em Streams
Para os exemplos que vem a seguir, eu criei um arquivo NDJSON que simula um relatório escolar, contendo algumas informações sobre o aluno e suas matérias.
Mas para esse primeiro exemplo você pode criar um simples arquivo.txt
.
Caso deseje utilizar o mesmo arquivo que eu, copie o ndjson
abaixo, crie um arquivo *.ndjson
, cole e salve o novo conteúdo:
{"name":"Peggy Hartmann","age":23,"email":"Esther.Hyatt@gmail.com","class":"rJrEw","subjects":["Matemática","História","Ciências","Geografia","Inglês"],"finalAverage":7.4}
{"name":"Marcus Breitenberg","age":30,"email":"Janice_Marvin24@gmail.com","class":"z1QxA","subjects":["Matemática","História","Ciências","Geografia","Inglês"],"finalAverage":0.5}
{"name":"Delores Muller","age":24,"email":"Bridgette96@hotmail.com","class":"hcSfn","subjects":["Matemática","História","Ciências","Geografia","Inglês"],"finalAverage":0.2}
O código abaixo cria uma stream de leitura que vai ler nosso ndjson
e retornar no console o seu conteúdo:
import fs from "fs";
const stream = fs.createReadStream("alunos.ndjson");
stream.on("data", (chunk) => {
console.log(`Recebido ${chunk.length} bytes de dados`);
// console.log(Buffer.byteLength(chunk, "utf-8")); // alternativa para saber o total de bytes de um buffer
console.log(chunk);
console.log(chunk.toString());
});
stream.on("end", () => console.log("Leitura concluída"));
Saída:
Recebido 535 bytes de dados
<Buffer 7b 22 6e 61 6d 65 22 3a 22 50 65 67 67 79 20 48 61 72 74 6d 61 6e 6e 22 2c 22 61 67 65 22 3a 32 33 2c 22 65 6d 61 69 6c 22 3a 22 45 73 74 68 65 72 2e ... 485 more bytes>
{"name":"Peggy Hartmann","age":23,"email":"Esther.Hyatt@gmail.com","class":"rJrEw","subjects":["Matemática","História","Ciências","Geografia","Inglês"],"finalAverage":7.4}
{"name":"Marcus Breitenberg","age":30,"email":"Janice_Marvin24@gmail.com","class":"z1QxA","subjects":["Matemática","História","Ciências","Geografia","Inglês"],"finalAverage":0.5}
{"name":"Delores Muller","age":24,"email":"Bridgette96@hotmail.com","class":"hcSfn","subjects":["Matemática","História","Ciências","Geografia","Inglês"],"finalAverage":0.2}
Leitura concluída
O fs.createReadStream
já fornece o uso de Buffers
de maneira implícita, por isso console.log()
nos dados do chunk imprime um Buffer
como resposta no terminal e em seguida, ao usar o método .toString()
conseguimos converter esse valor para uma string legível para nós, humanos.
No Node.js, o termo chunk se refere a um pedaço pequeno e gerenciável de dados que faz parte de um conjunto de dados maior.
Porém, temos um ponto interessante: como deixamos para o próprio Node.js lidar com o Buffer para essa leitura, ele criou um Buffer que corresponde ao tamanho total do arquivo.
Nosso arquivo tem apenas 3 linhas e 535 bytes, mas utilizamos streams para processar arquivos pequenos e principalmente arquivos grandes, no caso, 2GB ou maiores.
Então, caso nosso arquivo tivesse 2GB, o Buffer criado teria por volta dos 2.147.483.648 bytes. Essa quantidade de bytes seria consumida em nossa memória durante a execução da stream?
No caso da leitura do arquivo, a resposta é não!
Por padrão a fs.createReadStream
tem uma propriedade chamada highWaterMark
que vai dividir os chunks da leitura em um tamanho fixo de 64 KB (64 * 1024 bytes).
Quando estamos falando da escrita, a mesma regra se aplica? Bom, aí as coisas começam a ficar um pouco mais interessantes! Vamos para um exemplo mais prático para entender alguns conceitos.
Abra o seu gerenciador de tarefas e habilite o filtro para processos com o nome Student
, ou mais precisamente StudentStreamsGenerator
:



Agora vamos criar um script para gerar um relatório em formato `ndjson. Assim conseguiremos informações sobre nossos alunos de forma randômica, utilizando streams:
import { faker } from "@faker-js/faker";
import fs from "fs";
process.title = "StudentStreamsGenerator";
function createStudent() {
return {
name: faker.person.fullName(),
age: faker.number.int({ min: 18, max: 30 }),
email: faker.internet.email(),
class: faker.string.alphanumeric({ length: { min: 5, max: 5 } }),
subjects: ["Matemática", "História", "Ciências", "Geografia", "Inglês"],
finalAverage: parseFloat(
faker.number.float({ min: 0, max: 10, precision: 0.1 }).toFixed(1)
),
};
}
function generateStudentsWithStreams(numStudents, filePath) {
const writeStream = fs.createWriteStream(filePath);
writeStream.on("error", (error) => {
console.error(`Error writing to file ${filePath}:`, error);
});
for (let i = 0; i < numStudents; i++) {
const student = createStudent();
writeStream.write(JSON.stringify(student) + "\n");
}
writeStream.end();
writeStream.on("finish", () => {
console.log(`Generated ${numStudents} students and saved to ${filePath}`);
});
}
// Example usage:
generateStudentsWithStreams(1_000_000, "students_stream.ndjson");
No caso, estamos criando um processo chamado StudentStreamsGenerator
e criando um milhão (1_000_000
) de registros de alunos.
Ao executar o nosso script e verificar o terminal, podemos ver um alto consumo de memória atrelado a esse processo:

Agora vamos criar um novo arquivo para ler nosso novo relatório:
import fs from "fs";
process.title = "StudentLeitura";
const stream = fs.createReadStream("students_stream.ndjson");
stream.on("data", (chunk) => {
console.log(chunk);
});
Ao executar esse script, a execução é tão rápida que no nosso caso, não é possível ver o processo ser registrado no gerenciador de tarefas. Isso acontece porque o sistema operacional lê os dados de maneira extremamente rápida.
Os testes foram feitos em um computador Intel Core i7-12700H com 16GB de RAM.
Então, temos um resultado bem satisfatório usando streams para leitura de arquivos grandes, mas na hora de criar o relatório, por mais que o processo seja executado em alguns segundos (nesta situação, 8 segundos), temos um alto consumo de memória.
Qual o motivo desse comportamento?
Primeiramente, não temos um controle sobre o nosso fluxo de escrita e assim, geramos um problema de backpressure.
Ou seja, o writeStream.write()
não espera que o chunk anterior seja completamente escrito no arquivo antes de adicionar um novo ao buffer interno.
Se o buffer interno do writeStream
ficar cheio (ou seja, atingir o highWaterMark
), o método write
retorna false
, indicando que a aplicação deve parar de escrever até que o evento drain
seja emitido.
No caso, nossa aplicação não está emitindo o evento drain
, logo o writeStream
entende que pode continuar adicionando chunks ao buffer interno e seguir escrevendo o que for necessário.
Quanto ao Backpressure
, ele é o mecanismo que controla o fluxo de dados entre streams para evitar que o produtor (o loop que gera os estudantes) sobrecarregue o consumidor (writeStream
).
Em nosso código o loop continua escrevendo estudantes sem verificar se o writeStream
está pronto para receber mais dados. Como o loop não espera o writeStream
processar os dados, os chunks se acumulam no buffer interno da stream, causando um consumo alto de memória.
Vamos então tentar uma solução que utilize esse controle de backpressure e só escreveremos se o buffer ainda tiver espaço para isso. Caso contrário, vamos esperar que o buffer interno seja esvaziado para poder voltar a escrever os dados de estudantes.
Resolvendo backpressure com recursão
Adaptando nossa aplicação:
function generateStudentsWithStreams(numStudents, filePath) {
const writeStream = fs.createWriteStream(filePath);
function writeStudent(i) {
if (i >= numStudents) {
writeStream.end();
return;
}
const student = createStudent();
const canContinue = writeStream.write(JSON.stringify(student) + "\n");
if (canContinue) {
writeStudent(i + 1);
} else {
writeStream.once("drain", () => writeStudent(i + 1));
}
}
writeStream.on("finish", () => {
console.log(`Generated ${numStudents} students and saved to ${filePath}`);
});
writeStudent(0);
}
Vamos entender um pouco os pontos chave para essa nova implementação, que faz uso de uma função recursiva para manter o fluxo de escrita.
Dentro da função principal generateStudentsWithStreams
temos uma função chamada writeStudent
. Como toda boa função recursiva, temos uma validação para definir a saída dessa nossa função.
Caso nosso contador seja maior ou igual ao número de estudantes que escrever, iremos fechar a stream e sair com um return
:
if (i >= numStudents) {
writeStream.end();
return;
}
Vamos para o próximo passo:
const student = createStudent();
const canContinue = writeStream.write(JSON.stringify(student) + "\n");
Geramos então um estudante e convertemos para uma string JSON. O método writeStream.write
tenta escrever os dados no arquivo, retornando true
se os dados forem adicionados ao buffer interno do writeStream
e false
se o buffer estiver cheio (ou seja, atingiu o highWaterMark
).
Agora fazemos o controle do backpressure:
if (canContinue) {
writeStudent(i + 1);
} else {
writeStream.once("drain", () => writeStudent(i + 1));
}
Se canContinue
for true
, o buffer interno do writeStream
ainda terá espaço para mais dados e a função writeStudent
será chamada recursivamente para processar o próximo estudante (i + 1
).
Se canContinue
for false
, significa que o buffer interno do writeStream
está cheio, então a aplicação pausará a escrita e aguardará o evento drain
, que será emitido quando o buffer for esvaziado (ou seja, quando há espaço para novos dados).
Quando o evento drain
for emitido, a função writeStudent
será chamada novamente para continuar o processo, recebendo o contador atual +1, no caso i+1
.
Se executarmos nosso script agora e verificarmos o consumo de memória no gerenciador do sistema operacional, percebemos que o processo vai ter um uso de memória constante, sem variações para um pico elevado de consumo de memória:

Isso acontece porque agora estamos controlando nossa escrita para ela ser feita somente quando o writeStream
estiver pronto para receber mais dados.
E teríamos outra forma de fazer isso sem a recursão e sem ter que lidar com o controle manual do backpressure?
É claro! Vamos a mais uma alternativa de implementação!
Resolvendo backpressure com funções geradoras e pipelines
Primeiro vamos adicionar mais dois imports no começo do arquivo:
import { pipeline } from "stream/promises";
import { Readable } from "stream";
Em seguida vamos novamente substituir apenas a função generateStudentsWithStreams
:
async function generateStudentsWithStreams(numStudents, filePath) {
function* generateStudents() {
for (let i = 0; i < numStudents; i++) {
yield JSON.stringify(createStudent()) + "\n";
}
}
const studentStream = Readable.from(generateStudents(), { encoding: "utf-8" });
const writeStream = fs.createWriteStream(filePath);
try {
await pipeline(studentStream, writeStream);
console.log(`Generated ${numStudents} students and saved to ${filePath}`);
} catch (error) {
console.error("Error during pipeline:", error);
}
}
Agora vamos analisar o código, começando pela nossa generator function
presente em generateStudentsWithStreams
:
function* generateStudents() {
for (let i = 0; i < numStudents; i++) {
yield JSON.stringify(createStudent()) + "\n";
}
}
Com o uso do operador *
podemos criar uma função geradora, que permite a geração de dados sob demanda, retornando os dados também sob demanda através do operador yield
.
É possível dizer que o yield
é uma espécie de retorno incremental, que pode ser chamado diversas vezes até que não tenhamos mais dados para retornar.
Então utilizamos o Readable.from
, que cria uma stream de leitura a partir de um iterável (no caso, um gerador que é o nosso generateStudents
).
const studentStream = Readable.from(generateStudents(), { encoding: "utf-8" });
É aqui que está o segredo, pois o Readable.from
vai cuidar automaticamente do backpressure, pausando a geração de dados quando o consumidor writeStream
não estiver pronto.
Em seguida criamos nossa stream de escrita, passando o caminho/nome do arquivo que desejamos escrever como parâmetro:
const writeStream = fs.createWriteStream(filePath);
Por fim, utilizamos o pipeline para poder conectar a stream de leitura com a stream de escrita.
try {
await pipeline(studentStream, writeStream);
console.log(`Generated ${numStudents} students and saved to ${filePath}`);
} catch (error) {
console.error("Error during pipeline:", error);
}
Agora podemos executar o script novamente e acompanhar nosso consumo de memória no gerenciador de tarefas. Ele deve permanecer baixo e com um valor constante.
Streams e readline
Ainda seguindo o nosso exemplo do relatório de alunos, poderíamos então ler o relatório, aplicar uma regra de negócio a cada linha e criar um novo relatório a partir disso? A resposta é sim!
Agora que aprendemos a lidar com nossos buffers implícitos, podemos utilizar o módulo readline
para criar uma interface de leitura mais simples, que vai ler linha por linha do relatório e passar para escrita apenas uma linha por vez.
Isso faz com que o nosso buffer atual tenha apenas o tamanho em bytes necessário para a linha processada a cada vez.
Para esse exemplo, eu vou apenas utilizar aqueles 3 primeiros alunos usados anteriormente em vez do nosso relatório gigantesco. Você pode escolher usar outros registros para teste, se quiser.
{"name":"Peggy Hartmann","age":23,"email":"Esther.Hyatt@gmail.com","class":"rJrEw","subjects":["Matemática","História","Ciências","Geografia","Inglês"],"finalAverage":7.4}
{"name":"Marcus Breitenberg","age":30,"email":"Janice_Marvin24@gmail.com","class":"z1QxA","subjects":["Matemática","História","Ciências","Geografia","Inglês"],"finalAverage":0.5}
{"name":"Delores Muller","age":24,"email":"Bridgette96@hotmail.com","class":"hcSfn","subjects":["Matemática","História","Ciências","Geografia","Inglês"],"finalAverage":0.2}
E vamos mais uma vez criar um novo arquivo para executar o seguinte script:
import fs from "fs";
import readline from "readline";
process.title = "StudentFilter";
async function processStudents(inputFilePath, outputFilePath) {
const readStream = fs.createReadStream(inputFilePath, { encoding: "utf-8" });
const rl = readline.createInterface({
input: readStream,
});
const writeStream = fs.createWriteStream(outputFilePath);
let totalBytes = 0;
try {
for await (const line of rl) {
try {
const lineBytes = Buffer.byteLength(line, "utf-8");
console.log(lineBytes);
totalBytes += lineBytes;
const student = JSON.parse(line);
if (student.finalAverage >= 6.0) {
student.name = student.name.toUpperCase();
writeStream.write(JSON.stringify(student) + "\n");
}
} catch (error) {
console.error("Error ao processar JSON:", error);
}
}
console.log(`Processamento concluído, total de bytes: ${totalBytes}`);
} catch (error) {
console.error("Pipeline falhou:", error);
}
}
// Exemplo de uso:
processStudents("teste.ndjson", "students_filtered.ndjson");
Começamos criando uma stream de leitura e em seguida uma const
chamada rl
, que ao utilizar do módulo readline
, irá criar uma interface para nossa stream de leitura ser lida linha por linha.
const readStream = fs.createReadStream(inputFilePath, { encoding: "utf-8" });
const rl = readline.createInterface({
input: readStream,
});
Em seguida, vamos criar nossa stream de escrita e um contador de bytes apenas para fins didáticos.
Nesse caso, vamos comparar se mesmo utilizando o readline, a quantidade de bytes do nosso buffer vai ser igual ao nosso primeiro exemplo, que foi de 535
.
const writeStream = fs.createWriteStream(outputFilePath);
let totalBytes = 0;
Dentro do for await
vamos medir quantos bytes tem o buffer atual, ou seja, a linha que está sendo percorrida nesse momento. Depois printamos esse valor e somamos ao totalizador, para no final termos a somatória de todas as linhas.
const lineBytes = Buffer.byteLength(line, "utf-8");
console.log(lineBytes);
totalBytes += lineBytes;
Como a aplicação lê uma linha por vez e definimos que cada linha deve ser convertida de buffer bruto para utf-8
, só precisamos converter a linha atual em um objeto JavaScript com JSON.parse
. Com isso podemos manipular os dados como quisermos!
const student = JSON.parse(line);
if (student.finalAverage >= 6.0) {
student.name = student.name.toUpperCase();
writeStream.write(JSON.stringify(student) + "\n");
}
Então criamos uma condicional para informar que apenas estudantes com média final maior ou igual a 6
vão ser incluídos no novo relatório.
Definimos que stream de leitura deve escrever apenas aquela linha, que será novamente transformada em string com uma quebra de linha para manter o formato *.ndjson
de nosso relatório.
Dessa forma, a stream de escrita recebe um buffer apenas do tamanho da linha atual, e não o relatório todo, já que estamos fazendo isso incrementalmente pelo nosso for await
.
Ao executar o script teremos a seguinte saída:
175
182
176
Processamento concluído, total de bytes: 533
Isso mostra o tamanho do buffer correspondente a cada linha que estamos tentando ler. Temos dois bytes a mais pois o readline
remove a quebra de linha que temos no final. Se contarmos as quebras de linha da linha 1 e linha 2, teríamos os mesmos 535 bytes
.
Caso deseje, você pode executar o mesmo script passando o relatório com um milhão (1_000_000
) de alunos, e acompanhar o gerenciador de tarefas novamente.
Repare que o consumo de memória vai se manter baixo, com uma pequena variação dependendo do tamanho da linha sendo processada, mas nada que extrapole o uso de memória como no primeiro caso, em que não tínhamos o tratamento de backpressure.
Conclusão
O uso de buffers permite a criação de um espaço na memória de rápido acesso e torna nossas operações muito mais rápidas, em comparação com strings normais.
Buffers são aliados poderosos das streams
, usadas para transmitir dados sob demanda sem a necessidade de carregar todos esses dados em memória, o que mantém um fluxo contínuo de escrita e leitura de informações.
Importante: Caso não façamos a definição do buffer explicitamente para a stream, ela irá lidar com isso de maneira implícita, o que pode gerar problemas de backpressure e um alto consumo de memória durante a escrita dos dados.
Por fim, vimos algumas maneiras de lidar com o backpressure:
- Lidando diretamente com o intervalo de limpeza do buffer interno do Node.js;
- Delimitando a quantidade de informações sendo transmitidas e escritas, para que o buffer interno tenha apenas o tamanho do dado a ser transmitido. Dessa forma, um fluxo contínuo de entrega e um baixo consumo de memória são permitidos.