Skip to content
DevRecall
← Roadmap
05

Node.js Internals

drafted

V8 + libuv, event loop phases, thread pool, streams/backpressure, worker_threads vs cluster vs child_process, memory leak & heap snapshot, graceful shutdown, profiling, security.

0/63 rated · mastery 0%

Bài học

12 bài · ~155 phút đọc. Học theo thứ tự, rồi làm câu hỏi.
  1. 01~14 phút· 5 câuverify

    Kiến trúc runtime: V8, libuv, bindings và thread pool

    Một process Node gồm những gì, JS chạy trên một thread nhưng process có nhiều thread, đường đi của một lời gọi fs/crypto qua binding và thread pool, vì sao 5.000 socket không cần 5.000 thread, cách size UV_THREADPOOL_SIZE, và khi nào Node là lựa chọn sai.

  2. 02~12 phút· 6 câuverify

    Event loop của Node: các phase của libuv, nextTick, setImmediate và timer

    Một vòng uv_run chạy những gì theo thứ tự nào, vì sao poll phase block và khi nào nó thôi chờ, nextTick queue và microtask được drain ở đâu, thứ tự setTimeout vs setImmediate, thay đổi của Node 11 và libuv 1.45, starvation, và các gotcha của timer.

  3. 03~12 phút· 4 câuverify

    Buffer và stream: dữ liệu nhị phân, bốn loại stream, highWaterMark và backpressure

    Buffer nằm ở đâu trong bộ nhớ và vì sao không thấy trong heapUsed, alloc vs allocUnsafe, ký tự multi-byte bị cắt giữa chunk, bốn loại stream, objectMode, buffer nội bộ và highWaterMark, write() trả false và 'drain', chế độ paused/flowing và vì sao for await là cách đọc nên dùng.

  4. 04~12 phút· 7 câuverify

    Stream pipelines trong production: pipeline(), CSV import, export và bulk indexing

    Vì sao pipe() rò file descriptor còn pipeline() thì không, nối stage bằng async generator, parser CSV giữ phần dư giữa các chunk, batch cho bulk insert, review endpoint import bị OOMKilled và export 'dùng stream' vẫn tốn vài GB, backpressure tới tận database, và đẩy dữ liệu vào Elasticsearch không làm sập cluster.

  5. 05~12 phút· 5 câuverify

    EventEmitter, lỗi cấp process và cancellation trong Node

    EventEmitter gọi listener đồng bộ ra sao, sự kiện 'error' đặc biệt thế nào, MaxListenersExceededWarning nói gì, listener async và captureRejections, uncaughtException vs unhandledRejection và các chế độ --unhandled-rejections, vì sao 'log rồi chạy tiếp' làm hỏng state, fire-and-forget không mất dữ liệu, và huỷ việc bằng AbortSignal.

  6. 06~14 phút· 6 câuverify

    Việc CPU-bound trong Node: chặn event loop, chia nhỏ, worker_threads, cluster và tách service

    Chặn event loop trông như thế nào trong production, những cách vô tình chặn phổ biến, xử lý một response JSON 30 MB, worker_threads vs cluster vs child_process, vì sao chuyển sang worker lại chậm hơn, worker pool có hàng đợi giới hạn, cluster trên Kubernetes và Socket.IO, và thiết kế cho việc nặng như PDF hay resize ảnh.

  7. 07~14 phút· 4 câuverify

    Memory trong Node production: GC của V8, đọc memoryUsage và săn leak bằng heap snapshot

    Scavenge và Mark-Compact làm gì, heap_size_limit và --max-old-space-size thật sự giới hạn phần nào, đọc rss/heapUsed/external/arrayBuffers để biết leak nằm trong hay ngoài heap, đáy răng cưa sau GC, quy trình chứng minh một leak khiến pod bị OOMKilled mỗi hai ngày, và leak listener SSE đo bằng heap snapshot.

  8. 08~14 phút· 6 câuverify

    Observability và profiling cho Node: AsyncLocalStorage, event loop metrics, CPU profile và điều tra p99

    Gắn requestId/tenantId vào mọi log bằng AsyncLocalStorage và những chỗ context bị mất, event loop delay vs event loop utilization, GC metrics, đọc CPU profile, quy trình điều tra p99 spike khi CPU trung bình vẫn thấp, bộ observability mặc định cho mọi service, và kể chuyện load test hay incident theo STAR.

  9. 09~13 phút· 4 câuverify

    HTTP server và client trong Node: timeout của server, keep-alive sau load balancer, connection pool, retry và DNS

    Các timeout của http.Server và giá trị mặc định trên Node 24, vì sao keepAliveTimeout lệch với idle timeout của load balancer gây 502 lẻ tẻ, Agent/undici pool và connection reuse, timeout-retry-concurrency cho outbound call, bulkhead theo provider, và DNS lookup như một nút thắt ẩn trên thread pool.

  10. 10~13 phút· 6 câuverify

    Graceful shutdown và kết nối sống lâu: HTTP server, SSE/WebSocket, Kafka consumer và job dài

    server.close() thật sự làm gì và vì sao callback của nó có thể chờ thêm 5 giây, closeIdleConnections/closeAllConnections và Connection: close, review đoạn shutdown gây 5xx mỗi lần deploy, đóng SSE/WebSocket không gây reconnect storm, dừng Kafka consumer không mất offset, job 50 triệu bản ghi sống qua restart, và hệ thống push realtime cho 100k client.

  11. 11~14 phút· 6 câuverify

    Module, package và cấu hình: resolution, module cache, dependencies, env/secrets và vòng đời version Node

    Node quyết định một file là CommonJS hay ESM thế nào, thuật toán tìm package và field exports, module wrapper và vì sao __dirname không phải global, module cache là singleton theo đường dẫn, circular require, require(esm), dependencies/devDependencies/peerDependencies và npm ci, config và secret theo 12-factor, lịch LTS và cách nâng major version cho 30 service.

  12. 12~13 phút· 4 câuverify

    Bảo mật runtime Node: prototype pollution, ReDoS, command injection, path traversal và supply chain

    Vì sao một key __proto__ trong request body biến mọi object thành admin, --disable-proto chặn được gì và không chặn được gì, regex backtracking làm sập cả server một thread, exec qua shell so với execFile, path traversal khi ghép đường dẫn, permission model của Node, và cách giảm rủi ro supply chain của npm.

Tóm tắt & cheat sheet của track

TL;DR

  • Node.js = V8 (chạy JS, GC) + libuv (event loop, async I/O, thread pool) + C++ bindings. JS chạy trên một main thread, nhưng process có nhiều thread (libuv pool, V8 GC/compiler threads).
  • Network I/O dùng cơ chế non-blocking của OS (epoll/kqueue/IOCP) — không dùng thread pool. File system, dns.lookup, một số crypto và zlib async dùng thread pool (mặc định 4 thread, UV_THREADPOOL_SIZE).
  • Throughput của một Node service phụ thuộc vào việc không block event loop: CPU-bound → worker_threads/service riêng; dữ liệu lớn → streams + backpressure.
  • Production-ready = biết đo (event loop delay/utilization, heap, GC, CPU profile), tìm leak (heap snapshot comparison), shutdown đúng cách (SIGTERM, drain keep-alive connections) và phòng thủ (prototype pollution, ReDoS, command injection, supply chain).
  • Version: Node 24 là LTS đang dùng (local v24); nhiều hành vi thay đổi theo major — các hint có verify cần check lại docs đúng version.

Khái niệm cốt lõi

Khái niệmMột câuVí dụ ngắn
Event loopVòng lặp của libuv, chạy callback theo phasetimers → poll → check → close
process.nextTick queueQueue của Node, chạy trước promise microtasks, sau mỗi callbacknextTick in trước Promise.then
MicrotaskPromise reaction, queueMicrotask — drain hết sau mỗi macrotask callbackawait tiếp tục trong microtask
Thread poolPool thread của libuv cho việc không có async API của OSfs.readFile, crypto.pbkdf2, dns.lookup
StreamXử lý dữ liệu theo chunk thay vì load hết vào RAMfs.createReadStream().pipe(res)
BackpressureCơ chế consumer báo producer chậm lạiwrite() trả false → chờ 'drain'
worker_threadsThread JS riêng (V8 isolate riêng) trong cùng processResize ảnh, parse file lớn
clusterFork nhiều process chia chung portTận dụng nhiều core trên VM
Heap snapshotẢnh chụp heap V8 để tìm object bị giữ referenceComparison 2 snapshot → retainers
AsyncLocalStorageContext đi theo async call chainrequest-id, tenantId cho log

Cơ chế hoạt động

Event loop phases

  • Từ Node 11, nextTick + microtasks được drain sau mỗi callback (không phải chỉ giữa các phase) — giống browser.
  • Từ libuv 1.45 (Node 20), timers chạy sau poll trong mỗi iteration (trước đó chạy trước poll); libuv vẫn chạy timers một lần trước khi vào loop (verify khi dựa vào thứ tự chi tiết).

Ai dùng thread pool, ai không

Streams & backpressure

Graceful shutdown (Kubernetes)

Trade-offs

Cách chạy song songIsolationChia sẻ memoryChi phí khởi tạoDùng khi
worker_threadsV8 isolate riêng, cùng processSharedArrayBuffer, transfer ArrayBufferThấp–vừa (vài ms, MB heap)CPU-bound trong service (parse, hash, image)
clusterProcess riêngKhông (IPC)CaoNhiều core trên VM không có orchestrator
child_processProcess riêng, có thể là binary khácKhông (stdio/IPC)CaoGọi ffmpeg, script Python, tool CLI
N container/podHoàn toànKhôngCao nhấtMặc định trên Kubernetes — scale bằng replica
Xử lý dữ liệu lớnRAMĐộ phức tạpGhi chú
readFile + JSON.parseO(file)ThấpBlock event loop khi parse, OOM với file lớn
Streams + pipelineO(highWaterMark × stages)VừaBackpressure tự động, xử lý lỗi/cleanup đúng
Job queue + workerTuỳCaoTách khỏi request path, retry, quan sát được

Pitfalls

  • Sync API trong request path: fs.readFileSync, crypto.pbkdf2Sync, zlib.gzipSync, JSON.parse/stringify payload lớn, regex backtracking.
  • bcrypt/pbkdf2 async chiếm hết 4 thread pool → fs và dns.lookup của request khác bị xếp hàng.
  • readable.pipe(writable) không destroy stream khi lỗi → leak file descriptor; dùng stream.pipeline.
  • Bỏ qua giá trị trả về của write() → buffer phình trong RAM.
  • Listener on(...) đăng ký mỗi request mà không off → leak + MaxListenersExceededWarning.
  • Cache Map không giới hạn, closure giữ object lớn, timer không clearInterval.
  • server.keepAliveTimeout nhỏ hơn idle timeout của load balancer → 502 lẻ tẻ.
  • Chạy npm start làm PID 1 trong container → signal có thể không tới Node; dùng node trực tiếp hoặc init (tini, --init).
  • process.on('uncaughtException') rồi chạy tiếp → process ở trạng thái không xác định.
  • Deep merge JSON từ client → prototype pollution; exec("convert " + userInput) → command injection.

Cheat sheet

  • Thứ tự trong một tick: sync code → process.nextTick queue → promise microtasks → phase kế tiếp.
  • Main module: setTimeout(0) vs setImmediate không xác định; trong I/O callback: setImmediate luôn trước.
  • Thread pool: default 4, max 1024; set UV_THREADPOOL_SIZE trước khi pool được dùng lần đầu (tốt nhất là env var khi start).
  • Stream highWaterMark mặc định: 64 KiB cho byte stream từ Node 22 (trước đó 16 KiB), 16 object cho objectMode (verify).
  • Đo: performance.eventLoopUtilization(), perf_hooks.monitorEventLoopDelay(), --cpu-prof, --heap-prof, --heapsnapshot-signal=SIGUSR2, --heapsnapshot-near-heap-limit=N, --trace-gc.
  • process.memoryUsage(): rss (toàn process) · heapUsed/heapTotal (V8 heap) · external + arrayBuffers (Buffer ngoài heap).
  • --unhandled-rejections mặc định throw (từ Node 15) → rejection không có handler làm crash process.
  • setTimeout với delay > 2147483647 ms bị set thành 1 ms (kèm TimeoutOverflowWarning).
  • Default EventEmitter maxListeners = 10 (chỉ là warning, không phải giới hạn cứng).

Personal notes (Notion)

Ghi chú cá nhân — có thể sai hoặc lỗi thời, dùng để tham khảo.

Interview Questions

Showing 63 / 63

setTimeout(() => console.log("timeout"));
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
// nextTick, promise, then timeout/immediate in non-deterministic order (main module)
const Env = z.object({
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  FEATURE_X: z.enum(["true", "false"]).transform((v) => v === "true"),
});
export const env = Env.parse(process.env); // crash at boot, not at 3am
// A: main module
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

// B: inside an I/O callback
import { readFile } from "node:fs";
readFile(import.meta.filename, () => {
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
});
setTimeout(() => {
  console.log("t1");
  Promise.resolve().then(() => console.log("p1"));
  process.nextTick(() => console.log("n1"));
}, 0);
setTimeout(() => {
  console.log("t2");
}, 0);
import { once } from "node:events";
for (const row of rows) {
  if (!out.write(format(row))) await once(out, "drain");
}
out.end();
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
await pipeline(
  fs.createReadStream("report.csv"),
  async function* (src) { for await (const chunk of src) yield redact(chunk); },
  createGzip(),
  res,
);
import { AsyncLocalStorage } from "node:async_hooks";
export const ctx = new AsyncLocalStorage<{ requestId: string; tenantId?: string }>();
app.use((req, _res, next) => {
  ctx.run({ requestId: req.get("x-request-id") ?? crypto.randomUUID() }, next);
});
logger.info = (msg) => base.info({ ...ctx.getStore(), msg });
app.get("/search", async (req, res) => {
  const ac = new AbortController();
  res.on("close", () => { if (!res.writableFinished) ac.abort(); });
  const signal = AbortSignal.any([ac.signal, AbortSignal.timeout(2000)]);
  const r = await fetch(searchUrl(req.query), { signal });
  res.json(await r.json());
});
const server = app.listen(3000);
server.keepAliveTimeout = 65_000; // > ALB idle timeout (60s)
server.headersTimeout = 66_000;   // must exceed keepAliveTimeout
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
const h = monitorEventLoopDelay({ resolution: 20 }); h.enable();
let last = performance.eventLoopUtilization();
setInterval(() => {
  const now = performance.eventLoopUtilization(last); last = performance.eventLoopUtilization();
  metrics.gauge("elu", now.utilization);
  metrics.gauge("loop_delay_p99_ms", h.percentile(99) / 1e6); h.reset();
}, 10_000).unref();
import { setImmediate as yieldToLoop } from "node:timers/promises";
for (let i = 0; i < rows.length; i += 1000) {
  processBatch(rows.slice(i, i + 1000));
  await yieldToLoop(); // let I/O and timers run
}
await pipeline(
  fs.createReadStream(path),
  parse({ columns: true, bom: true }),       // csv-parse: Transform in objectMode
  async function* (rows) {
    let batch: Row[] = [];
    for await (const r of rows) {
      batch.push(validate(r));
      if (batch.length === 1000) { yield batch; batch = []; }
    }
    if (batch.length) yield batch;
  },
  new Writable({ objectMode: true, async write(batch, _e, cb) {
    try { await repo.bulkInsert(batch); cb(); } catch (e) { cb(e as Error); }
  } }),
);
function merge(t: any, s: any) {
  for (const k in s) {
    if (typeof s[k] === "object") merge((t[k] ??= {}), s[k]);
    else t[k] = s[k];
  }
}
merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
({} as any).isAdmin; // true — every object is now "admin"
const EMAIL = /^([a-zA-Z0-9]+)*@example\.com$/;
EMAIL.test("a".repeat(35) + "!"); // exponential backtracking, blocks the loop
import { exec } from "node:child_process";
app.post("/convert", (req, res) => {
  const name = req.body.filename; // e.g. "report.docx"
  exec(`libreoffice --headless --convert-to pdf /uploads/${name}`, (err, out) => {
    if (err) return res.status(500).end();
    res.json({ ok: true });
  });
});
import { EventEmitter } from "node:events";
export const priceBus = new EventEmitter(); // module-level singleton

app.get("/prices/stream", (req, res) => {
  res.setHeader("Content-Type", "text/event-stream");
  priceBus.on("price", (p) => res.write(`data: ${JSON.stringify(p)}\n\n`));
});
app.post("/import", upload.single("file"), async (req, res) => {
  const text = await fs.promises.readFile(req.file!.path, "utf8");
  const rows = text.split("\n").map((l) => l.split(","));
  await Promise.all(rows.map((r) => db.insert("products", toProduct(r))));
  res.json({ imported: rows.length });
});
const out = fs.createWriteStream("/tmp/export.csv");
const cursor = db.query("SELECT * FROM orders").stream();
cursor.on("data", (row) => {
  out.write(toCsvLine(row)); // return value ignored
});
cursor.on("end", () => out.end());
// login
const hash = await new Promise<Buffer>((ok, ko) =>
  crypto.pbkdf2(password, salt, 600_000, 64, "sha512", (e, k) => (e ? ko(e) : ok(k))));
// other endpoints
const tpl = await fs.promises.readFile("templates/invoice.html", "utf8");
const r = await fetch("https://inventory.internal/stock"); // dns.lookup under the hood
let shuttingDown = false;
async function shutdown(signal: string) {
  if (shuttingDown) return; shuttingDown = true;
  logger.info({ signal }, "shutting down");
  health.ready = false;
  await sleep(5_000);                        // let the LB stop routing
  const force = setTimeout(() => { server.closeAllConnections(); process.exit(1); }, 20_000).unref();
  const closed = new Promise<void>((r) => server.close(() => r()));
  const sweep = setInterval(() => server.closeIdleConnections(), 500); // sockets that go idle after close()
  await closed; clearInterval(sweep);
  await Promise.allSettled([consumer.disconnect(), db.end(), redis.quit()]);
  clearTimeout(force);
  process.exit(0);
}
process.on("SIGTERM", shutdown).on("SIGINT", shutdown);
process.on("SIGTERM", () => {
  server.close();
  db.end();
  process.exit(0);
});
import { EventEmitter } from "node:events";
const orders = new EventEmitter();
orders.on("created", async (order) => {
  await inventory.reserve(order); // rejects when out of stock
});
try {
  orders.emit("created", order);
} catch (e) {
  logger.error(e); // never reached
}
app.get("/files/:id", (req, res) => {
  const src = fs.createReadStream(pathFor(req.params.id));
  src.pipe(createGzip()).pipe(res);
});