Рантайм Node.js
Як влаштований Node.js під бекендом: модулі й пакети, event loop і libuv, потоки даних, помилки й зупинка, масштабування та діагностика. На співбесіді .NET-розробника тут перевіряють, чи розуміє він, чим один потік подій відрізняється від пулу потоків ASP.NET Core і чим це загрожує в продакшені.
Перед цією темою: JavaScript і TypeScript.
Як влаштовані модулі й пакети в 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" |
Як користуватися
npm install expressзаписує залежність уpackage.jsonі ставить пакет уnode_modules.package-lock.jsonфіксує точні версії всього дерева — його комітять.- У 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-levelawait, буде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 undefinedconsole.log(import.meta.filename.endsWith('main.mjs')); // true
const source = await readFile(import.meta.filename, 'utf8'); // top-level await — лише в ESMconsole.log(source.length > 0, typeof os.cpus); // true function// 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_SYNTAXconst Status = { New: 'new', Paid: 'paid' } as const; // заміна enum без трансформаціїtype Status = (typeof Status)[keyof typeof Status];const s: Status = Status.Paid;console.log(s); // paid- Чим
^1.2.3відрізняється від~1.2.3? — Перший дозволяє нові minor і patch, другий — лише patch. - Навіщо комітити
package-lock.json? — Щоб усі машини й CI ставили те саме дерево версій. - Чи можна викликати
requireдля ES-модуля? — З Node 22.12 так, якщо в його графі немає top-levelawait. - Що таке фантомна залежність? — Імпорт пакета, якого немає у вашому
package.json, але який npm підняв у пласкийnode_modules. - Чи перевіряє Node типи під час запуску
.ts? — Ні, він лише стирає їх; перевірка —tsc --noEmit.
Як Node.js обробляє тисячі з’єднань в одному потоці?
Section titled “Як Node.js обробляє тисячі з’єднань в одному потоці?”Що це
- Node.js — середовище виконання JavaScript поза браузером: рушій V8 плюс бібліотека libuv для вводу-виводу.
- Event loop — цикл, який бере готові події та по одній викликає їхні колбеки. Подія — прийшов запит, база відповіла, спрацював таймер.
Як це працює на сервері
- Прийшов запит — обробник надсилає запит до бази й на
awaitповертає керування. - Потік вільний і приймає наступні запити.
- База відповіла — продовження обробника потрапляє в чергу, і event loop його виконує.
Типова помилка
Синхронна робота в обробнику: fs.readFileSync, JSON.parse величезного рядка, важкий цикл. Поки вона триває, сервер не відповідає нікому.
Як влаштовано
Основні фази одного витка циклу, по черзі:
- timers — колбеки
setTimeoutіsetInterval, у яких сплив термін. - poll — нові події вводу-виводу та їхні колбеки. Якщо робити нічого, цикл чекає тут.
- check — колбеки
setImmediate. - 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 — CommonJSconst 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});Числа в коментарях — порядок виводу.
// delay.cjs — CommonJSconst { 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 усі шість хешів завершуються приблизно одночасно.
- Що виконається раніше:
process.nextTickчиPromise.then? — У CommonJSnextTick; в ES-модулі на верхньому рівні — проміс. - Які операції йдуть у пул libuv? — Файли,
dns.lookup,crypto.pbkdf2іscrypt, асинхроннийzlib; сокети — ні. - Навіщо
setImmediate, якщо єsetTimeout(fn, 0)? — Він виконується одразу після вводу-виводу поточного витка й не залежить від таймерів. - Як зрозуміти, що цикл блокується? —
monitorEventLoopDelayіeventLoopUtilization, потім CPU-профіль. - Чим модель відрізняється від ASP.NET Core? — Там запити йдуть у пулі потоків на всіх ядрах; у Node — один потік JS на процес.
Як працювати з потоками даних і помилками в 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небезпечно.
Коректна зупинка
docker stopабо Kubernetes надсилає SIGTERM, після терміну — SIGKILL.server.close()перестає приймати з’єднання. З Node 19 він одразу закриває keep-alive з’єднання, що простоюють.- Активних запитів чекають із тайм-аутом, коротшим за термін оркестратора — у Kubernetes 30 секунд за замовчуванням.
- Закрити пули БД і клієнти брокерів, потім вийти з кодом 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 закрив і джерело}// 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(); // страховка від завислих запитів});- Що таке backpressure і як його дотримується
pipeline? — Приймач гальмує джерело:write()повернувfalse— чекаємо на'drain';pipelineробить це сам. - Чим
pipelineкращий за.pipe()? — Передає помилки й знищує всі потоки, а зnode:stream/promisesйого можна чекати черезawait. - Що буде, якщо в
EventEmitterнемає слухача'error'? —emit('error')викине виняток, і процес упаде. - Чи можна продовжувати роботу після
uncaughtException? — Ні: залогувати й завершитися, перезапуск зробить оркестратор. - Чому контейнер із
npm startдовго зупиняється? — npm не передає SIGTERM процесу Node, і той отримує SIGKILL за тайм-аутом.
Як масштабувати й профілювати 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_threadsimport { 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 не спрацює жодного разу, доки обчислення не закінчиться.
- Чим
clusterвідрізняється відworker_threads? — cluster — процеси зі спільним портом і окремою пам’яттю; воркери — потоки в одному процесі з власними ізолятами. - Чи потрібен cluster у Kubernetes? — Зазвичай ні: масштабують подами, а оркестратор сам перезапускає й балансує.
- Як знайти витік пам’яті? — Два-три heap snapshot під навантаженням, порівняння в DevTools, пошук об’єктів, що ростуть, і посилань, що їх утримують.
- Чому контейнер убито з кодом 137, а
heap out of memoryу лозі немає? — Ліміт контейнера скінчився раніше за ліміт купи V8; зменшити--max-old-space-size. - Яку версію Node ставити в продакшен? — LTS: Active або Maintenance; на жовтень 2026 — Node 24, після 28 жовтня — і Node 26.