Guia de estudoStudy guide

Node.js e TypeScript, do event loop à produção.Node.js and TypeScript, from the event loop to production.

Uma linguagem dos dois lados do fio. O que quem se especializa nela deveria saber além da sintaxe: como o runtime funciona, o que o trava, como a configuração chega, como testar, publicar, observar e colocar sob carga. Todos os experimentos daqui foram executados, e os números são os medidos.One language on both sides of the wire. What someone who specialises in it should know beyond the syntax: how the runtime works, what blocks it, how configuration arrives, how to test, ship, observe and load-test it. Every experiment here was run, and the numbers are the ones measured.

O mapa e a referênciaThe map and the reference

JavaScript é a única linguagem que roda nativamente no navegador e, com o Node.js, também no servidor. Quem escolhe se especializar nela ganha uma vantagem rara: o mesmo idioma, os mesmos tipos e às vezes o mesmo código dos dois lados do fio. O preço é que “saber JavaScript” não basta. É preciso entender o runtime (o que executa o código e como ele espera por I/O), o ferramental (TypeScript, gerenciador de pacotes, bundler, test runner) e a operação (configuração, deploy, observabilidade, carga). Esta nota percorre os três.JavaScript is the only language that runs natively in the browser and, with Node.js, on the server too. Whoever chooses to specialise in it gets a rare advantage: the same language, the same types and sometimes the same code on both sides of the wire. The price is that “knowing JavaScript” is not enough. You need to understand the runtime (what executes the code and how it waits for I/O), the tooling (TypeScript, package manager, bundler, test runner) and the operation (configuration, deploy, observability, load). This note walks through all three.

Seu código (TypeScript compilado ou com tipos apagados)Your code (TypeScript compiled or with types stripped)app.ts · express · fastify · react-dom/serverAPIs do Node em JavaScriptNode's JavaScript APIsnode:fs · node:http · node:stream · node:crypto · node:test · node:worker_threadsBindings em C++ (a ponte entre o JavaScript e o sistema)C++ bindings (the bridge between JavaScript and the system)V8compila e executa JS, heap, GCcompiles and runs JS, heap, GClibuvevent loop, I/O assíncrono, thread poolevent loop, async I/O, thread poolSistema operacional: epoll (Linux), kqueue (macOS), IOCP (Windows)Operating system: epoll (Linux), kqueue (macOS), IOCP (Windows)
As camadas de um processo Node. O seu código fala com APIs em JavaScript; por baixo, o V8 executa o JavaScript e o libuv cuida do event loop, do I/O e de um pool de threads.The layers of a Node process. Your code talks to JavaScript APIs; underneath, V8 runs the JavaScript and libuv handles the event loop, I/O and a thread pool.
ÁreaAreaO que saberWhat to knowFerramentasTools
LinguagemLanguagepromises, async/await, closures, erros, ESM e CommonJSpromises, async/await, closures, errors, ESM and CommonJSECMAScript, MDN
RuntimeRuntimeevent loop, microtasks, thread pool, streams, workersevent loop, microtasks, thread pool, streams, workersNode.js (V8 + libuv)
TiposTypestsconfig, tipos nas fronteiras, validação em runtimetsconfig, types at the boundaries, runtime validationTypeScript, zod
FrontFront endevent loop do navegador, bundle, hidratação, Web Vitalsbrowser event loop, bundle, hydration, Web VitalsVite, React, Lighthouse
TestesTestsunidade, integração, ponta a ponta, mocks de tempounit, integration, end to end, time mocksnode:test, Vitest, Playwright
EntregaDeliverylockfile, imagem, sinais, health check, pipelinelockfile, image, signals, health check, pipelinepnpm/npm, Docker, GitHub Actions
OperaçãoOperationlogs estruturados, traces, atraso do event loop, cargastructured logs, traces, event loop delay, loadpino, OpenTelemetry, autocannon, k6

A referência: Erick WendelThe reference: Erick Wendel

Para ir fundo em Node, uma boa referência brasileira é o Erick Wendel. Ele é committer do núcleo do Node.js e escreveu partes que você vai usar nesta nota: os mock timers do test runner nativo (PR #47775, no Node 20.4) e depois o mock de Date, que levou o recurso a estável no Node 23.1. Ele também recriou um runtime JavaScript do zero em C++, com V8 e libuv, para mostrar o que o Node faz por baixo (conversa no JS Party #268). O estilo dele é o desta nota: entender o mecanismo construindo e medindo, em vez de decorar API. Os conteúdos estão no site dele e nos cursos da ew.academy.To go deep in Node, a good Brazilian reference is Erick Wendel. He is a Node.js core committer and wrote parts you will use in this note: the native test runner's mock timers (PR #47775, in Node 20.4) and later the Date mock, which took the feature to stable in Node 23.1. He also rebuilt a JavaScript runtime from scratch in C++, with V8 and libuv, to show what Node does underneath (discussed on JS Party #268). His style is this note's: understand the mechanism by building and measuring, instead of memorising APIs. His material is on his site and in the ew.academy courses.

Como os números desta nota foram obtidos. Rodei tudo no Node 24.18 (Linux) e conferi os pontos sensíveis no Node 26.13 em contêiner (node:26-alpine). Em 3 de novembro de 2026, o Node 26 é a linha Active LTS e o 24 está em manutenção até abril de 2028, segundo o calendário oficial. A partir do Node 27 o projeto passa a lançar uma versão por ano, e toda versão vira LTS.How this note's numbers were obtained. I ran everything on Node 24.18 (Linux) and checked the sensitive points on Node 26.13 in a container (node:26-alpine). On 3 November 2026, Node 26 is the Active LTS line and 24 is in maintenance until April 2028, according to the official schedule. From Node 27 on, the project ships one major a year, and every major becomes LTS.

JavaScript: o que importa de verdadeJavaScript: what really matters

Sintaxe se aprende em uma semana. O que separa quem domina JavaScript é entender poucas ideias que explicam quase todos os bugs estranhos.Syntax takes a week to learn. What sets apart someone who masters JavaScript is understanding a few ideas that explain almost every strange bug.

  • Uma thread executa o seu JavaScript. Nunca duas funções suas rodam ao mesmo tempo no mesmo processo (salvo em workers, que têm memória separada). Isso elimina condições de corrida em variáveis, mas significa que um laço demorado trava tudo. Concorrência em JavaScript é esperar várias coisas ao mesmo tempo, não calcular várias ao mesmo tempo.One thread runs your JavaScript. Two of your functions never run at the same time in the same process (except in workers, which have separate memory). This removes race conditions on variables, but it means a long loop freezes everything. Concurrency in JavaScript is waiting for several things at once, not computing several at once.
  • Promise é um valor que chega depois. async/await é açúcar sobre promises: cada await devolve o controle ao event loop e continua numa microtask. Para esperar várias coisas em paralelo, inicie todas e espere juntas com Promise.all (falha na primeira rejeição) ou Promise.allSettled (espera todas). Um await dentro de um for transforma chamadas independentes em uma fila.A promise is a value that arrives later. async/await is sugar over promises: each await hands control back to the event loop and resumes in a microtask. To wait for several things in parallel, start them all and wait together with Promise.all (fails on the first rejection) or Promise.allSettled (waits for all). An await inside a for turns independent calls into a queue.
  • Erro assíncrono não tratado derruba o processo. Desde o Node 15 uma promise rejeitada sem catch encerra o processo (modo --unhandled-rejections=throw). Vi isso acontecer no laboratório: um handler HTTP async lançou erro, ninguém capturou, e o servidor caiu. Todo ponto de entrada assíncrono (handler, job, listener) precisa de um catch que registra e responde.An unhandled async error kills the process. Since Node 15 a rejected promise with no catch ends the process (--unhandled-rejections=throw mode). I saw it happen in the lab: an async HTTP handler threw, nobody caught it, and the server went down. Every async entry point (handler, job, listener) needs a catch that logs and responds.
  • Closures guardam variáveis, não valores. Uma função lembra o escopo onde nasceu. É assim que funcionam debounce, caches e módulos, e também a origem de vazamentos de memória: um listener que nunca é removido mantém vivo tudo o que a sua closure enxerga.Closures keep variables, not values. A function remembers the scope it was born in. That is how debounce, caches and modules work, and also the origin of memory leaks: a listener that is never removed keeps alive everything its closure can see.
  • Números são double. 0.1 + 0.2 !== 0.3 e inteiros só são exatos até Number.MAX_SAFE_INTEGER (2⁵³ − 1). Dinheiro em centavos inteiros; IDs grandes de banco (bigint do Postgres) como string ou BigInt.Numbers are double. 0.1 + 0.2 !== 0.3 and integers are only exact up to Number.MAX_SAFE_INTEGER (2⁵³ − 1). Money in integer cents; large database IDs (Postgres bigint) as strings or BigInt.
  • Dois sistemas de módulos. ESM (import/export) é o padrão da linguagem e o que o navegador entende. CommonJS (require) é o formato original do Node. O Node decide pelo campo "type" do package.json ou pela extensão (.mjs, .cjs). Projeto novo: ESM. Atenção: o npm init do npm 11 grava "type": "commonjs", e meu primeiro import falhou por isso.Two module systems. ESM (import/export) is the language standard and what the browser understands. CommonJS (require) is Node's original format. Node decides by the "type" field in package.json or by the extension (.mjs, .cjs). New project: ESM. Watch out: npm 11's npm init writes "type": "commonjs", and my first import failed because of it.
  • Cancelamento é um padrão, não um detalhe. AbortController e AbortSignal.timeout(ms) funcionam no fetch, em streams, timers e várias APIs do Node. Toda chamada de rede precisa de timeout; sem ele, uma dependência lenta prende conexões até esgotar o servidor.Cancellation is a pattern, not a detail. AbortController and AbortSignal.timeout(ms) work with fetch, streams, timers and many Node APIs. Every network call needs a timeout; without one, a slow dependency holds connections until the server runs out.
// Sequencial sem querer: ~3 × latência
for (const id of ids) users.push(await fetchUser(id));

// Paralelo: ~1 × latência (e cada chamada com timeout)
const users = await Promise.all(
  ids.map((id) => fetch(`${API}/users/${id}`, { signal: AbortSignal.timeout(2_000) })
    .then((response) => response.json())),
);

Como o Node funciona: V8, libuv e o event loopHow Node works: V8, libuv and the event loop

O V8 é o motor JavaScript do Chrome: compila o código para instruções de máquina (com um compilador que otimiza as funções “quentes”), gerencia o heap e faz a coleta de lixo. Ele não sabe abrir arquivo nem socket. Quem sabe é o libuv, uma biblioteca em C que dá ao Node um event loop: um laço que pergunta ao sistema operacional “o que ficou pronto?” e chama os callbacks correspondentes. Rede usa as APIs assíncronas do próprio sistema (epoll no Linux). O que o sistema não oferece de forma assíncrona vai para um pool de threads (4 por padrão, ajustável com UV_THREADPOOL_SIZE): leitura de arquivos, dns.lookup, crypto.pbkdf2, scrypt e zlib.V8 is Chrome's JavaScript engine: it compiles code to machine instructions (with a compiler that optimises “hot” functions), manages the heap and collects garbage. It cannot open a file or a socket. libuv can: a C library that gives Node an event loop, a loop that asks the operating system “what is ready?” and calls the matching callbacks. Networking uses the system's own async APIs (epoll on Linux). What the system does not offer asynchronously goes to a thread pool (4 by default, tunable with UV_THREADPOOL_SIZE): file reads, dns.lookup, crypto.pbkdf2, scrypt and zlib.

timerssetTimeout, setIntervalpendingcallbacks de sistema adiadosdeferred system callbackspollespera e roda I/O prontowaits for and runs ready I/OchecksetImmediateclosesocket.on('close')Depois de cada callback: esvazia process.nextTick, depois as microtasks (promises, queueMicrotask)After each callback: drain process.nextTick, then microtasks (promises, queueMicrotask)Se essas filas nunca esvaziam, o loop nunca avançaIf these queues never empty, the loop never advances
As fases de uma volta do event loop. Entre um callback e o próximo, o Node esvazia as filas de microtasks: primeiro process.nextTick, depois as promises.The phases of one turn of the event loop. Between one callback and the next, Node drains the microtask queues: process.nextTick first, then promises.

O experimento da ordemThe ordering experiment

O clássico das entrevistas: em que ordem isto imprime? Rodei o mesmo arquivo como ESM (.mjs) e como CommonJS (.cjs), no Node 24 e no 26. O resultado tem uma pegadinha que a maioria dos artigos não menciona.The interview classic: in what order does this print? I ran the same file as ESM (.mjs) and as CommonJS (.cjs), on Node 24 and 26. The result has a catch most articles do not mention.

import fs from "node:fs";

console.log("1 sync");
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
queueMicrotask(() => console.log("queueMicrotask"));
fs.readFile(import.meta.filename, () => {
  setTimeout(() => console.log("io → timeout"), 0);
  setImmediate(() => console.log("io → immediate"));
});
console.log("2 sync");
PosiçãoPositionESM (.mjs)ESM (.mjs)CommonJS (.cjs)CommonJS (.cjs)
1–21 sync, 2 sync1 sync, 2 sync
3promisenextTick
4queueMicrotaskpromise
5nextTickqueueMicrotask
6–7timeout, immediatetimeout, immediate
8–9io → immediate, io → timeoutio → immediate, io → timeout
  • Código síncrono sempre termina antes. Nenhum callback interrompe uma função no meio.Synchronous code always finishes first. No callback interrupts a function halfway.
  • No ESM, as promises passaram na frente do nextTick. Um módulo ES é avaliado de forma assíncrona, como se já estivesse dentro de uma promise; quando ele termina, a fila de microtasks está sendo esvaziada e as promises novas entram nela antes de o Node olhar a fila do nextTick. No CommonJS, o script roda “fora” de uma microtask e o nextTick vem primeiro, como dizem os livros. Moral: não escreva código que dependa dessa ordem.In ESM, promises jumped ahead of nextTick. An ES module is evaluated asynchronously, as if already inside a promise; when it ends, the microtask queue is being drained and new promises join it before Node looks at the nextTick queue. In CommonJS, the script runs “outside” a microtask and nextTick comes first, as the books say. Moral: do not write code that depends on this order.
  • setTimeout(0) contra setImmediate no módulo principal não é garantido. A documentação diz que depende de quanto tempo o processo levou para entrar no loop. Dentro de um callback de I/O, porém, o setImmediate vence sempre: depois do poll vem o check, e os timers só na volta seguinte.setTimeout(0) versus setImmediate in the main module is not guaranteed. The docs say it depends on how long the process took to enter the loop. Inside an I/O callback, however, setImmediate always wins: after poll comes check, and timers only on the next turn.

Não bloqueie o event loopDo not block the event loop

Se uma thread executa todo o seu JavaScript, um cálculo de 1,5 segundo faz todos os outros usuários esperarem 1,5 segundo. Montei um servidor com três rotas: /health (responde na hora), /block (calcula fib(38) na thread principal) e /worker (o mesmo cálculo num worker_thread). E medi o atraso do event loop com monitorEventLoopDelay.If one thread runs all your JavaScript, a 1.5-second computation makes every other user wait 1.5 seconds. I built a server with three routes: /health (answers at once), /block (computes fib(38) on the main thread) and /worker (the same computation in a worker_thread). And I measured event loop delay with monitorEventLoopDelay.

import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { monitorEventLoopDelay } from "node:perf_hooks";
import { Worker } from "node:worker_threads";
import { extname } from "node:path";

// tsc reescreve imports, não URLs: usa a extensão deste arquivo (.ts ou .js)
const workerFile = new URL(`./fib-worker${extname(import.meta.filename)}`, import.meta.url);

const loopDelay = monitorEventLoopDelay({ resolution: 10 });
loopDelay.enable();

const fib = (n: number): number => (n < 2 ? n : fib(n - 1) + fib(n - 2));

function fibInWorker(n: number): Promise<number> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(workerFile, { workerData: n });
    worker.once("message", resolve);
    worker.once("error", reject);
  });
}

async function handle(req: IncomingMessage, res: ServerResponse) {
  const { pathname } = new URL(req.url ?? "/", "http://localhost");
  if (pathname === "/health") return res.end("ok\n");
  if (pathname === "/block") return res.end(`fib=${fib(38)}\n`);
  if (pathname === "/worker") return res.end(`fib=${await fibInWorker(38)}\n`);
  res.statusCode = 404;
  res.end();
}

// Um erro num handler async vira 500 e log, em vez de derrubar o processo.
const server = createServer((req, res) => {
  handle(req, res).catch((error) => {
    console.error(error);
    res.statusCode = 500;
    res.end();
  });
});

server.listen(Number(process.env.PORT ?? 3000));

process.on("SIGTERM", () => server.close(() => process.exit(0)));
// fib-worker.ts: roda em outra thread, com outro event loop e outro heap
import { parentPort, workerData } from "node:worker_threads";
const fib = (n: number): number => (n < 2 ? n : fib(n - 1) + fib(n - 2));
parentPort?.postMessage(fib(workerData as number));
MediçãoMeasurementCálculo na thread principalComputation on the main threadCálculo num workerComputation in a worker
Tempo de fib(38)Time of fib(38)1,50 sigual (a CPU é a mesma)same (same CPU)
/health chamado durante o cálculo/health called during the computation1,36 s0,01 s
Atraso máximo do event loopMax event loop delay1476 ms10,9 ms
Sem workerNo workerfib(38) · 1,5 schega /health/health arrivesresponde (1,36 s)answers (1.36 s)Com workerWith workerworker: fib(38) em outra threadworker: fib(38) on another thread/health responde em 10 ms/health answers in 10 ms
Na thread principal, o pedido de /health chega no meio do cálculo e só é atendido quando ele acaba. Com o worker, o cálculo roda ao lado e o loop continua livre.On the main thread, the /health request arrives mid-computation and is only served when it ends. With a worker, the computation runs alongside and the loop stays free.
  • O que bloqueia na prática: JSON.parse/stringify de payloads enormes, regex com backtracking catastrófico (o ataque ReDoS), laços sobre milhões de itens, criptografia e compressão síncronas (*Sync), e qualquer fs.*Sync dentro de um handler.What blocks in practice: JSON.parse/stringify of huge payloads, regexes with catastrophic backtracking (the ReDoS attack), loops over millions of items, synchronous crypto and compression (*Sync), and any fs.*Sync inside a handler.
  • As saídas: mover para um worker_thread (use um pool, como o Piscina, em vez de criar um worker por pedido), quebrar o trabalho em pedaços que cedem a vez (await setImmediate() de node:timers/promises), ou tirar o trabalho do processo (fila + outro serviço).The ways out: move it to a worker_thread (use a pool, such as Piscina, instead of creating a worker per request), split the work into chunks that yield (await setImmediate() from node:timers/promises), or move the work out of the process (queue + another service).
  • Workers não são para I/O. Esperar banco ou rede já é concorrente no event loop. Worker serve para CPU. Para usar vários núcleos com HTTP, rode vários processos (réplicas no Kubernetes, tarefas no ECS, ou node:cluster).Workers are not for I/O. Waiting on a database or network is already concurrent on the event loop. Workers are for CPU. To use several cores for HTTP, run several processes (Kubernetes replicas, ECS tasks, or node:cluster).

Streams e backpressureStreams and backpressure

Stream é processar dados em pedaços enquanto eles chegam, em vez de carregar tudo na memória. É o que permite a um servidor com 512 MB de RAM servir um arquivo de 10 GB. Backpressure é o mecanismo que faz o produtor esperar quando o consumidor está lento: sem ele, os pedaços se acumulam na memória até o processo morrer.A stream processes data in chunks as it arrives, instead of loading everything into memory. It is what lets a server with 512 MB of RAM serve a 10 GB file. Backpressure is the mechanism that makes the producer wait when the consumer is slow: without it, chunks pile up in memory until the process dies.

createReadStreamorigem (Readable)source (Readable)Transformpedaço a pedaçochunk by chunkcreateWriteStreamdestino (Writable)destination (Writable)- - - “espera”: o buffer de destino está cheio (backpressure)- - - “wait”: the destination buffer is full (backpressure)
pipeline() liga as pontas e propaga a backpressure: quando o destino enche o buffer, a origem para de ler. Também propaga erros e fecha tudo no fim.pipeline() connects the ends and propagates backpressure: when the destination fills its buffer, the source stops reading. It also propagates errors and closes everything at the end.
import { createReadStream, createWriteStream } from "node:fs";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";

const upperCase = new Transform({
  transform(chunk, _encoding, callback) {
    callback(null, chunk.toString().toUpperCase());
  },
});

await pipeline(createReadStream("big.txt"), upperCase, createWriteStream("out.txt"));
ArquivoFilereadFile + writeFile (tudo na memória)readFile + writeFile (all in memory)pipeline (streams)pipeline (streams)
200 MBpico de 868 MB de RAM, 1,6 s868 MB peak RAM, 1.6 spico de 75 MB de RAM, 3,6 s75 MB peak RAM, 3.6 s
674 MBfalhou: RangeError: Invalid string lengthfailed: RangeError: Invalid string lengthpico de 78 MB, terminou78 MB peak, finished

Duas lições. A memória do stream ficou constante independentemente do tamanho; a do readFile cresceu mais de 4 vezes o arquivo (texto, cópia em maiúsculas, buffers). E acima de cerca de 512 milhões de caracteres o V8 nem consegue criar a string (buffer.constants.MAX_STRING_LENGTH é 536.870.888 no Node 24). O stream foi mais lento aqui porque converte pedaço a pedaço, um preço pequeno por memória previsível. Use sempre pipeline (e não .pipe()), porque ele trata erros e fecha as pontas. Requisições e respostas HTTP, fetch().body, gzip e cursores de banco são todos streams.Two lessons. The stream's memory stayed constant regardless of size; readFile's grew to more than 4 times the file (text, upper-cased copy, buffers). And above roughly 512 million characters V8 cannot even create the string (buffer.constants.MAX_STRING_LENGTH is 536,870,888 on Node 24). The stream was slower here because it converts chunk by chunk, a small price for predictable memory. Always use pipeline (not .pipe()), because it handles errors and closes both ends. HTTP requests and responses, fetch().body, gzip and database cursors are all streams.

TypeScript no back e no frontTypeScript on the back and the front

TypeScript é JavaScript com tipos que somem na execução. Serve para o editor e o compilador acharem erros antes de rodar. Há três formas de executá-lo, e confundir as três é a origem de metade das dúvidas. A nota Java, Python e TypeScript na prática detalha tsconfig e npm; aqui fica o que muda para quem vive nisso.TypeScript is JavaScript with types that vanish at runtime. It lets the editor and the compiler find errors before running. There are three ways to execute it, and mixing them up causes half of the confusion. The note Java, Python and TypeScript in practice covers tsconfig and npm in detail; here is what changes for someone who lives in it.

OndeWhereQuem transforma TS em JSWho turns TS into JSQuem confere os tiposWho checks types
Node em desenvolvimentoNode in developmento próprio Node (apaga os tipos ao carregar .ts)Node itself (strips types when loading .ts)tsc --noEmit e o editorand the editor
Node em produçãoNode in productiontsc → dist/*.jstsc
NavegadorBrowsero bundler (Vite), que apaga os tipos sem checá-losthe bundler (Vite), which strips types without checking themtsc --noEmit no CIin CI
{
  "compilerOptions": {
    "target": "es2024",
    // segue as regras reais de ESM/CJS do Node
    "module": "nodenext",
    // sem isto, TypeScript é só autocomplete
    "strict": true,
    // arr[i] é T | undefined, como de fato é
    "noUncheckedIndexedAccess": true,
    // proíbe enum/namespace, que o Node não sabe apagar
    "erasableSyntaxOnly": true,
    // exige "import type" para tipos
    "verbatimModuleSyntax": true,
    // import "./x.ts" vira "./x.js" no dist
    "rewriteRelativeImportExtensions": true,
    "outDir": "dist"
  }
}
  • Tipos não validam dados de fora. req.body as User é uma promessa sua ao compilador, não uma checagem. Tudo o que cruza uma fronteira (corpo HTTP, variável de ambiente, resposta de API, linha do banco, localStorage) deve ser validado em runtime com um schema (zod, valibot, ArkType) que também gera o tipo. Um schema, uma fonte de verdade.Types do not validate outside data. req.body as User is a promise you make to the compiler, not a check. Everything that crosses a boundary (HTTP body, environment variable, API response, database row, localStorage) should be validated at runtime with a schema (zod, valibot, ArkType) that also produces the type. One schema, one source of truth.
  • Front e back compartilham tipos, não código de servidor. Num monorepo (pnpm workspaces), um pacote contracts com os schemas é importado pelos dois lados. Assim, mudar um campo da API quebra o build do front, não o usuário. tRPC, OpenAPI com geração de tipos e GraphQL codegen são variações da mesma ideia.Front and back share types, not server code. In a monorepo (pnpm workspaces), a contracts package with the schemas is imported by both sides. Changing an API field then breaks the front-end build, not the user. tRPC, OpenAPI with type generation and GraphQL codegen are variations of the same idea.
A pegadinha que eu caí: URLs não são reescritas. Com rewriteRelativeImportExtensions, o tsc troca import "./x.ts" por "./x.js". Mas o worker era carregado por new URL("./fib-worker.ts", import.meta.url), que é só uma string. Em desenvolvimento funcionou; na imagem Docker, compilada, o worker falhou com Cannot find module '/app/dist/fib-worker.ts', a promise rejeitou sem tratamento e o servidor caiu. A correção está no código acima: escolher a extensão a partir do próprio arquivo. Teste sempre o artefato compilado, não só o .ts.The trap I fell into: URLs are not rewritten. With rewriteRelativeImportExtensions, tsc changes import "./x.ts" to "./x.js". But the worker was loaded through new URL("./fib-worker.ts", import.meta.url), which is just a string. In development it worked; in the compiled Docker image the worker failed with Cannot find module '/app/dist/fib-worker.ts', the promise rejected unhandled and the server went down. The fix is in the code above: pick the extension from the file itself. Always test the compiled artifact, not only the .ts.

Ferramentas do dia a diaEveryday tools

NecessidadeNeedEscolha comumCommon choicePor quê / atençãoWhy / watch out
Versão do NodeNode versionfnm, nvm, mise + arquivo+ file .nvmrctodos usam a mesma; o campo engines documenta o mínimoeveryone uses the same one; the engines field documents the minimum
PacotesPackagespnpm ouor npmpnpm economiza disco e é estrito com dependências não declaradas; em CI use pnpm install --frozen-lockfile / npm cipnpm saves disk and is strict about undeclared dependencies; in CI use pnpm install --frozen-lockfile / npm ci
Versão do gerenciadorManager versionpackageManager noin package.jsona imagem do Node 26 não traz mais o corepack (conferi: which corepack veio vazio); instale-o ou use a action do pnpmthe Node 26 image no longer ships corepack (I checked: which corepack was empty); install it or use the pnpm action
Rodar em devRun in devnode --watch, node --env-filesem nodemon nem dotenv: já vem no Nodeno nodemon or dotenv: built into Node
Lint e formataçãoLint and formatESLint + typescript-eslint, Prettier; ouor Biomea regra no-floating-promises pega promise esquecida, a causa do processo que caithe no-floating-promises rule catches forgotten promises, the cause of crashed processes
HTTP no backHTTP on the backFastify, Hono, Express, NestJSFastify valida e serializa por schema; Hono roda também em edge; Nest dá estrutura para times grandesFastify validates and serialises by schema; Hono also runs on the edge; Nest gives structure to large teams
BancoDatabasePrisma, Drizzle, Kysely, pgpool de conexões tem tamanho; veja Postgres para devsconnection pools have a size; see Postgres for devs
Bundler / frontBundler / front endVite, Next.js, React RouterVite para SPA e sites estáticos; frameworks quando precisa de SSRVite for SPAs and static sites; frameworks when you need SSR

Outros runtimes existem: Deno e Bun rodam TypeScript direto e trazem ferramentas embutidas, e o Cloudflare Workers roda JavaScript na borda. Vale conhecê-los, mas o mercado e as bibliotecas ainda giram em torno do Node, e o próprio Node absorveu boa parte do que os tornava atraentes (TypeScript nativo, --watch, --env-file, test runner, fetch).Other runtimes exist: Deno and Bun run TypeScript directly and bundle tools, and Cloudflare Workers runs JavaScript at the edge. They are worth knowing, but the market and the libraries still revolve around Node, and Node itself absorbed much of what made them attractive (native TypeScript, --watch, --env-file, a test runner, fetch).

Configuração e variáveis de ambienteConfiguration and environment variables

A regra é a do Twelve-Factor App: o que muda entre ambientes (URL do banco, chaves, nível de log) vive no ambiente, não no código. O Node lê as variáveis em process.env, sempre como texto. O arquivo .env não é lido sozinho: você liga com --env-file (ou process.loadEnvFile() no código). Uma variável já definida no ambiente vence o .env, o que permite ao servidor sobrescrever sem tocar no arquivo.The rule is the Twelve-Factor App's: what changes between environments (database URL, keys, log level) lives in the environment, not in code. Node reads variables from process.env, always as text. The .env file is not read by itself: you turn it on with --env-file (or process.loadEnvFile() in code). A variable already set in the environment beats the .env, which lets the server override without touching the file.

// src/env.ts: valida uma vez, na partida.
// O resto do app importa `env`, nunca process.env.
import { z } from "zod";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.url(),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});

const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
  console.error("Invalid environment:", z.prettifyError(parsed.error));
  process.exit(1);
}
export const env = parsed.data;
Como rodeiHow I ran itResultadoResult
node src/print-env.tssaiu com código 1: ✖ Invalid input: expected string, received undefined → at DATABASE_URLexited with code 1: ✖ Invalid input: expected string, received undefined → at DATABASE_URL
node --env-file=.env src/print-env.tsPORT: 8080 (do .env), já como numberPORT: 8080 (from .env), already a number
PORT=9090 node --env-file=.env …PORT: 9090: o ambiente venceu o .envPORT: 9090: the environment beat the .env
  • NODE_ENV não é “o ambiente”. Bibliotecas (React, Express) leem NODE_ENV=production para desligar checagens e ficar mais rápidas. Use-o só para isso, e defina em produção. Para “staging contra produção”, use outra variável (APP_ENV) ou, melhor, valores diferentes para as mesmas chaves.NODE_ENV is not “the environment”. Libraries (React, Express) read NODE_ENV=production to turn off checks and run faster. Use it only for that, and set it in production. For “staging versus production”, use another variable (APP_ENV) or, better, different values for the same keys.
  • Segredos de produção não moram em arquivo no repositório. .env no .gitignore e no .dockerignore, um .env.example versionado, e em produção os valores vêm do orquestrador ou de um cofre (Secrets Manager, Parameter Store, secrets do Kubernetes, secrets do GitHub). Nunca registre env inteiro no log.Production secrets do not live in a file in the repository. .env in .gitignore and .dockerignore, a committed .env.example, and in production the values come from the orchestrator or a vault (Secrets Manager, Parameter Store, Kubernetes secrets, GitHub secrets). Never log the whole env.

O lado do frontThe front-end side

No navegador a linguagem é a mesma, mas o runtime é outro. Também há uma thread principal com um event loop, só que ela divide o tempo com o desenho da tela: entre tarefas, o navegador calcula estilos, layout e pinta. Um JavaScript que ocupa a thread por 200 ms congela cliques e rolagem por 200 ms. O mesmo princípio do servidor (não bloqueie o loop) vira aqui uma métrica de experiência.In the browser the language is the same but the runtime is different. There is also a main thread with an event loop, but it shares time with drawing the screen: between tasks, the browser computes styles, layout and paints. JavaScript that holds the thread for 200 ms freezes clicks and scrolling for 200 ms. The server's principle (do not block the loop) becomes a user-experience metric here.

src/*.ts+ contracts (zod)+ contracts (zod)tsc → dist/servidorservervite buildbundle com hash, minificadohashed, minified bundlenode dist/server.jsenv lido em runtimeenv read at runtimenavegador do usuáriouser's browserenv embutido no buildenv baked in at build
O mesmo TypeScript segue dois caminhos. No servidor ele vira um processo que responde pedidos; no navegador vira um bundle que precisa ser pequeno, porque cada byte é baixado e executado no aparelho do usuário.The same TypeScript takes two paths. On the server it becomes a process that answers requests; in the browser it becomes a bundle that must be small, because every byte is downloaded and executed on the user's device.
  • Variável de ambiente no front é pública. No Vite, só as variáveis com prefixo VITE_ chegam ao código, por import.meta.env, e são substituídas no bundle no momento do build (no Next.js, o prefixo é NEXT_PUBLIC_). Qualquer pessoa lê no DevTools. Chave de API secreta nunca vai para o front; o front chama o seu back, que tem a chave. E trocar a variável exige um novo build, não um restart.Front-end environment variables are public. In Vite, only variables prefixed with VITE_ reach the code, through import.meta.env, and they are replaced in the bundle at build time (in Next.js the prefix is NEXT_PUBLIC_). Anyone can read them in DevTools. A secret API key never goes to the front end; the front end calls your back end, which holds the key. And changing the variable needs a new build, not a restart.
  • Renderização: onde o HTML nasce. SPA (o navegador monta tudo), SSR (o servidor Node gera o HTML a cada pedido), SSG (gera no build, como este site) e hidratação (o React “acorda” o HTML pronto, anexando eventos). Cada escolha muda custo de servidor, tempo até o conteúdo e SEO.Rendering: where the HTML is born. SPA (the browser builds everything), SSR (the Node server generates HTML per request), SSG (generated at build, like this site) and hydration (React “wakes up” the ready HTML, attaching events). Each choice changes server cost, time to content and SEO.
  • Meça o que o usuário sente: Core Web Vitals. LCP (maior conteúdo visível, bom até 2,5 s), INP (resposta a interações, bom até 200 ms) e CLS (pulos de layout, bom até 0,1), segundo o web.dev. Lighthouse dá a medida de laboratório; a biblioteca web-vitals mede usuários reais. INP ruim quase sempre é JavaScript longo na thread principal.Measure what the user feels: Core Web Vitals. LCP (largest visible content, good up to 2.5 s), INP (response to interactions, good up to 200 ms) and CLS (layout shifts, good up to 0.1), according to web.dev. Lighthouse gives the lab measurement; the web-vitals library measures real users. Poor INP is almost always long JavaScript on the main thread.
  • Estado e dados. Separe estado do servidor (cache de requisições: TanStack Query, SWR) de estado da interface. A nota Estado no React trata disso.State and data. Keep server state (request cache: TanStack Query, SWR) apart from UI state. The note State in React covers this.

TestesTests

E2EE2EIntegraçãoIntegrationUnidadeUnitPlaywrightfetch +Testcontainersnode:testVitest
A pirâmide: muitos testes rápidos embaixo, poucos lentos e caros em cima. Em APIs Node, a camada de integração (HTTP real + banco real em contêiner) costuma dar o melhor retorno.The pyramid: many fast tests at the bottom, few slow and expensive ones at the top. For Node APIs, the integration layer (real HTTP + real database in a container) usually gives the best return.

O Node tem test runner próprio: node:test com node:assert, mocks de função e de tempo, cobertura e modo watch, sem instalar nada. Ele roda arquivos .ts direto. Este é o teste de um debounce usando os mock timers do Erick Wendel: o tempo avança na hora, sem esperar 300 ms de verdade.Node has its own test runner: node:test with node:assert, function and time mocks, coverage and watch mode, with nothing to install. It runs .ts files directly. This is the test of a debounce using Erick Wendel's mock timers: time advances instantly, without really waiting 300 ms.

// src/debounce.ts
export function debounce<A extends unknown[]>(fn: (...args: A) => void, waitMs: number) {
  let timer: ReturnType<typeof setTimeout> | undefined;
  return (...args: A) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), waitMs);
  };
}

// test/debounce.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { debounce } from "../src/debounce.ts";

test("only the last call within the window runs", (t) => {
  t.mock.timers.enable({ apis: ["setTimeout"] });
  const save = t.mock.fn();
  const debouncedSave = debounce(save, 300);

  debouncedSave("a");
  debouncedSave("b");
  t.mock.timers.tick(299);
  assert.equal(save.mock.callCount(), 0);

  t.mock.timers.tick(1);
  assert.equal(save.mock.callCount(), 1);
  assert.deepEqual(save.mock.calls[0].arguments, ["b"]);
});
$ node --test
✔ only the last call within the window runs (6.06ms)
ℹ tests 1  ℹ pass 1  ℹ fail 0

$ node --test --experimental-test-coverage
ℹ file         | line % | branch % | funcs %
ℹ  debounce.ts | 100.00 |   100.00 |  100.00
FerramentaToolQuando usarWhen to use
node:testback end e bibliotecas; zero dependência, roda TS diretoback end and libraries; zero dependencies, runs TS directly
Vitestfront com Vite (mesma configuração e transformações), ambiente jsdom/happy-dom, snapshot, modo navegadorfront end with Vite (same config and transforms), jsdom/happy-dom environment, snapshots, browser mode
Testing Librarytestar componentes pelo que o usuário vê e faz, não pela implementaçãotest components by what the user sees and does, not by implementation
Playwrightponta a ponta em Chromium, Firefox e WebKit; poucos testes, fluxos críticos (login, compra)end to end in Chromium, Firefox and WebKit; few tests, critical flows (login, checkout)
Testcontainerssobe Postgres, Redis etc. reais em Docker durante o teste de integraçãostarts real Postgres, Redis etc. in Docker during the integration test
MSWintercepta fetch no front e no Node para simular APIs externasintercepts fetch on the front and in Node to fake external APIs
Teste de integração de API sem biblioteca. Suba o servidor com listen(0) (porta livre aleatória), leia a porta em server.address(), chame com fetch e feche no after(). O fetch nativo dispensa o supertest, e você testa o mesmo caminho que o cliente usa.API integration test without a library. Start the server with listen(0) (random free port), read the port from server.address(), call it with fetch and close it in after(). Native fetch removes the need for supertest, and you test the same path the client uses.

Produção: Docker, processos e desligamentoProduction: Docker, processes and shutdown

Em produção, o app Node quase sempre roda num contêiner. O Dockerfile tem dois estágios: um compila com todas as dependências; o outro leva só o dist/ e as dependências de produção, e roda como usuário sem privilégio. A nota Docker explica camadas e cache.In production, a Node app almost always runs in a container. The Dockerfile has two stages: one compiles with all dependencies; the other takes only dist/ and production dependencies, and runs as an unprivileged user. The Docker note explains layers and caching.

FROM node:26-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npx tsc

FROM node:26-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Sinais e desligamento graciosoSignals and graceful shutdown

Quando o orquestrador vai trocar uma versão, ele manda SIGTERM, espera um prazo (10 s no docker stop, 30 s no Kubernetes) e depois mata com SIGKILL. O app deveria parar de aceitar conexões, terminar as em andamento, fechar o pool do banco e sair. Medi três casos:When the orchestrator replaces a version, it sends SIGTERM, waits a grace period (10 s for docker stop, 30 s on Kubernetes) and then kills with SIGKILL. The app should stop accepting connections, finish those in flight, close the database pool and exit. I measured three cases:

CasoCaseTempo do docker stopdocker stop timeCódigo de saídaExit code
Node como PID 1, sem handler de SIGTERMNode as PID 1, without a SIGTERM handler10,29 s137 (morto por SIGKILL)137 (killed by SIGKILL)
Mesmo app com docker run --initSame app with docker run --init0,24 s143 (encerrado por SIGTERM)143 (ended by SIGTERM)
Node como PID 1, com handler que fecha o servidorNode as PID 1, with a handler that closes the server0,24 s0 (saída limpa)0 (clean exit)

Por quê: o processo com PID 1 no Linux não recebe o comportamento padrão de sinais. Sem handler, o Node ignora o SIGTERM e só morre no SIGKILL, 10 segundos depois e sem fechar nada. Isso torna cada deploy 10 s mais lento e corta requisições no meio. Escreva o handler e rode node direto no CMD (não npm start, que acrescenta um processo intermediário).Why: the process with PID 1 on Linux does not get default signal behaviour. Without a handler, Node ignores SIGTERM and only dies on SIGKILL, 10 seconds later and without closing anything. That makes every deploy 10 s slower and cuts requests mid-way. Write the handler and run node directly in CMD (not npm start, which adds an intermediate process).

async function shutdown(signal: string) {
  logger.info({ signal }, "shutting down");
  server.close();                       // para de aceitar conexões novas
  server.closeIdleConnections();        // fecha keep-alives ociosos
  await db.end();                       // fecha o pool do banco
  process.exit(0);
}
process.once("SIGTERM", () => void shutdown("SIGTERM"));
process.once("SIGINT", () => void shutdown("SIGINT"));
  • Health checks separados. Liveness responde se o processo está vivo (não toca no banco); readiness responde se ele pode receber tráfego (pode checar dependências e passa a falhar durante o desligamento). Confundir os dois faz o orquestrador reiniciar tudo quando o banco oscila.Separate health checks. Liveness answers whether the process is alive (does not touch the database); readiness answers whether it can take traffic (may check dependencies and starts failing during shutdown). Mixing them makes the orchestrator restart everything when the database blips.
  • Memória. O heap do V8 tem limite próprio, separado do limite do contêiner. Defina --max-old-space-size (em MB) abaixo da memória do contêiner, deixando margem para buffers e o que fica fora do heap: assim o V8 coleta lixo com mais vontade antes de o kernel matar o processo por falta de memória.Memory. V8's heap has its own limit, separate from the container's. Set --max-old-space-size (in MB) below the container memory, leaving headroom for buffers and what lives off-heap: V8 then collects garbage harder before the kernel kills the process for running out of memory.
  • Um processo por contêiner. Para usar mais núcleos, escale réplicas. PM2 e cluster fazem sentido numa VM sem orquestrador, não dentro do Kubernetes.One process per container. To use more cores, scale replicas. PM2 and cluster make sense on a VM without an orchestrator, not inside Kubernetes.
  • Logs no stdout. O app escreve JSON no stdout; quem coleta, roteia e guarda é a plataforma. Nada de arquivos de log dentro do contêiner.Logs to stdout. The app writes JSON to stdout; the platform collects, routes and stores it. No log files inside the container.

CI/CD e a cadeia de dependênciasCI/CD and the dependency chain

install--frozenlinteslinttypechecktsctestnode --testbuildimage:shadeploysó na mainmain onlyem todo pull request: segundos a poucos minutoson every pull request: seconds to a few minutesno merge na mainon merge to main
Cada etapa barra um tipo de erro antes do seguinte. As rápidas vêm primeiro; o deploy só acontece na branch principal, com a imagem que passou em tudo.Each stage stops one kind of error before the next. The fast ones come first; deploy only happens on the main branch, with the image that passed everything.
# .github/workflows/ci.yml
name: CI
on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: pnpm/action-setup@v6          # usa a versão de "packageManager"
      - uses: actions/setup-node@v7
        with:
          node-version-file: .nvmrc
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm typecheck
      - run: pnpm test
      - run: pnpm build
  • Uma imagem, vários ambientes. Construa a imagem uma vez, marque com o SHA do commit, e promova a mesma imagem de staging para produção mudando só as variáveis de ambiente. Reconstruir por ambiente é testar uma coisa e publicar outra.One image, several environments. Build the image once, tag it with the commit SHA, and promote the same image from staging to production changing only environment variables. Rebuilding per environment means testing one thing and shipping another.
  • Credenciais de nuvem sem chave guardada. Use OIDC do GitHub para assumir um papel na AWS durante o job; veja GitHub Actions com AWS via OIDC.Cloud credentials without a stored key. Use GitHub OIDC to assume an AWS role during the job; see GitHub Actions to AWS via OIDC.
  • A cadeia de dependências é superfície de ataque. Um projeto típico puxa centenas de pacotes. Versione o lockfile e instale sempre com ele (npm ci, --frozen-lockfile); desde o pnpm 10, scripts de instalação de dependências ficam bloqueados até você liberar pacote a pacote; Dependabot ou Renovate para atualizar em PRs pequenos; npm audit/pnpm audit no CI; e prefira poucas dependências bem mantidas. O modelo de permissões (--permission) restringe o que o processo pode ler, escrever e executar.The dependency chain is attack surface. A typical project pulls hundreds of packages. Commit the lockfile and always install from it (npm ci, --frozen-lockfile); since pnpm 10, dependency install scripts are blocked until you allow them package by package; Dependabot or Renovate to update in small PRs; npm audit/pnpm audit in CI; and prefer few, well-maintained dependencies. The permission model (--permission) restricts what the process can read, write and execute.

Observabilidade em NodeObservability in Node

Os conceitos (métricas, logs, traces, percentis, Prometheus e Grafana) estão na nota Observabilidade. O que é específico de Node:The concepts (metrics, logs, traces, percentiles, Prometheus and Grafana) are in the Observability note. What is specific to Node:

  • Logs estruturados com pino. Uma linha JSON por evento, com nível, mensagem e campos. Ele é rápido porque formata pouco no processo; para ler bonito em dev, use pino-pretty no pipe, não no código.Structured logs with pino. One JSON line per event, with level, message and fields. It is fast because it formats little in-process; for pretty output in dev, use pino-pretty on the pipe, not in code.
  • Contexto por requisição com AsyncLocalStorage. Como não há uma thread por requisição, não dá para guardar o “request ID atual” numa variável global. O AsyncLocalStorage (de node:async_hooks) carrega um valor através de todos os await daquela requisição, e o logger o inclui em cada linha. É também o que o OpenTelemetry usa por baixo para saber a qual trace cada chamada pertence.Per-request context with AsyncLocalStorage. Since there is no thread per request, you cannot keep the “current request ID” in a global variable. AsyncLocalStorage (from node:async_hooks) carries a value through every await of that request, and the logger adds it to each line. It is also what OpenTelemetry uses underneath to know which trace each call belongs to.
  • Traces sem mexer no código. node --import @opentelemetry/auto-instrumentations-node/register dist/server.js instrumenta HTTP, pg, Redis, Express, Fastify e outros, e exporta para um coletor OTLP definido por variáveis OTEL_*.Traces without touching code. node --import @opentelemetry/auto-instrumentations-node/register dist/server.js instruments HTTP, pg, Redis, Express, Fastify and others, and exports to an OTLP collector set by OTEL_* variables.
  • A métrica que só o Node tem: atraso do event loop. monitorEventLoopDelay (usado no experimento) ou prom-client, que já exporta nodejs_eventloop_lag_seconds, heap usado e pausas do GC. Atraso alto com CPU baixa costuma ser código síncrono; atraso alto com heap crescendo é vazamento e GC lutando.The metric only Node has: event loop delay. monitorEventLoopDelay (used in the experiment) or prom-client, which already exports nodejs_eventloop_lag_seconds, heap used and GC pauses. High delay with low CPU is usually synchronous code; high delay with a growing heap is a leak and the GC struggling.
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import pino from "pino";

const requestContext = new AsyncLocalStorage<{ requestId: string }>();

export const logger = pino({
  level: env.LOG_LEVEL,
  mixin: () => requestContext.getStore() ?? {},   // todo log ganha o requestId
  redact: ["req.headers.authorization", "*.password"],
});

createServer((req, res) => {
  const requestId = req.headers["x-request-id"]?.toString() ?? randomUUID();
  requestContext.run({ requestId }, () => handle(req, res).catch(/* … */));
});

Performance, profiling e teste de cargaPerformance, profiling and load testing

Otimizar sem medir é chutar. O ciclo é: carga para reproduzir o problema, profile para achar onde o tempo vai, correção, e carga de novo para confirmar. Medi o servidor do experimento com o autocannon (50 conexões por 5 s):Optimising without measuring is guessing. The cycle is: load to reproduce the problem, profile to find where the time goes, fix, and load again to confirm. I measured the experiment's server with autocannon (50 connections for 5 s):

$ npx autocannon -c 50 -d 5 http://localhost:3000/health
CenárioScenarioRequisições em 5 sRequests in 5 sLatência médiaAvg latencyp99MáximaMax
Só /healthOnly /health49 mil4,8 ms15 ms152 ms
/health + 3 chamadas a /block ao mesmo tempo/health + 3 /block calls at the same time16 mil15,4 ms37 ms1443 ms

Três chamadas que bloqueiam derrubaram a vazão de todo o servidor em dois terços e levaram a latência máxima a 1,4 s, enquanto o p99 continuou parecendo razoável (37 ms). Por isso se olha a distribuição (p50, p99, máxima) e não a média, e por isso o atraso do event loop é a primeira métrica a checar.Three blocking calls cut the whole server's throughput by two thirds and took max latency to 1.4 s, while p99 still looked reasonable (37 ms). That is why you look at the distribution (p50, p99, max) and not the average, and why event loop delay is the first metric to check.

PerguntaQuestionFerramentaTool
Onde a CPU vai?Where does CPU go?node --cpu-prof gera um .cpuprofile; abra na aba Performance do Chrome DevTools e veja o flame graph. Ou node --inspect e conecte em chrome://inspect.node --cpu-prof writes a .cpuprofile; open it in Chrome DevTools' Performance tab and read the flame graph. Or node --inspect and connect at chrome://inspect.
A memória só cresce?Does memory only grow?dois heap snapshots (DevTools, ou v8.writeHeapSnapshot()) separados por carga, e compare o que sobrou; --heap-prof para alocaçãotwo heap snapshots (DevTools, or v8.writeHeapSnapshot()) separated by load, and compare what stayed; --heap-prof for allocation
Aguenta quanto?How much can it take?autocannon (rápido, local) ou k6 (cenários em JavaScript, rampas, limites que reprovam o CI)autocannon (quick, local) or k6 (scenarios in JavaScript, ramps, thresholds that fail CI)
Função A ou B?Function A or B?micro-benchmark com performance.now() em muitas iterações, ou mitata/tinybench; desconfie de diferenças pequenasmicro-benchmark with performance.now() over many iterations, or mitata/tinybench; distrust small differences
// load.js: k6 com rampa e limites (falha se p95 > 200 ms ou erros > 1%)
import http from "k6/http";
import { check } from "k6";

export const options = {
  stages: [
    { duration: "30s", target: 50 },   // sobe até 50 usuários virtuais
    { duration: "1m", target: 50 },    // mantém
    { duration: "15s", target: 0 },    // desce
  ],
  thresholds: {
    http_req_duration: ["p(95)<200"],
    http_req_failed: ["rate<0.01"],
  },
};

export default function () {
  const response = http.get("http://localhost:3000/health");
  check(response, { "status 200": (r) => r.status === 200 });
}
  • Teste de carga, de estresse e de resistência são coisas diferentes. Carga confirma que o sistema aguenta o tráfego esperado; estresse sobe até quebrar, para saber como quebra (erro rápido é melhor que fila infinita); resistência (soak) mantém carga por horas para revelar vazamentos.Load, stress and soak tests are different things. Load confirms the system handles the expected traffic; stress pushes until it breaks, to learn how it breaks (failing fast beats an infinite queue); soak holds load for hours to reveal leaks.
  • Os ganhos grandes raramente estão no JavaScript. Em APIs, quase sempre o gargalo é a consulta sem índice, o N+1 do ORM, a falta de cache ou o pool de conexões pequeno. Profile antes de reescrever laços.The big wins are rarely in the JavaScript. In APIs, the bottleneck is almost always the unindexed query, the ORM's N+1, missing caching or a small connection pool. Profile before rewriting loops.

Trilha de estudoStudy path

Uma ordem que funciona, do fundamento à operação. Cada etapa tem um exercício que prova que você entendeu, no espírito do Erick Wendel: construir para entender.An order that works, from foundation to operation. Each step has an exercise that proves you understood, in Erick Wendel's spirit: build to understand.

  1. Linguagem e assincronia. Promises, async/await, erros, closures, módulos. Exercício: implemente Promise.all e um retry com backoff à mão.Language and asynchrony. Promises, async/await, errors, closures, modules. Exercise: implement Promise.all and a retry with backoff by hand.
  2. Runtime. Event loop, microtasks, thread pool, streams, workers. Exercício: reproduza a tabela de ordem e o experimento de bloqueio desta nota; depois leia o runtime feito do zero do Erick.Runtime. Event loop, microtasks, thread pool, streams, workers. Exercise: reproduce this note's ordering table and blocking experiment; then read Erick's runtime built from scratch.
  3. HTTP sem framework. Um servidor só com node:http: rotas, corpo JSON validado, streams, erros, timeouts. Depois Fastify, e você vai saber o que ele faz por você.HTTP without a framework. A server with only node:http: routes, validated JSON body, streams, errors, timeouts. Then Fastify, and you will know what it does for you.
  4. TypeScript a sério. strict, genéricos, tipos de união discriminada, schemas compartilhados entre front e back num monorepo.TypeScript for real. strict, generics, discriminated unions, schemas shared between front and back in a monorepo.
  5. Testes. node:test com mocks de tempo, integração com banco real em contêiner, alguns E2E com Playwright.Tests. node:test with time mocks, integration with a real database in a container, a few Playwright E2E tests.
  6. Front. Vite + React, um formulário que consome a sua API com os tipos compartilhados, Lighthouse e Web Vitals medidos.Front end. Vite + React, a form that consumes your API with the shared types, Lighthouse and Web Vitals measured.
  7. Entrega e operação. Dockerfile multiestágio, desligamento gracioso, pipeline no GitHub Actions, logs com pino, traces com OpenTelemetry, um teste k6 com limites no CI.Delivery and operation. Multi-stage Dockerfile, graceful shutdown, a GitHub Actions pipeline, pino logs, OpenTelemetry traces, a k6 test with thresholds in CI.

Cola e revisãoCheat sheet and review

ColaCheat sheet

TarefaTaskComandoCommand
Rodar TS em dev, recarregandoRun TS in dev, reloadingnode --watch --env-file-if-exists=.env src/server.ts
Rodar um script do package.jsonRun a package.json scriptnode --run build
Conferir tiposCheck typestsc --noEmit
Testes e coberturaTests and coveragenode --test --experimental-test-coverage
Instalar no CIInstall in CInpm ci · pnpm install --frozen-lockfile
DepurarDebugnode --inspect src/server.ts → chrome://inspect
Profile de CPUCPU profilenode --cpu-prof dist/server.js
Carga rápidaQuick loadnpx autocannon -c 50 -d 10 URL
Mais threads para fs/cryptoMore threads for fs/cryptoUV_THREADPOOL_SIZE=8
Desligamento limpo no DockerClean shutdown in Dockerhandler de SIGTERM + CMD ["node", …]SIGTERM handler + CMD ["node", …]

Laboratório: reproduza em 30 minutosLab: reproduce in 30 minutes

  1. Rode o arquivo de ordem como .mjs e como .cjs. Explique por que nextTick muda de lugar.Run the ordering file as .mjs and as .cjs. Explain why nextTick moves.
  2. Suba o servidor do experimento, chame /block e, ao mesmo tempo, /health. Repita com /worker. Compare os tempos.Start the experiment's server, call /block and, at the same time, /health. Repeat with /worker. Compare the times.
  3. Rode o autocannon contra /health sozinho e com /block em paralelo. Anote p50, p99 e máxima.Run autocannon against /health alone and with /block in parallel. Write down p50, p99 and max.
  4. Gere um arquivo de 200 MB e compare readFile com pipeline usando process.resourceUsage().maxRSS.Generate a 200 MB file and compare readFile with pipeline using process.resourceUsage().maxRSS.
  5. Construa a imagem, rode e meça docker stop com e sem o handler de SIGTERM.Build the image, run it and time docker stop with and without the SIGTERM handler.
O que foi e o que não foi executado. Executei: a ordem do event loop (ESM e CJS, Node 24 e 26), o servidor com /block e /worker, o autocannon 8, a comparação de memória com streams, os testes com node:test e mock timers (incluindo cobertura), a validação de ambiente com zod 4 e --env-file, a imagem Docker em node:26-alpine (267 MB) e os três tempos de docker stop, e o --cpu-prof. Não executei: o workflow do GitHub Actions, o script k6, o exemplo de pino com AsyncLocalStorage e a auto-instrumentação do OpenTelemetry; eles seguem a documentação oficial de cada ferramenta.What was and was not run. I ran: the event loop ordering (ESM and CJS, Node 24 and 26), the server with /block and /worker, autocannon 8, the streams memory comparison, the node:test tests with mock timers (including coverage), environment validation with zod 4 and --env-file, the Docker image on node:26-alpine (267 MB) and the three docker stop timings, and --cpu-prof. I did not run: the GitHub Actions workflow, the k6 script, the pino example with AsyncLocalStorage and OpenTelemetry auto-instrumentation; they follow each tool's official documentation.

Perguntas de revisãoReview questions

  • O que o V8 faz e o que o libuv faz? Quais operações vão para o pool de threads?What does V8 do and what does libuv do? Which operations go to the thread pool?
  • Por que nextTick imprime antes das promises no CommonJS e depois no ESM?Why does nextTick print before promises in CommonJS and after them in ESM?
  • Um cálculo de 1,5 s no handler: o que acontece com o /health? Quando usar worker, e quando réplicas?A 1.5 s computation in the handler: what happens to /health? When to use a worker, and when replicas?
  • O que é backpressure e o que acontece sem ela? Por que pipeline e não .pipe()?What is backpressure and what happens without it? Why pipeline and not .pipe()?
  • Por que req.body as User não é validação? Onde o schema deve morar num monorepo?Why is req.body as User not validation? Where should the schema live in a monorepo?
  • Qual a diferença entre uma variável do back e uma VITE_ do front, em quando é lida e em quem a vê?What is the difference between a back-end variable and a front-end VITE_ one, in when it is read and who sees it?
  • Por que o docker stop levou 10 s sem handler de SIGTERM? Quais as duas correções?Why did docker stop take 10 s without a SIGTERM handler? What are the two fixes?
  • Liveness e readiness: o que cada um checa e o que dá errado se você confundir?Liveness and readiness: what does each check and what goes wrong if you mix them up?
  • Para que serve o AsyncLocalStorage, e por que um “request ID global” não funciona em Node?What is AsyncLocalStorage for, and why does a “global request ID” not work in Node?
  • No teste de carga, o p99 ficou em 37 ms mas a máxima em 1,4 s. O que isso diz, e qual métrica do Node explicaria?In the load test, p99 stayed at 37 ms but max hit 1.4 s. What does that tell you, and which Node metric would explain it?
100%