5 Lacunas Críticas que Todo Dev JavaScript Ignora (e Como Cobrir Cada Uma)

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O Problema Real
A maioria dos devs JavaScript sabe fazer funcionar. Poucos sabem fazer funcionar direito.
Depois de revisar centenas de PRs em projetos open source e mentorar times, identifiquei cinco lacunas que se repetem. Não são bugs óbvios. São buracos silenciosos que explodem em produção.
Este post cobre cada um com código funcional. Sem teoria vazia.
Lacuna 1: Event Loop e Microtasks Mal Compreendidos
Esse é o gap mais perigoso. Devs escrevem async/await sem entender a fila de execução.
Veja este código. Qual a ordem de saída?
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
queueMicrotask(() => console.log("4"));
console.log("5");A resposta correta é: 1, 5, 3, 4, 2.
Se você errou, essa lacuna está aberta no seu código. Microtasks (Promises, queueMicrotask) executam antes de macrotasks (setTimeout, setInterval). Isso afeta tudo: ordem de renderização, race conditions, estado inconsistente.
Como cobrir na prática
Crie um scheduler explícito quando a ordem importa:
class TaskScheduler {
#microtasks = [];
#macrotasks = [];
#isProcessing = false;
addMicrotask(fn, label = "unnamed") {
this.#microtasks.push({ fn, label });
this.#scheduleFlush();
}
addMacrotask(fn, label = "unnamed") {
this.#macrotasks.push({ fn, label });
this.#scheduleFlush();
}
#scheduleFlush() {
if (this.#isProcessing) return;
this.#isProcessing = true;
queueMicrotask(() => {
this.#flush();
this.#isProcessing = false;
});
}
#flush() {
// Microtasks primeiro, respeitando a spec
while (this.#microtasks.length > 0) {
const task = this.#microtasks.shift();
console.log(`[micro] ${task.label}`);
task.fn();
}
// Uma macrotask por ciclo, como o browser faz
if (this.#macrotasks.length > 0) {
const task = this.#macrotasks.shift();
console.log(`[macro] ${task.label}`);
setTimeout(() => {
task.fn();
if (this.#macrotasks.length > 0) this.#scheduleFlush();
}, 0);
}
}
}
// Uso
const scheduler = new TaskScheduler();
scheduler.addMacrotask(() => fetch("/api/logs"), "send-logs");
scheduler.addMicrotask(() => updateUI(), "ui-update");Esse padrão torna a ordem de execução visível e testável. Sem surpresas.
Lacuna 2: Memory Leaks em Closures e Event Listeners
JavaScript tem garbage collector. Mas ele não é mágico.
O leak mais comum? Closures que capturam referências gigantes sem necessidade.
// ❌ Leak: handler mantém referência ao array inteiro
function setupSearch(hugeDataset) {
const searchInput = document.getElementById("search");
searchInput.addEventListener("input", (e) => {
// hugeDataset (10MB) nunca será coletado enquanto
// o listener existir
const results = hugeDataset.filter((item) =>
item.name.includes(e.target.value)
);
renderResults(results);
});
}O hugeDataset fica preso na closure. Mesmo que você nunca mais precise dele inteiro.
A correção real
// ✅ Sem leak: WeakRef + cleanup explícito
function setupSearch(hugeDataset) {
const searchInput = document.getElementById("search");
// Pré-processa: cria índice leve
const searchIndex = new Map();
for (const item of hugeDataset) {
const key = item.name.toLowerCase();
if (!searchIndex.has(key)) {
searchIndex.set(key, []);
}
searchIndex.get(key).push(item.id);
}
// hugeDataset pode ser coletado agora
const controller = new AbortController();
searchInput.addEventListener(
"input",
(e) => {
const query = e.target.value.toLowerCase();
const matchedIds = [];
for (const [key, ids] of searchIndex) {
if (key.includes(query)) matchedIds.push(...ids);
}
renderResults(matchedIds);
},
{ signal: controller.signal }
);
// Cleanup quando necessário
return () => controller.abort();
}
// Uso com cleanup
const cleanup = setupSearch(massiveArray);
// Quando o componente/página desmonta:
cleanup();Duas técnicas aqui. Primeiro, pré-processamento para soltar a referência pesada. Segundo, AbortController para remover listeners de forma limpa.
Detectando leaks no seu projeto
# Chrome DevTools via CLI
npx playwright test --headed --debug
# Ou use o clinic.js para Node
npm install -g clinic
clinic doctor -- node server.jsNo Chrome DevTools, use a aba Memory > Heap Snapshot. Tire um snapshot, execute a ação suspeita, tire outro. Compare.
Lacuna 3: Projetos Open Source sem Build Reproduzível
Você clona um repo, roda npm install, e quebra. Isso é epidemia no ecossistema JavaScript.
A lacuna? Falta de rigor na configuração de build.
Setup open source profissional
Comece com o mínimo viável de reprodutibilidade:
# Crie o projeto com engine strict
mkdir meu-projeto-oss && cd meu-projeto-oss
npm init -y
# Trave a versão do Node
echo "20.11.0" > .nvmrc
node -e "console.log(process.version)" > .node-version
# Use corepack para travar o package manager
corepack enable
corepack use [email protected]Agora o package.json com constraints reais:
{
"name": "@vivodecodigo/toolkit",
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=20.11.0",
"pnpm": ">=9.15.0"
},
"packageManager": "[email protected]",
"scripts": {
"preinstall": "npx only-allow pnpm",
"build": "tsup src/index.ts --format esm,cjs --dts",
"test": "vitest run",
"lint": "biome check .",
"prepublishOnly": "npm run build && npm run test"
},
"files": ["dist"],
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
}
}Instale as ferramentas:
pnpm add -D tsup typescript vitest @biomejs/biomeO arquivo que ninguém cria: biome.json
{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": {
"suspicious": {
"noExplicitAny": "error",
"noDoubleEquals": "error"
},
"complexity": {
"noForEach": "warn",
"useFlatMap": "error"
},
"performance": {
"noAccumulatingSpread": "error"
}
}
},
"formatter": {
"indentStyle": "space",
"indentWidth": 2
}
}Biome é 35x mais rápido que ESLint + Prettier combinados. Para projetos open source, isso importa no CI.
CI mínimo que funciona
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm build
- run: pnpm testO --frozen-lockfile é inegociável. Sem ele, o CI pode instalar versões diferentes do que você testou localmente.
Lacuna 4: Testes que Testam Implementação, Não Comportamento
A maioria dos testes JavaScript que vejo são frágeis. Qualquer refatoração os quebra.
// ❌ Teste frágil: acoplado à implementação
import { vi, describe, it, expect } from "vitest";
import { UserService } from "./user-service.js";
describe("UserService", () => {
it("should call repository.findById", async () => {
const mockRepo = {
findById: vi.fn().mockResolvedValue({ id: 1, name: "Ana" }),
};
const service = new UserService(mockRepo);
await service.getUser(1);
// Isso testa COMO, não O QUE
expect(mockRepo.findById).toHaveBeenCalledWith(1);
});
});Se você mudar a implementação interna (cache, batch, etc.), esse teste quebra. Mesmo que o comportamento esteja correto.
Testes baseados em comportamento
// ✅ Teste robusto: verifica comportamento
import { describe, it, expect, beforeEach } from "vitest";
import { UserService } from "./user-service.js";
import { InMemoryUserRepository } from "./test-helpers/in-memory-user-repo.js";
describe("UserService", () => {
let service;
let repo;
beforeEach(() => {
repo = new InMemoryUserRepository();
service = new UserService(repo);
});
it("retorna usuário existente pelo id", async () => {
repo.seed({ id: 1, name: "Ana", email: "[email protected]" });
const user = await service.getUser(1);
expect(user).toEqual({
id: 1,
name: "Ana",
email: "[email protected]",
});
});
it("lança erro para usuário inexistente", async () => {
await expect(service.getUser(999)).rejects.toThrow("User not found");
});
it("retorna dados formatados independente da fonte", async () => {
repo.seed({ id: 2, name: " Carlos ", email: "[email protected]" });
const user = await service.getUser(2);
// Testa o contrato de saída, não a implementação
expect(user.name).toBe("Carlos");
expect(user.email).toBe("[email protected]");
});
});O repositório in-memory:
// test-helpers/in-memory-user-repo.js
export class InMemoryUserRepository {
#users = new Map();
seed(user) {
this.#users.set(user.id, { ...user });
}
async findById(id) {
return this.#users.get(id) ?? null;
}
async save(user) {
this.#users.set(user.id, { ...user });
}
clear() {
this.#users.clear();
}
}Agora você pode refatorar UserService à vontade. Cache, batch queries, trocar ORM. Os testes continuam passando se o comportamento estiver correto.
Configure o Vitest corretamente
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
include: ["src/**/*.test.{js,ts}"],
coverage: {
provider: "v8",
reporter: ["text", "lcov"],
exclude: ["**/test-helpers/**", "**/*.config.*"],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
},
},
testTimeout: 5000,
},
});# Rodar testes
pnpm vitest run
# Com coverage
pnpm vitest run --coverageLacuna 5: Web Performance Ignorada no Código do Dia a Dia
Devs instalam lighthouse, veem o score, e param por aí. A lacuna real está no código JavaScript que escrevem diariamente.
O spread acumulativo
Essa é a regra noAccumulatingSpread do Biome. E é devastadora:
// ❌ O(n²) - cria novo array a cada iteração
const result = items.reduce((acc, item) => [...acc, transform(item)], []);
// ✅ O(n) - push muta o mesmo array
const result = items.reduce((acc, item) => {
acc.push(transform(item));
return acc;
}, []);
// ✅✅ Melhor ainda: use map
const result = items.map(transform);Com 10.000 itens, a versão com spread é 400x mais lenta. Não é exagero. Meça você mesmo:
// benchmark.js
const items = Array.from({ length: 10_000 }, (_, i) => i);
const transform = (x) => x * 2;
console.time("spread");
items.reduce((acc, item) => [...acc, transform(item)], []);
console.timeEnd("spread");
console.time("push");
items.reduce((acc, item) => {
acc.push(transform(item));
return acc;
}, []);
console.timeEnd("push");
console.time("map");
items.map(transform);
console.timeEnd("map");node benchmark.js
# spread: 1247ms
# push: 3ms
# map: 1msLazy evaluation com generators
Quando processa listas grandes, generators evitam alocar arrays intermediários:
// ❌ Três arrays intermediários na memória
const result = hugeArray
.filter((x) => x.active)
.map((x) => x.name)
.slice(0, 10);
// ✅ Zero arrays intermediários
function* pipeline(source) {
let count = 0;
for (const item of source) {
if (!item.active) continue;
yield item.name;
count++;
if (count >= 10) return;
}
}
const result = [...pipeline(hugeArray)];A versão com generator para de iterar assim que encontra 10 resultados. Se o array tem 1 milhão de itens e os 10 primeiros são válidos, ele processa 10. Não 1 milhão.
Estruture um módulo de performance utils
// src/perf.js
export function* take(iterable, n) {
let count = 0;
for (const item of iterable) {
if (count >= n) return;
yield item;
count++;
}
}
export function* filter(iterable, predicate) {
for (const item of iterable) {
if (predicate(item)) yield item;
}
}
export function* map(iterable, fn) {
for (const item of iterable) {
yield fn(item);
}
}
// Composição lazy
export function pipe(source, ...fns) {
return fns.reduce((acc, fn) => fn(acc), source);
}
// Uso
const top5ActiveNames = pipe(
users,
(s) => filter(s, (u) => u.active),
(s) => map(s, (u) => u.name),
(s) => take(s, 5)
);
// Só executa quando consome
console.log([...top5ActiveNames]);Isso é programação funcional real em JavaScript. Sem bibliotecas de 50KB.
Checklist: Auditoria Rápida do Seu Projeto
Antes de seguir, passe este checklist no seu projeto atual:
# 1. Tem lockfile commitado?
ls pnpm-lock.yaml || ls package-lock.json
# 2. Tem versão do Node travada?
cat .nvmrc || cat .node-version
# 3. Tem CI rodando em push?
cat .github/workflows/ci.yml
# 4. Testes verificam comportamento ou implementação?
grep -r "toHaveBeenCalled" src/ --include="*.test.*" -l
# 5. Tem spread acumulativo escondido?
grep -rn "\.reduce.*\[\.\.\.acc" src/ --include="*.{js,ts}"Se algum desses falhou, você tem uma lacuna aberta.
Minha Opinião Sincera
Vou ser direto.
O ecossistema JavaScript sofre de excesso de ferramentas e falta de fundamentos. Devs instalam 47 dependências para um CRUD, mas não entendem como o event loop prioriza microtasks.
Sobre ferramentas: Biome vai substituir ESLint + Prettier na maioria dos projetos. Não é questão de "se", é de "quando". A velocidade é absurda e a DX é superior. O ESLint 9 com flat config melhorou, mas ainda é lento e a configuração continua confusa.
Sobre testes: Jest está morto para projetos novos. Vitest é mais rápido, tem melhor DX, suporta ESM nativamente e compartilha config com Vite. Se você está começando um projeto em 2025 e escolhe Jest, está fazendo uma escolha ruim.
Sobre open source: a maioria dos projetos JavaScript no GitHub é impossível de contribuir. Sem CI, sem lockfile, sem documentação de setup. Se você mantém um projeto OSS, seu README deveria levar qualquer dev do git clone ao test passing em menos de 3 minutos. Mais que isso e você está perdendo contribuidores.
Sobre performance: ninguém deveria usar reduce com spread. Nunca. Em nenhum cenário. É O(n²) e existem alternativas triviais. Se seu linter não pega isso, troque de linter.
E a lacuna mais honesta de todas? Devs não leem a spec. O MDN é excelente, mas a especificação ECMAScript explica o porquê. Quando você entende o porquê, para de decorar e começa a raciocinar.
Cubra essas cinco lacunas e você já está no top 10% dos devs JavaScript. Não porque são difíceis, mas porque quase ninguém se dá ao trabalho.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


