Ir para o conteúdo
Backend

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

Marcos Soares
Atualizado em 
10 minutos de leitura
Ilustração 3D de bloco de vidro translúcido fraturado com luz esmeralda representando lacunas críticas em JavaScript
Ouça este artigo
0:005 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?

JAVASCRIPT
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:

JAVASCRIPT
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.

JAVASCRIPT
// ❌ 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

JAVASCRIPT
// ✅ 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

Bash
# Chrome DevTools via CLI
npx playwright test --headed --debug
 
# Ou use o clinic.js para Node
npm install -g clinic
clinic doctor -- node server.js

No 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:

Bash
# 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:

JSON
{
  "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:

Bash
pnpm add -D tsup typescript vitest @biomejs/biome

O arquivo que ninguém cria: biome.json

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

YAML
# .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 test

O --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.

JAVASCRIPT
// ❌ 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

JAVASCRIPT
// ✅ 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:

JAVASCRIPT
// 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

TYPESCRIPT
// 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,
  },
});
Bash
# Rodar testes
pnpm vitest run
 
# Com coverage
pnpm vitest run --coverage

Lacuna 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:

JAVASCRIPT
// ❌ 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:

JAVASCRIPT
// 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");
Bash
node benchmark.js
# spread: 1247ms
# push: 3ms
# map: 1ms

Lazy evaluation com generators

Quando processa listas grandes, generators evitam alocar arrays intermediários:

JAVASCRIPT
// ❌ 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

JAVASCRIPT
// 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:

Bash
# 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.

Marcos Soares

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.