Introdução: por que a performance do JavaScript é importante?
Quando você escreve seu primeiro aplicativo em JavaScript, ele pode funcionar rápido e sem problemas. Mas à medida que o projeto cresce — adicionando novas funcionalidades, aumentando o volume de dados e o número de usuários — a performance começa a cair. Scripts lentos, congelamentos de interface e processamento lento de requisições são problemas típicos que até mesmo desenvolvedores experientes enfrentam.
Para programadores iniciantes, a otimização muitas vezes parece algo complexo e secundário. No entanto, entender os fundamentos da performance ajudará você a escrever não apenas código funcional, mas também código rápido desde o início. Neste artigo, vamos abordar as principais técnicas de otimização de aplicações JavaScript em Node.js que você pode aplicar ainda hoje.
1. Entendendo o Event Loop e a Assincronicidade
JavaScript é uma linguagem single-thread, mas graças ao Event Loop, ele consegue processar múltiplas tarefas sem bloqueio. Para Node.js, isso é especialmente crítico, pois o servidor precisa atender milhares de requisições simultaneamente.
O erro principal de iniciantes é usar operações síncronas onde a assincronicidade é necessária. Por exemplo, ler um arquivo grande de forma síncrona bloqueará todo o processo:
// RUIM: bloqueia o Event Loop
const fs = require('fs');
const data = fs.readFileSync('/bigfile.txt', 'utf8');
console.log(data);
Em vez disso, use métodos assíncronos:
// BOM: não bloqueia o Event Loop
const fs = require('fs');
fs.readFile('/bigfile.txt', 'utf8', (err, data) => {
if (err) throw err;
console.log(data);
});
A abordagem moderna é usar async/await com promises:
// AINDA MELHOR: async/await
const fs = require('fs').promises;
async function readFile() {
const data = await fs.readFile('/bigfile.txt', 'utf8');
console.log(data);
}
readFile();
Dica: Sempre dê preferência às versões assíncronas das funções (com callbacks, promises ou async/await). Isso permitirá que seu servidor processe outras requisições enquanto aguarda operações de I/O.
2. Evite operações "pesadas" na thread principal
Às vezes, não há como evitar cálculos síncronos — por exemplo, criptografia, processamento de imagens ou cálculos matemáticos complexos. Se executá-los na thread principal, o aplicativo "congelará" durante a execução.
Para tais tarefas, use Worker Threads (no Node.js) ou Web Workers (no navegador). Vejamos um exemplo em Node.js:
// main.js
const { Worker } = require('worker_threads');
function runHeavyTask(data) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', { workerData: data });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) reject(new Error(`Worker stopped with exit code ${code}`));
});
});
}
// worker.js
const { parentPort, workerData } = require('worker_threads');
// Cálculos pesados
let result = 0;
for (let i = 0; i < 1e9; i++) {
result += i;
}
parentPort.postMessage(result);
Essa abordagem permite manter a capacidade de resposta do aplicativo mesmo ao executar tarefas que consomem muitos recursos.
3. Otimização do trabalho com dados e cache
Frequentemente, o gargalo é a execução repetida das mesmas operações. Por exemplo, uma consulta ao banco de dados ou o cálculo de um valor complexo. A solução é o cache.
Um exemplo simples usando um objeto de cache:
const cache = {};
async function getExpensiveData(userId) {
if (cache[userId]) {
console.log('Retornando do cache');
return cache[userId];
}
// Simulação de uma requisição longa
const data = await fetchFromDatabase(userId);
cache[userId] = data;
return data;
}
Para cenários mais complexos, use