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.
| ÁreaArea | O que saberWhat to know | FerramentasTools |
|---|---|---|
| LinguagemLanguage | promises, async/await, closures, erros, ESM e CommonJSpromises, async/await, closures, errors, ESM and CommonJS | ECMAScript, MDN |
| RuntimeRuntime | event loop, microtasks, thread pool, streams, workersevent loop, microtasks, thread pool, streams, workers | Node.js (V8 + libuv) |
| TiposTypes | tsconfig, tipos nas fronteiras, validação em runtimetsconfig, types at the boundaries, runtime validation | TypeScript, zod |
| FrontFront end | event loop do navegador, bundle, hidratação, Web Vitalsbrowser event loop, bundle, hydration, Web Vitals | Vite, React, Lighthouse |
| TestesTests | unidade, integração, ponta a ponta, mocks de tempounit, integration, end to end, time mocks | node:test, Vitest, Playwright |
| EntregaDelivery | lockfile, imagem, sinais, health check, pipelinelockfile, image, signals, health check, pipeline | pnpm/npm, Docker, GitHub Actions |
| OperaçãoOperation | logs estruturados, traces, atraso do event loop, cargastructured logs, traces, event loop delay, load | pino, 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.
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: cadaawaitdevolve o controle ao event loop e continua numa microtask. Para esperar várias coisas em paralelo, inicie todas e espere juntas comPromise.all(falha na primeira rejeição) ouPromise.allSettled(espera todas). Umawaitdentro de umfortransforma chamadas independentes em uma fila.A promise is a value that arrives later.async/awaitis sugar over promises: eachawaithands control back to the event loop and resumes in a microtask. To wait for several things in parallel, start them all and wait together withPromise.all(fails on the first rejection) orPromise.allSettled(waits for all). Anawaitinside aforturns independent calls into a queue. - Erro assíncrono não tratado derruba o processo. Desde o Node 15 uma promise rejeitada sem
catchencerra o processo (modo--unhandled-rejections=throw). Vi isso acontecer no laboratório: um handler HTTPasynclançou erro, ninguém capturou, e o servidor caiu. Todo ponto de entrada assíncrono (handler, job, listener) precisa de umcatchque registra e responde.An unhandled async error kills the process. Since Node 15 a rejected promise with nocatchends the process (--unhandled-rejections=throwmode). I saw it happen in the lab: anasyncHTTP handler threw, nobody caught it, and the server went down. Every async entry point (handler, job, listener) needs acatchthat 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 howdebounce, 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.3e 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 ouBigInt.Numbers aredouble.0.1 + 0.2 !== 0.3and integers are only exact up toNumber.MAX_SAFE_INTEGER(2⁵³ − 1). Money in integer cents; large database IDs (Postgres bigint) as strings orBigInt. - 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"dopackage.jsonou pela extensão (.mjs,.cjs). Projeto novo: ESM. Atenção: onpm initdo npm 11 grava"type": "commonjs", e meu primeiroimportfalhou 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 inpackage.jsonor by the extension (.mjs,.cjs). New project: ESM. Watch out: npm 11'snpm initwrites"type": "commonjs", and my firstimportfailed because of it. - Cancelamento é um padrão, não um detalhe.
AbortControllereAbortSignal.timeout(ms)funcionam nofetch, 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.AbortControllerandAbortSignal.timeout(ms)work withfetch, 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.
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çãoPosition | ESM (.mjs)ESM (.mjs) | CommonJS (.cjs)CommonJS (.cjs) |
|---|---|---|
| 1–2 | 1 sync, 2 sync | 1 sync, 2 sync |
| 3 | promise | nextTick |
| 4 | queueMicrotask | promise |
| 5 | nextTick | queueMicrotask |
| 6–7 | timeout, immediate | timeout, immediate |
| 8–9 | io → immediate, io → timeout | io → 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 donextTick. No CommonJS, o script roda “fora” de uma microtask e onextTickvem primeiro, como dizem os livros. Moral: não escreva código que dependa dessa ordem.In ESM, promises jumped ahead ofnextTick. 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 thenextTickqueue. In CommonJS, the script runs “outside” a microtask andnextTickcomes first, as the books say. Moral: do not write code that depends on this order. setTimeout(0)contrasetImmediateno 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, osetImmediatevence sempre: depois do poll vem o check, e os timers só na volta seguinte.setTimeout(0)versussetImmediatein 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,setImmediatealways 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çãoMeasurement | Cálculo na thread principalComputation on the main thread | Cálculo num workerComputation in a worker |
|---|---|---|
| Tempo de fib(38)Time of fib(38) | 1,50 s | igual (a CPU é a mesma)same (same CPU) |
/health chamado durante o cálculo/health called during the computation | 1,36 s | 0,01 s |
| Atraso máximo do event loopMax event loop delay | 1476 ms | 10,9 ms |
- O que bloqueia na prática:
JSON.parse/stringifyde 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 qualquerfs.*Syncdentro de um handler.What blocks in practice:JSON.parse/stringifyof huge payloads, regexes with catastrophic backtracking (the ReDoS attack), loops over millions of items, synchronous crypto and compression (*Sync), and anyfs.*Syncinside 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()denode:timers/promises), ou tirar o trabalho do processo (fila + outro serviço).The ways out: move it to aworker_thread(use a pool, such as Piscina, instead of creating a worker per request), split the work into chunks that yield (await setImmediate()fromnode: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, ornode: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.
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"));| ArquivoFile | readFile + writeFile (tudo na memória)readFile + writeFile (all in memory) | pipeline (streams)pipeline (streams) |
|---|---|---|
| 200 MB | pico de 868 MB de RAM, 1,6 s868 MB peak RAM, 1.6 s | pico de 75 MB de RAM, 3,6 s75 MB peak RAM, 3.6 s |
| 674 MB | falhou: RangeError: Invalid string lengthfailed: RangeError: Invalid string length | pico 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.
| OndeWhere | Quem transforma TS em JSWho turns TS into JS | Quem confere os tiposWho checks types |
|---|---|---|
| Node em desenvolvimentoNode in development | o 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 production | tsc → dist/*.js | tsc |
| NavegadorBrowser | o bundler (Vite), que apaga os tipos sem checá-losthe bundler (Vite), which strips types without checking them | tsc --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 Useris 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
contractscom 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), acontractspackage 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.
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
| NecessidadeNeed | Escolha comumCommon choice | Por quê / atençãoWhy / watch out |
|---|---|---|
| Versão do NodeNode version | fnm, nvm, mise + arquivo+ file .nvmrc | todos usam a mesma; o campo engines documenta o mínimoeveryone uses the same one; the engines field documents the minimum |
| PacotesPackages | pnpm ouor npm | pnpm 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 version | packageManager noin package.json | a 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 dev | node --watch, node --env-file | sem nodemon nem dotenv: já vem no Nodeno nodemon or dotenv: built into Node |
| Lint e formataçãoLint and format | ESLint + typescript-eslint, Prettier; ouor Biome | a 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 back | Fastify, Hono, Express, NestJS | Fastify 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 |
| BancoDatabase | Prisma, Drizzle, Kysely, pg | pool de conexões tem tamanho; veja Postgres para devsconnection pools have a size; see Postgres for devs |
| Bundler / frontBundler / front end | Vite, Next.js, React Router | Vite 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 it | ResultadoResult |
|---|---|
node src/print-env.ts | saiu 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.ts | PORT: 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_ENVnão é “o ambiente”. Bibliotecas (React, Express) leemNODE_ENV=productionpara 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_ENVis not “the environment”. Libraries (React, Express) readNODE_ENV=productionto 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.
.envno.gitignoree no.dockerignore, um.env.exampleversionado, 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 registreenvinteiro no log.Production secrets do not live in a file in the repository..envin.gitignoreand.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 wholeenv.
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.
- Variável de ambiente no front é pública. No Vite, só as variáveis com prefixo
VITE_chegam ao código, porimport.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 withVITE_reach the code, throughimport.meta.env, and they are replaced in the bundle at build time (in Next.js the prefix isNEXT_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-vitalsmede 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; theweb-vitalslibrary 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
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| FerramentaTool | Quando usarWhen to use |
|---|---|
node:test | back end e bibliotecas; zero dependência, roda TS diretoback end and libraries; zero dependencies, runs TS directly |
| Vitest | front 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 Library | testar 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 |
| Playwright | ponta 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) |
| Testcontainers | sobe Postgres, Redis etc. reais em Docker durante o teste de integraçãostarts real Postgres, Redis etc. in Docker during the integration test |
| MSW | intercepta fetch no front e no Node para simular APIs externasintercepts fetch on the front and in Node to fake external APIs |
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:
| CasoCase | Tempo do docker stopdocker stop time | Código de saídaExit code |
|---|---|---|
Node como PID 1, sem handler de SIGTERMNode as PID 1, without a SIGTERM handler | 10,29 s | 137 (morto por SIGKILL)137 (killed by SIGKILL) |
Mesmo app com docker run --initSame app with docker run --init | 0,24 s | 143 (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 server | 0,24 s | 0 (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
clusterfazem sentido numa VM sem orquestrador, não dentro do Kubernetes.One process per container. To use more cores, scale replicas. PM2 andclustermake 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
# .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 auditno 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 auditin 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-prettyno 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, usepino-prettyon 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. OAsyncLocalStorage(denode:async_hooks) carrega um valor através de todos osawaitdaquela 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 withAsyncLocalStorage. Since there is no thread per request, you cannot keep the “current request ID” in a global variable.AsyncLocalStorage(fromnode:async_hooks) carries a value through everyawaitof 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.jsinstrumenta HTTP,pg, Redis, Express, Fastify e outros, e exporta para um coletor OTLP definido por variáveisOTEL_*.Traces without touching code.node --import @opentelemetry/auto-instrumentations-node/register dist/server.jsinstruments HTTP,pg, Redis, Express, Fastify and others, and exports to an OTLP collector set byOTEL_*variables. - A métrica que só o Node tem: atraso do event loop.
monitorEventLoopDelay(usado no experimento) ouprom-client, que já exportanodejs_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) orprom-client, which already exportsnodejs_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árioScenario | Requisições em 5 sRequests in 5 s | Latência médiaAvg latency | p99 | MáximaMax |
|---|---|---|---|---|
Só /healthOnly /health | 49 mil | 4,8 ms | 15 ms | 152 ms |
/health + 3 chamadas a /block ao mesmo tempo/health + 3 /block calls at the same time | 16 mil | 15,4 ms | 37 ms | 1443 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.
| PerguntaQuestion | FerramentaTool |
|---|---|
| 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.
- Linguagem e assincronia. Promises,
async/await, erros, closures, módulos. Exercício: implementePromise.alle umretrycom backoff à mão.Language and asynchrony. Promises,async/await, errors, closures, modules. Exercise: implementPromise.alland aretrywith backoff by hand. - 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.
- 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 onlynode:http: routes, validated JSON body, streams, errors, timeouts. Then Fastify, and you will know what it does for you. - 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. - Testes.
node:testcom mocks de tempo, integração com banco real em contêiner, alguns E2E com Playwright.Tests.node:testwith time mocks, integration with a real database in a container, a few Playwright E2E tests. - 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.
- 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
| TarefaTask | ComandoCommand |
|---|---|
| Rodar TS em dev, recarregandoRun TS in dev, reloading | node --watch --env-file-if-exists=.env src/server.ts |
| Rodar um script do package.jsonRun a package.json script | node --run build |
| Conferir tiposCheck types | tsc --noEmit |
| Testes e coberturaTests and coverage | node --test --experimental-test-coverage |
| Instalar no CIInstall in CI | npm ci · pnpm install --frozen-lockfile |
| DepurarDebug | node --inspect src/server.ts → chrome://inspect |
| Profile de CPUCPU profile | node --cpu-prof dist/server.js |
| Carga rápidaQuick load | npx autocannon -c 50 -d 10 URL |
| Mais threads para fs/cryptoMore threads for fs/crypto | UV_THREADPOOL_SIZE=8 |
| Desligamento limpo no DockerClean shutdown in Docker | handler de SIGTERM + CMD ["node", …]SIGTERM handler + CMD ["node", …] |
Laboratório: reproduza em 30 minutosLab: reproduce in 30 minutes
- Rode o arquivo de ordem como
.mjse como.cjs. Explique por quenextTickmuda de lugar.Run the ordering file as.mjsand as.cjs. Explain whynextTickmoves. - Suba o servidor do experimento, chame
/blocke, ao mesmo tempo,/health. Repita com/worker. Compare os tempos.Start the experiment's server, call/blockand, at the same time,/health. Repeat with/worker. Compare the times. - Rode o autocannon contra
/healthsozinho e com/blockem paralelo. Anote p50, p99 e máxima.Run autocannon against/healthalone and with/blockin parallel. Write down p50, p99 and max. - Gere um arquivo de 200 MB e compare
readFilecompipelineusandoprocess.resourceUsage().maxRSS.Generate a 200 MB file and comparereadFilewithpipelineusingprocess.resourceUsage().maxRSS. - Construa a imagem, rode e meça
docker stopcom e sem o handler deSIGTERM.Build the image, run it and timedocker stopwith and without theSIGTERMhandler.
/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
nextTickimprime antes das promises no CommonJS e depois no ESM?Why doesnextTickprint 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 Usernão é validação? Onde o schema deve morar num monorepo?Why isreq.body as Usernot 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-endVITE_one, in when it is read and who sees it? - Por que o
docker stoplevou 10 s sem handler deSIGTERM? Quais as duas correções?Why diddocker stoptake 10 s without aSIGTERMhandler? 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 isAsyncLocalStoragefor, 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?
- Node.js: the event loop, timers and process.nextTick() · Don't block the event loop · libuv: design overview
- Node.js: worker_threads · monitorEventLoopDelay · Stream · Backpressuring in streams
- Node.js: TypeScript · --env-file · node:test (MockTimers) · AsyncLocalStorage · Permissions · Profiling
- Node.js release schedule · TSConfig reference · zod
- Vite: env variables and modes · web.dev: Web Vitals · Vitest · Playwright · Testcontainers
- Docker: --init · Kubernetes: container lifecycle · The Twelve-Factor App: config · pnpm: build scripts of dependencies
- pino · OpenTelemetry: zero-code instrumentation for JavaScript · prom-client · autocannon · k6
- Erick Wendel: MockTimers (PR #47775) · myownnode: a JavaScript runtime on V8 and libuv · JS Party #268: Recreating Node.js from scratch · ew.academy