Перейти до вмісту

Рантайм Node.js

Як влаштований Node.js під бекендом: модулі й пакети, event loop і libuv, потоки даних, помилки й зупинка, масштабування та діагностика. На співбесіді .NET-розробника тут перевіряють, чи розуміє він, чим один потік подій відрізняється від пулу потоків ASP.NET Core і чим це загрожує в продакшені.

Перед цією темою: JavaScript і TypeScript.

Junior+

Як влаштовані модулі й пакети в Node.js?

Section titled “Як влаштовані модулі й пакети в Node.js?”
Відповідь

Що це

  • Модуль — файл зі своєю областю видимості: назовні видно лише те, що він експортує.
  • Пакет — тека з package.json: ім’я, версія, залежності, скрипти.

Дві системи модулів

CommonJS ES-модулі
Імпорт const fs = require('node:fs') import fs from 'node:fs'
Експорт module.exports = { f } export function f() {}
Коли діє .cjs або немає "type": "module" .mjs або "type": "module"

Як користуватися

  1. npm install express записує залежність у package.json і ставить пакет у node_modules.
  2. package-lock.json фіксує точні версії всього дерева — його комітять.
  3. У CI ставлять через npm ci: суворо за lock-файлом.

Типова помилка

Писати require в ES-модулі. Результат — ReferenceError: require is not defined in ES module scope.

Як влаштовано

  • CommonJS. require синхронно читає й виконує файл під час першого виклику та кешує результат. Експорт — звичайний об’єкт.
  • ESM. Завантажувач спершу розбирає всі import і будує граф, потім виконує модулі. Імпорти — живі прив’язки до експортів, а не копії.
  • Тип файлу .js визначає найближчий package.json. З Node 22.7 файл без поля "type", але із синтаксисом import, розпізнається як ESM автоматично.

Версії та діапазони

Запис Що дозволяє
^1.2.3 від 1.2.3 до 2.0.0, не включно
~1.2.3 від 1.2.3 до 1.3.0, не включно
^0.2.3 від 0.2.3 до 0.3.0: у 0.x minor вважається ламким
1.2.3 саме цю версію

Пастки

  • npm install підганяє lock-файл під package.json, а npm ci ставить суворо за lock і падає, якщо вони розходяться.
  • В ESM відносний імпорт пишуть із розширенням: import './db.js', а не './db'.
  • В ESM немає __dirname і require. Заміна — import.meta.dirname і createRequire.

Правило

Новий проєкт — "type": "module", lock-файл у git, npm ci у CI.

Під капотом

  • require() ES-модуля працює без прапорця з Node 22.12 і 20.19, стабільний з 24.15 і 25.4. Якщо в графі є top-level await, буде ERR_REQUIRE_ASYNC_MODULE.
  • Поле "exports" у package.json задає публічні точки входу та умови import і require. Решта файлів пакета закриті для імпорту.
  • Type stripping увімкнений за замовчуванням з 22.18 і 23.6, стабільний з 24.12 і 25.2. Node замінює типи пробілами, tsconfig.json не читає й типи не перевіряє.
  • Не підтримуються enum, namespace з кодом і parameter properties — помилка ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. Імпорт типів — лише через import type, розширення .ts в імпорті обов’язкове, файли в node_modules не обробляються.

Компроміси

npm pnpm
node_modules плаский: видно будь-який пакет дерева ізольований: видно лише оголошені
Диск копія в кожному проєкті спільне сховище й жорсткі посилання
Install-скрипти залежностей виконуються з pnpm 10 за замовчуванням ні

TypeScript без збирання добрий для скриптів та інструментів. Для сервісу типи все одно перевіряють tsc --noEmit у CI, а прапорець erasableSyntaxOnly (TypeScript 5.8) забороняє синтаксис, який Node не зітре.

Як перевірити в продакшені

  • npm ls пакет показує, звідки в дереві взялася версія. overrides у package.json піднімає вразливу транзитивну залежність.
  • npm audit у CI та заборона install-скриптів закривають типові атаки на ланцюжок постачання.

Підсумок: ESM за замовчуванням, require(esm) для сумісності, lock-файл і суворий менеджер пакетів — проти сюрпризів у залежностях.

Приклад
// main.mjs — ES-модуль
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url); // require всередині ESM — лише так
const os = require('node:os'); // CommonJS-модуль: завантаження синхронне
console.log(typeof module, typeof __dirname); // undefined undefined
console.log(import.meta.filename.endsWith('main.mjs')); // true
const source = await readFile(import.meta.filename, 'utf8'); // top-level await — лише в ESM
console.log(source.length > 0, typeof os.cpus); // true function
TypeScript без збирання
// order.ts — запуск: node order.ts (Node 22.18+ або 23.6+)
type Order = { id: number; total: number };
function sum(orders: Order[]): number {
return orders.reduce((acc, o) => acc + o.total, 0);
}
console.log(sum([{ id: 1, total: 10 }, { id: 2, total: 5 }])); // 15
// enum Status { New, Paid } — ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
const Status = { New: 'new', Paid: 'paid' } as const; // заміна enum без трансформації
type Status = (typeof Status)[keyof typeof Status];
const s: Status = Status.Paid;
console.log(s); // paid
Middle+

Як Node.js обробляє тисячі з’єднань в одному потоці?

Section titled “Як Node.js обробляє тисячі з’єднань в одному потоці?”
Відповідь

Що це

  • Node.js — середовище виконання JavaScript поза браузером: рушій V8 плюс бібліотека libuv для вводу-виводу.
  • Event loop — цикл, який бере готові події та по одній викликає їхні колбеки. Подія — прийшов запит, база відповіла, спрацював таймер.

Як це працює на сервері

  1. Прийшов запит — обробник надсилає запит до бази й на await повертає керування.
  2. Потік вільний і приймає наступні запити.
  3. База відповіла — продовження обробника потрапляє в чергу, і event loop його виконує.

Типова помилка

Синхронна робота в обробнику: fs.readFileSync, JSON.parse величезного рядка, важкий цикл. Поки вона триває, сервер не відповідає нікому.

Як влаштовано

Основні фази одного витка циклу, по черзі:

  1. timers — колбеки setTimeout і setInterval, у яких сплив термін.
  2. poll — нові події вводу-виводу та їхні колбеки. Якщо робити нічого, цикл чекає тут.
  3. check — колбеки setImmediate.
  4. close callbacks — події 'close', наприклад у сокета.

Після кожного колбека Node виконує всю чергу process.nextTick, потім усі мікрозадачі промісів.

Хто чекає на ввід-вивід

Операція Де чекає
Сокети: HTTP, TCP, драйвери БД ОС: epoll, kqueue, IOCP
fs, dns.lookup, crypto.pbkdf2, zlib пул потоків libuv, 4 за замовчуванням

Пастки

  • Рекурсивний process.nextTick або нескінченний ланцюжок промісів не дають циклу дійти до вводу-виводу.
  • Порядок setTimeout(fn, 0) і setImmediate у головному модулі не визначений. У колбеку вводу-виводу setImmediate завжди перший.
  • Пул із 4 потоків: п’ятий crypto.pbkdf2 чекає, поки звільниться потік.

Правило

Відкласти роботу — setImmediate, його й радить документація Node. Важке обчислення — геть із головного потоку.

Під капотом

  • З libuv 1.45 (Node 20) таймери виконуються після фази poll, а не перед нею.
  • ES-модуль виконує асинхронний завантажувач, тому на верхньому рівні ES-модуля проміс спрацьовує раніше за process.nextTick. У CommonJS — навпаки.
  • dns.lookup викликає getaddrinfo у пулі, і його за замовчуванням використовують http і net. dns.resolve* іде через c-ares, повз пул.
  • Пул спільний на процес, максимум — 1024 потоки через UV_THREADPOOL_SIZE.

Компроміси

Node.js ASP.NET Core
Де виконується код запиту один потік event loop потоки пулу .NET
CPU-робота в запиті затримує всі запити займає один потік
Ядра один процес — одне ядро для JS усі ядра в одному процесі
Синхронний I/O зупиняє сервер призводить до голодування пулу

Як перевірити в продакшені

  • perf_hooks.monitorEventLoopDelay() дає гістограму затримки циклу. Зростання p99 до десятків мілісекунд — ознака блокування.
  • performance.eventLoopUtilization() — частка часу, коли цикл зайнятий. Близько до 1 — процес уперся в CPU.
  • Функцію, яка тримає потік, покаже CPU-профіль: --cpu-prof або DevTools.
  • Повільні файли й DNS за вільного CPU — привід збільшити UV_THREADPOOL_SIZE під час запуску процесу.

Підсумок: модель Node виграє на очікуванні вводу-виводу й програє на CPU, тому затримку циклу міряють так само, як у .NET міряють пул потоків.

Приклад
// phases.cjs — CommonJS
const fs = require('node:fs');
fs.readFile(__filename, () => { // колбек вводу-виводу: фаза poll
setTimeout(() => console.log('timeout'), 0); // 5: фаза timers, наступний виток
setImmediate(() => console.log('immediate')); // 4: фаза check, одразу після poll
Promise.resolve().then(() => console.log('promise')); // 3: мікрозадача
process.nextTick(() => console.log('nextTick')); // 2: раніше за проміси
console.log('poll callback'); // 1
});

Числа в коментарях — порядок виводу.

Блокування циклу й пул libuv
// delay.cjs — CommonJS
const { monitorEventLoopDelay } = require('node:perf_hooks');
const crypto = require('node:crypto');
const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();
setTimeout(() => {
const start = Date.now();
while (Date.now() - start < 200) {} // синхронна робота на 200 мс
}, 10);
setTimeout(() => {
histogram.disable();
console.log(histogram.max / 1e6 > 150); // true: цикл стояв ~200 мс
}, 500);
const start = Date.now();
for (let i = 1; i <= 6; i++) {
crypto.pbkdf2('secret', 'salt', 500_000, 64, 'sha512', () => {
console.log(i, Date.now() - start); // 1–4 приблизно разом, 5 і 6 — удвічі пізніше
});
}

З UV_THREADPOOL_SIZE=6 усі шість хешів завершуються приблизно одночасно.

Middle+

Як працювати з потоками даних і помилками в Node.js?

Section titled “Як працювати з потоками даних і помилками в Node.js?”
Відповідь

Що це

  • Потік (stream) — об’єкт, через який дані йдуть порціями (chunks).
  • Readable — джерело: файл, тіло HTTP-запиту. Writable — приймач: файл, HTTP-відповідь. Transform — перетворює дорогою: стиснення, парсинг.

Як користуватися

  • З’єднувати: await pipeline(source, transform, destination) з node:stream/promises.
  • Читати в коді: for await (const chunk of stream).

Як ловити помилки

Стиль API Як ловити
проміси й async try/catch навколо await
колбек (err, data) насамперед перевірити err
EventEmitter і потоки слухач on('error')

Типова помилка

Читати великий файл через fs.readFile: він повністю потрапляє в пам’ять. Для великих даних — fs.createReadStream і pipeline.

Як влаштовано

  • У потоку є внутрішній буфер розміром highWaterMark. write() повертає false, коли буфер Writable заповнений, і джерело має чекати на подію 'drain'.
  • Це і є backpressure: повільний приймач гальмує швидке джерело, а не накопичує дані в пам’яті.
  • pipeline робить це сам. У разі помилки будь-якої ланки він знищує всі потоки й відхиляє проміс.

Коли що обирати

Задача Інструмент
З’єднати потоки pipeline з node:stream/promises
Прочитати потік у коді for await...of
Власне перетворення Transform або async-генератор усередині pipeline

Пастки

  • .pipe() не передає помилку далі: збій джерела залишить приймач відкритим.
  • 'error' без слухача в EventEmitter — виняток і падіння процесу.
  • Ігнорувати false від write() — буфер росте, доки не скінчиться пам’ять.
  • async-слухач події. Його відхилений проміс EventEmitter не ловить, і виходить unhandled rejection.

Правило

Потоки — лише через pipeline, у кожного довгоживучого EventEmitter — обробник 'error'.

Під капотом

  • Виняток, що дійшов до event loop, — uncaughtException. За замовчуванням Node друкує стек і завершується з кодом 1.
  • Відхилений проміс без обробника з Node 15 за замовчуванням (--unhandled-rejections=throw) спричиняє подію unhandledRejection. Якщо слухача немає, він стає uncaught exception.
  • Документація Node прямо каже: продовжувати роботу після uncaughtException небезпечно.

Коректна зупинка

  1. docker stop або Kubernetes надсилає SIGTERM, після терміну — SIGKILL.
  2. server.close() перестає приймати з’єднання. З Node 19 він одразу закриває keep-alive з’єднання, що простоюють.
  3. Активних запитів чекають із тайм-аутом, коротшим за термін оркестратора — у Kubernetes 30 секунд за замовчуванням.
  4. Закрити пули БД і клієнти брокерів, потім вийти з кодом 0.

Компроміси

Глобальний обробник легко перетворити на «глушилку» помилок, але процес із невідомим станом небезпечніший за впалий. Надійніше кілька екземплярів і швидкий перезапуск.

Як перевірити в продакшені

  • Підписка на SIGTERM скасовує стандартне завершення: без process.exit процес житиме, доки його не вб’є SIGKILL.
  • Node не розрахований працювати як PID 1: у Docker його запускають із --init або через tini і командою CMD ["node", "dist/main.js"], а не npm start.
  • Код виходу 137 у контейнера — SIGKILL: зупинка не вклалася в термін або спрацював OOM killer.

Підсумок: помилки, не перехоплені на місці, логують і завершують процес, а зупинку за SIGTERM пишуть явно й із тайм-аутом.

Приклад
// pipe.mjs — ES-модуль
import { pipeline } from 'node:stream/promises';
import { Readable, Transform, Writable } from 'node:stream';
const upper = () => new Transform({
transform(chunk, _encoding, callback) {
callback(null, chunk.toString().toUpperCase());
},
});
const collect = (parts) => new Writable({
write(chunk, _encoding, callback) { parts.push(chunk.toString()); callback(); },
});
const parts = [];
await pipeline(Readable.from(['a', 'b', 'c']), upper(), collect(parts));
console.log(parts.join('')); // ABC
const broken = new Transform({
transform(_chunk, _encoding, callback) { callback(new Error('bad chunk')); },
});
const source = Readable.from(['x']);
try {
await pipeline(source, broken, collect([]));
} catch (err) {
console.log(err.message, source.destroyed); // bad chunk true: pipeline закрив і джерело
}
Помилки процесу й зупинка за SIGTERM
// server.mjs — ES-модуль
import http from 'node:http';
import { EventEmitter } from 'node:events';
const bus = new EventEmitter();
try {
bus.emit('error', new Error('nobody listens')); // 'error' без слухача — виняток
} catch (err) {
console.log(err.message); // nobody listens
}
process.on('unhandledRejection', (reason) => {
console.error('unhandled rejection:', reason); // залогувати й завершитися
process.exit(1);
});
const server = http.createServer((req, res) => res.end('ok'));
server.listen(3000);
process.once('SIGTERM', () => { // docker stop, Kubernetes
console.log('SIGTERM: draining');
server.close(() => process.exit(0)); // нових з'єднань немає, поточних чекаємо
setTimeout(() => process.exit(1), 10_000).unref(); // страховка від завислих запитів
});
Senior+

Як масштабувати й профілювати Node.js-сервіс?

Section titled “Як масштабувати й профілювати Node.js-сервіс?”
Відповідь

Що це

  • Масштабування — запуск кількох процесів, щоб задіяти всі ядра й машини.
  • Профілювання — вимірювання, на що йдуть CPU і пам’ять.

Як користуватися

  • Кілька екземплярів за балансувальником: Nginx, Kubernetes Service.
  • Важке обчислення — у worker_threads, щоб не блокувати event loop.
  • node --inspect app.js, потім chrome://inspect у Chrome: вкладки Performance і Memory.

Типова помилка

Зберігати сесії або кеш у пам’яті процесу. За кількох екземплярів кожен бачить своє, тому спільний стан тримають у Redis або в базі.

Коли що обирати

Спосіб Коли
Кілька подів або контейнерів за замовчуванням у Kubernetes і Docker
cluster або PM2 одна машина без оркестратора
worker_threads CPU-задача всередині процесу: хешування, парсинг, обробка зображень

Як влаштовано

  • cluster. Головний процес приймає з’єднання й роздає їх робочим процесам по колу (round-robin; на Windows — ні). Пам’ять у процесів не спільна.
  • worker_threads. Потоки в одному процесі, у кожного свій ізолят V8, купа й event loop. Дані передають через postMessage (копія або передача володіння) чи SharedArrayBuffer.
  • Документація Node прямо каже: воркери корисні для CPU-роботи й майже не допомагають із вводом-виводом.

Пастки

  • Воркер на кожен запит — дорогий старт. Використовують пул воркерів, наприклад бібліотеку piscina.
  • Типові витоки: глобальний Map як кеш без обмеження, незакриті таймери, слухачі подій, яких ніхто не знімає. Останнє підсвічує MaxListenersExceededWarning.

Правило

Уперлися у ввід-вивід — більше екземплярів. Уперлися в CPU — воркери або окремий сервіс.

Під капотом: пам’ять

  • Node передає V8 ліміт пам’яті з урахуванням cgroups. За замовчуванням старе покоління купи отримує близько половини пам’яті контейнера, зі стелею в кілька гігабайтів.
  • Пам’ять процесу (RSS) — це купа плюс Buffer поза купою, код і стеки воркерів. Тому --max-old-space-size ставлять із запасом до ліміту контейнера. З Node 24.7 є --max-old-space-size-percentage.
  • Купа вперлася в ліміт — FATAL ERROR: Reached heap limit зі стеком. Контейнер уперся раніше — SIGKILL і код 137 без діагностики.

Як перевірити в продакшені

  • --heapsnapshot-near-heap-limit=2 пише знімки купи перед OOM, --heapsnapshot-signal=SIGUSR2 — за сигналом. Знімок блокує потік і потребує пам’яті приблизно вдвічі більше за купу.
  • Два знімки порівнюють у DevTools (Memory → Comparison): шукають типи об’єктів, що ростуть, і ланцюжки утримання.
  • kill -USR1 <pid> вмикає інспектор у працюючого процесу. --inspect слухає 127.0.0.1:9229, назовні його не відкривають.
  • Трейси й метрики — OpenTelemetry: SDK завантажують до застосунку (--import або --require). Бібліотеки публікують події через diagnostics_channel, без підміни модулів.

Компроміси: версії

  • Node 24 — Active LTS до 20 жовтня 2026, потім maintenance до квітня 2028. Node 22 — maintenance до квітня 2027. Node 20 не підтримується з квітня 2026.
  • З Node 27 виходить одна мажорна версія на рік, у квітні, і кожна стає LTS у жовтні. Непарних Current-версій більше немає.

Підсумок: масштабують процесами, пам’ять налаштовують під ліміт контейнера, витоки ловлять знімками купи, а версію беруть з LTS.

Приклад
// fib.mjs — ES-модуль: CPU-робота у worker_threads
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
if (isMainThread) {
let ticks = 0;
const timer = setInterval(() => ticks++, 10); // головний цикл продовжує працювати
const worker = new Worker(new URL(import.meta.url), { workerData: 38 });
worker.once('message', (result) => {
clearInterval(timer);
console.log(result, ticks > 10); // 39088169 true
});
} else {
const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2)); // рахується в окремому потоці
parentPort.postMessage(fib(workerData));
}

Якщо порахувати fib(38) у головному потоці, setInterval не спрацює жодного разу, доки обчислення не закінчиться.