О разделе

Node.js и NestJS разбираются вместе: сначала рантайм, потом фреймворк поверх него. Большая часть ошибок в Node растёт из непонимания event loop, поэтому он идёт первым.

Главная параллель для тех, кто пришёл из Laravel. NestJS устроен очень похоже: модули вместо сервис-провайдеров, DI-контейнер, DTO вместо FormRequest, guards вместо middleware авторизации. Разница в рантайме: PHP-процесс умирает после запроса, Node-процесс живёт и держит состояние — отсюда и утечки памяти, и необходимость graceful shutdown.
Общая картина: как живёт процесс Node
Одна схема, из которой следует весь раздел

Если вы пришли из PHP, самое важное отличие Node не в синтаксисе и не в пакетах. Оно в том, кто живёт между запросами. В привычной связке Nginx и PHP-FPM на каждый запрос берётся отдельный процесс-воркер: он поднимает фреймворк, собирает ответ, отдаёт его и умирает. Всё, что он успел положить в переменные, исчезает вместе с ним — именно поэтому в Laravel вы не задумываетесь, не утечёт ли память и не увидит ли следующий посетитель чужие данные. Каждый запрос начинается с чистого листа.

Node устроен наоборот. Процесс запускается один раз и дальше работает неделями, обслуживая все запросы подряд. Ваш файл выполняется не на каждый запрос, а однократно при старте — он лишь расставляет обработчики и говорит: «вот что делать, когда придёт событие». После этого управление уходит в бесконечный цикл, который принимает события и вызывает ваши функции. Код внутри этих функций выполняется в одном-единственном потоке.

PHP-FPM Node.js запрос → свой процесс запрос ─┐ ↓ запрос ─┼→ ОДИН процесс, выполнение запрос ─┤ живёт неделями ↓ запрос ─┘ ответ → процесс умер ↓ переменные исчезли ответ переменные остались

Из этих двух абзацев вырастает всё остальное в разделе, поэтому к ним стоит возвращаться. Разберём, что именно следует.

СледствиеПочему такГде об этом подробно
Нельзя делать долгие вычисления прямо в обработчикеПоток один. Пока он занят вашим циклом, он не обслуживает никого — ни один из тысяч открытых запросовEvent loop, Thread pool
Ожидание сети и диска не тормозит серверЖдёт не ваш поток, а операционная система. Поток в это время обслуживает другихВвод-вывод и libuv
Данные в переменных переживают запросПроцесс не умирает. Это даёт бесплатный кэш в памяти — и утечки, если складывать туда лишнееУтечки памяти
Процесс нужно гасить аккуратноВ момент остановки внутри него могут быть недообработанные запросы, которых в PHP просто не бываетGraceful shutdown
Ошибка способна уронить весь серверНеобработанное исключение валит процесс, а с ним — все текущие соединения, а не один запросОшибки в async-коде
Короткая формула, которую стоит запомнить. PHP-процесс — одноразовая посуда: взяли, использовали, выбросили. Node-процесс — рабочее место, за которым сидит один сотрудник и очень быстро переключается между задачами. Он успевает обслужить сотни посетителей, но если дать ему одну долгую задачу, очередь встанет целиком.
Что происходит при запуске

Команда node app.js запускает процесс операционной системы, внутри которого поднимаются две части рантайма: V8 — тот самый движок из Chrome, он исполняет ваш JavaScript, и libuv — библиотека на C, которая умеет работать с сетью, файлами и таймерами, не блокируя поток. Дальше Node выполняет служебную подготовку, загружает ваш файл и проходит его сверху вниз ровно один раз.

Вот место, где у людей из мира PHP чаще всего ломается модель. Строка app.listen(3000) не начинает обрабатывать запросы и не блокирует программу — она лишь регистрирует намерение и немедленно возвращает управление. Когда ваш файл дочитан до конца, Node переходит в цикл ожидания событий, и только там начинается настоящая работа. Поэтому код, написанный «после сервера», выполнится сразу же, а не после первого запроса.

node app.js │ ├─ 1. Поднимаются V8 и libuv │ ├─ 2. Служебная подготовка: process, console, загрузчик модулей │ ├─ 3. Ваш файл выполняется сверху вниз — ОДИН раз. │ Здесь только регистрация обработчиков: │ «создать сервер», «подписаться», «завести таймер» │ ├─ 4. Файл кончился. Управление уходит в цикл ожидания событий │ ├─ 5. Цикл крутится, пока есть незакрытые дела: │ открытый сервер, таймер, запрос к базе │ └─ 6. Дел не осталось → процесс завершается сам

Отсюда же ответ на вопрос «почему мой скрипт не завершается». Пока у libuv есть хотя бы одно незакрытое дело — слушающий сокет, живой интервал, незавершённый запрос — цикл продолжает работать. Как только их не остаётся, процесс выходит самостоятельно, без всякого exit.

Аналогия с Laravel: где здесь ядро

В Laravel путь запроса понятен и неизменен: public/index.php поднимает приложение через bootstrap/app.php, создаётся ядро, и Kernel::handle($request) прогоняет запрос через конвейер middleware к контроллеру. Этот путь повторяется для каждого запроса с самого начала.

В Node ядра в этом смысле нет. Роль диспетчера играет цикл ожидания событий: он получает событие «пришёл HTTP-запрос» и вызывает ту функцию, которую вы зарегистрировали при старте. Разница в том, что конвейер Laravel собирается заново на каждый запрос, а цикл поднимается однажды и живёт, пока жив процесс.

Laravel на PHP-FPMNode.js
Точка входаpublic/index.php — заново на каждый запросapp.js — один раз при старте
Сборка приложенияbootstrap/app.php: контейнер, провайдерыВаш код верхнего уровня, один проход
Кто вызывает ваш кодЯдро: Kernel::handle()Цикл ожидания событий
Состояние между запросамиНе сохраняетсяСохраняется
Цена утечки памятиПочти нулевая: процесс всё равно умрётНакапливается неделями, пока не придёт OOM
Что спросят по этой теме
ВопросКороткий ответ
Чем модель Node отличается от PHP-FPM?Один долгоживущий процесс с одним потоком JS против процесса на каждый запрос. Отсюда общее состояние, кэш в памяти, утечки и необходимость аккуратного завершения
Почему Node называют однопоточным, если он держит тысячи соединений?В одном потоке выполняется только ваш JavaScript. Само ожидание сети и диска происходит вне его, поэтому поток не простаивает
Что будет, если в обработчике посчитать что-то тяжёлое?Встанут все запросы процесса, а не только текущий
Почему процесс не завершается после выполнения скрипта?У libuv остались незакрытые дела: сервер, таймер или незавершённая операция
Что такое Node.js
Не язык и не фреймворк

Node.js — это среда выполнения (runtime) для JavaScript вне браузера. Язык остался тот же самый — JavaScript. Изменилось окружение: вместо DOM, window и запросов к серверу появились файловая система, сеть, процессы и всё, что нужно серверной программе.

Что этоПримерNode.js
ЯзыкJavaScript, PHP, PythonНет — язык здесь JavaScript
Среда выполненияPHP-FPM, JVM, CPythonДа, это она
ФреймворкLaravel, NestJS, ExpressНет — они пишутся поверх Node
Аналогия с PHP. Node.js занимает то же место, что PHP-FPM: он берёт твой код и исполняет его на сервере. NestJS в этой картине — Laravel, а Express — ближе к Slim. Сравнивать «PHP и Node» некорректно: сравнивать нужно либо языки (PHP и JavaScript), либо рантаймы (PHP-FPM и Node.js).
Для чего подходит и для чего нет
Сильная сторонаСлабая сторона
Много одновременных соединений при лёгкой обработке: API, чаты, стриминг, проксиТяжёлые вычисления: обработка изображений, криптография в цикле, разбор гигабайтных файлов
Реалтайм: WebSocket, long polling, SSEЗадачи, где нужен настоящий параллелизм на одном процессе
Один язык на фронте и бэке, общие типы через TypeScriptДолгие синхронные операции — они блокируют всех
Откуда растут и плюсы, и минусы. Из одной архитектурной идеи: один поток для кода плюс неблокирующий ввод-вывод. Пока программа ждёт БД или сеть, процесс занимается другими запросами — отсюда тысячи соединений на одном процессе. Но если программа считает, а не ждёт, этот же поток занят, и ждут уже все остальные.
Установка Node.js
JavaScript и Node.js — в чём разница
АспектJavaScript — языкNode.js — среда выполнения
Что этоЯзык программирования, стандарт ECMAScriptПлатформа, исполняющая JS вне браузера
Где работаетВ браузереНа сервере или локально, отдельным процессом
ДвижокДвижок конкретного браузера: V8, SpiderMonkey, JavaScriptCoreV8 из Chrome плюс libuv и системные библиотеки
Доступные APIБраузерные: DOM, window, localStorage, fetchСерверные: fs, http, net, stream, child_process
Глобальный объектwindowglobal (и globalThis в обоих)
ПрименениеИнтерактивные страницы, фронтендБэкенд, API, CLI-инструменты, десктоп через Electron
Аналогия. JavaScript — мотор, Node.js — шасси, на которое этот мотор ставят, чтобы он работал не только в браузере. Node даёт коду доступ к системным ресурсам, которых в браузере нет по соображениям безопасности.
Node.js не версия и не замена JavaScript. Это инструмент, расширяющий сферу применения языка. Один и тот же Array.map работает и там, и там — различается только окружение вокруг.
Установка
# macOS — через Homebrew, стандартный путь
brew install node              # последняя версия
brew install node@22           # конкретная major-версия

# Windows
winget install OpenJS.NodeJS.LTS
# либо .msi-установщик с nodejs.org

# Ubuntu/Debian — в репозитории обычно устаревшая версия,
# поэтому ставят из NodeSource
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
# Проверка — npm ставится вместе с Node
node --version
npm --version
Менеджеры версий

На разных проектах нужны разные версии Node. Менеджер версий держит их рядом и переключает одной командой — примерно как brew link для PHP.

# nvm — самый распространённый (macOS, Linux)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash

nvm install --lts      # поставить последнюю LTS
nvm install 22         # конкретную major
nvm use 22             # переключиться
nvm ls                 # что установлено
nvm alias default 22   # версия по умолчанию в новых сессиях
МенеджерОсобенность
nvmСтандарт де-факто. Shell-функция, поэтому which nvm ничего не покажет
fnmТо же самое, но на Rust — заметно быстрее при старте оболочки
voltaПрибивает версию к проекту: заходишь в папку — версия переключается сама
HomebrewНе менеджер версий. Держит одну версию, переключение через brew unlink / brew link
Файл .nvmrc в корне проекта с номером версии плюс nvm use — способ зафиксировать версию для всей команды. Аналог "engines" в package.json, только исполняемый.
LTS против Current — что ставить
ЛинияНомер версииПоддержкаДля чего
LTSЧётный: 20, 22, 24~30 месяцевПрод и всё, что должно работать долго
CurrentНечётный: 21, 23, 25~6 месяцевПосмотреть новые возможности
Нечётная версия в проде — мина замедленного действия. Поддержка кончается примерно через полгода, дальше нет даже патчей безопасности. Плюс часть пакетов просто не заявляет совместимость с Current-линией. Правило простое: чётный номер — ставим, нечётный — только поэкспериментировать.
Проверить, на чём работаешь сейчас: node -v. Зафиксировать требование в проекте: "engines": { "node": ">=22" } в package.json — npm предупредит при несовпадении.
Из чего состоит Node.js
Четыре слоя
┌─────────────────────────────────────────────┐ │ Твой код + пакеты из npm │ ├─────────────────────────────────────────────┤ │ Стандартная библиотека Node │ fs, http, path, crypto, │ (58 встроенных модулей) │ events, stream, os… ├─────────────────────────────────────────────┤ │ Bindings — мост из JavaScript в C++ │ ├──────────────────────┬──────────────────────┤ │ V8 │ libuv │ │ исполняет JS │ event loop, │ │ (движок из Chrome) │ неблокирующий I/O, │ │ │ пул потоков │ ├──────────────────────┴──────────────────────┤ │ OpenSSL · zlib · c-ares · llhttp · undici │ шифрование, сжатие, │ │ DNS, HTTP-парсер, fetch └─────────────────────────────────────────────┘
КомпонентЗа что отвечает
V8Движок JavaScript от Google, тот же, что в Chrome. Компилирует JS в машинный код и исполняет его
libuvСердце асинхронности: event loop, неблокирующая работа с сетью и файлами, пул потоков. Написан на C
BindingsПрослойка, через которую JS вызывает C++-функции — например, fs.readFile
OpenSSLКриптография и TLS: модуль crypto, HTTPS
llhttpПарсер HTTP-протокола
undiciHTTP-клиент, на котором работает встроенный fetch
zlib, c-ares, zstdСжатие и DNS-резолвинг
# Посмотреть, что внутри именно твоей сборки
node -p "process.versions"

# Пример вывода (Node 25):
# node: '25.2.1', v8: '14.1.146', uv: '1.51.0',
# openssl: '3.6.2', undici: '7.16.0', zlib: '1.2.12', ...
Ключевая мысль. V8 умеет только исполнять JavaScript — он ничего не знает ни о файлах, ни о сокетах. Всё, что делает Node серверной платформой, даёт libuv и остальные C-библиотеки. Поэтому event loop — это не часть языка, а часть рантайма.
Состояние между запросами
Продолжение общей картины. Там мы увидели, что процесс Node живёт неделями, а переменные переживают запрос. Здесь — что из этого следует на практике и какой код начинает вести себя иначе.
Что меняется в сравнении с PHP
PHP-FPMNode.js
Жизнь процессаОдин запросНедели, пока не перезапустят
Глобальные переменныеУмирают с запросомЖивут и видны всем запросам
ПараллельностьНесколько процессов-воркеровОдин поток + неблокирующий I/O
Соединение с БДОбычно одно на запросПул, общий на весь процесс
Утечка памятиПочти незаметна — процесс умрётКопится и роняет сервис
Тяжёлый циклТормозит только свой запросТормозит все соединения
ОстановкаМожно убивать в любой моментНужен graceful shutdown
Это сдвиг мышления номер один при переходе с PHP. Привычка «переменная живёт один запрос» в Node становится источником трёх классов багов разом: утечки памяти, протекание данных одного пользователя к другому, недетерминированное поведение из-за накопленного состояния.
Как это выглядит в коде
// В PHP безобидно, в Node — утечка и протекание данных
let currentUser;                  // модульная переменная, одна на весь процесс
const cache = new Map();          // растёт бесконечно

export function handler(req, res) {
  currentUser = req.user;          // параллельный запрос перетрёт
  cache.set(req.id, req.body);     // никто никогда не чистит
}

// Состояние запроса живёт в самом запросе
export function handler(req, res) {
  const user = req.user;           // локально, умрёт с вызовом
}
В NestJS за это отвечает DI: сервисы по умолчанию синглтоны на весь процесс, и класть в их поля данные запроса нельзя. Для данных запроса — аргументы методов, AsyncLocalStorage или scope REQUEST.
Первая программа и встроенные модули
HTTP-сервер без единого пакета
// server.js — всё из коробки, npm install не нужен
import { createServer } from 'node:http';

const server = createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ ok: true, url: req.url }));
});

server.listen(3000, () => console.log('http://localhost:3000'));
node server.js
Разница с PHP видна сразу. В PHP веб-сервер — отдельная программа (Nginx, Apache), а код запускается по запросу. В Node сервер — это твоя программа: она сама слушает порт и живёт, пока её не остановят. Nginx перед ней обычно ставят, но уже как reverse proxy, а не как исполнителя кода.
Стандартная библиотека

В Node 58 встроенных модулей. Подключаются без установки, с префиксом node:.

МодульДля чего
node:http, node:httpsHTTP-сервер и клиент
node:fs, node:fs/promisesФайловая система
node:pathРабота с путями, кроссплатформенно
node:cryptoХеши, HMAC, шифрование, UUID
node:eventsEventEmitter — на нём построена половина Node
node:streamПотоки и pipeline
node:os, node:processСистема, переменные окружения, сигналы
node:child_processЗапуск внешних команд
node:worker_threadsНастоящие потоки для CPU-задач
node:testВстроенный тест-раннер
// Посмотреть полный список
node -p "require('module').builtinModules.join('\n')"
Префикс node: не обязателен (import 'fs' тоже работает), но желателен: он явно говорит, что это встроенный модуль, а не пакет из node_modules с таким же именем.
node:path — пути к файлам, а не маршруты
Источник путаницы. По-русски «путь» означает и файловый путь, и URL-маршрут. Это разные вещи, и path знает только о первом: о файлах и папках на диске. К маршрутизации HTTP он отношения не имеет.
Что этоПримерЧем работать
Файловый путь./data/users.json, C:\app\logsnode:path, node:fs
URL-маршрут/users/123, /api/orders?status=newExpress, Fastify, NestJS, req.url
URL как объектhttps://site.com/a?b=1Глобальный URL, node:url
// Маршрут — это URL-шаблон, path тут ни при чём
app.get('/users/:id', (req, res) => {
  res.json({ id: req.params.id });   // '123' из /users/123
});

// Разбор URL — третья вещь, тоже не path
const u = new URL('https://site.com/users/123?tab=posts');
u.pathname;                   // '/users/123' — URL-путь, не файловый
u.searchParams.get('tab');   // 'posts'
URL-пути всегда через /, независимо от операционной системы. Поэтому собирать их через path.join — ошибка: на Windows получишь \ внутри адреса.
Зачем нужен path

Главная задача — кроссплатформенность. В Windows разделитель \, в Linux и macOS — /. Склеивание строк через '/' вручную ломается на Windows.

import path from 'node:path';

path.join('users', 'data', 'file.txt');
// POSIX:   'users/data/file.txt'
// Windows: 'users\data\file.txt'

path.resolve('file.txt');       // абсолютный путь от текущей папки
path.extname('index.html');      // '.html'
path.basename('/foo/baz.txt');   // 'baz.txt'
path.dirname('/foo/baz.txt');    // '/foo'
path.sep;                        // '/' или '\' в зависимости от ОС

path.parse('/home/user/file.txt');
// { root: '/', dir: '/home/user', base: 'file.txt',
//   ext: '.txt', name: 'file' }
join против resolve. join просто склеивает куски и нормализует разделители. resolve строит абсолютный путь, идя справа налево до первого абсолютного сегмента, и достраивает остальное от текущей рабочей директории. Для «файл рядом со скриптом» нужен resolve или import.meta.dirname, потому что рабочая директория зависит от того, откуда запустили процесс.
Путь из пользовательского ввода — дыра обхода каталога. path.join(uploadDir, req.query.name) при name=../../etc/passwd выведет за пределы папки. Проверять надо после нормализации — и сравнивать не с самой папкой, а с папкой плюс разделитель, иначе проверку пройдёт соседний каталог с похожим именем.
import path from 'node:path';

const uploadDir = path.resolve('/var/www/uploads');
const target    = path.resolve(uploadDir, req.query.name);

// так нельзя: '/var/www/uploads-secret/key' тоже начинается с uploadDir
if (target.startsWith(uploadDir)) { /* ... */ }

// правильно: добавляем разделитель, тогда совпадёт только вложенное
if (target.startsWith(uploadDir + path.sep)) {
  // путь точно внутри uploadDir
}
Что делает каждый встроенный модуль
МодульЧто даёт
node:http / node:httpsHTTP-сервер через createServer и клиент для запросов. На нём стоят Express, Fastify и почти весь веб в Node
node:fs / node:fs/promisesЧтение и запись файлов и папок. Версия /promises — тот же API, но под async/await
node:pathСборка и разбор файловых путей, кроссплатформенно
node:url + глобальный URLРазбор и сборка URL, работа с query-параметрами
node:cryptoХеши (createHash), HMAC, шифрование, randomUUID, случайные байты
node:eventsEventEmitter. На событиях построены серверы, потоки и сокеты: on / emit
node:streamОбработка данных по частям без загрузки в память. pipeline() связывает потоки и ловит ошибки
node:osИнформация о системе: cpus(), platform(), объём памяти
node:processТекущий процесс: env, argv, exit(), обработка сигналов
node:child_processЗапуск внешних программ: exec, spawn — например git или ffmpeg
node:worker_threadsНастоящие потоки ОС для CPU-задач, чтобы не блокировать event loop
node:testВстроенный тест-раннер (node --test) — лёгкая замена Jest без зависимостей
Префикс node: — зачем он на самом деле
Частое объяснение неверно. Пишут, что префикс защищает от подмены встроенного модуля одноимённым пакетом из node_modules. Это не так: Node резолвит встроенные модули раньше node_modules. Пакет с именем fs встроенный fs не перекроет — проверяется за минуту:

mkdir -p node_modules/fs && echo 'module.exports={HACKED:1}' > node_modules/fs/index.js
node -e "console.log(require('fs').HACKED ?? 'встроенный')" → встроенный
Настоящая причинаПояснение
Новые модули иначе не подключитьrequire('test') даёт MODULE_NOT_FOUND, работает только require('node:test'). Так же с node:sqlite
ЯвностьСразу видно, что это часть Node, а не зависимость из package.json
Скорость резолваNode не ищет по файловой системе — сразу берёт встроенный
Для старых модулей import 'fs' и import 'node:fs' равнозначны. Привычка писать префикс везде полезна тем, что код единообразен и не ломается на новых модулях.
package.json и npm
Манифест проекта
{
  "name": "my-api",
  "type": "module",          // ESM: import/export. Без этого — CommonJS
  "engines": { "node": ">=22" },
  "scripts": {
    "dev":   "node --watch src/main.js",
    "start": "node src/main.js",
    "test":  "node --test"
  },
  "dependencies":    { "@nestjs/core": "^11.0.0" },
  "devDependencies": { "typescript": "^5.7.0" }
}
ПонятиеАналог в PHPСмысл
package.jsoncomposer.jsonМанифест: зависимости, скрипты, метаданные
package-lock.jsoncomposer.lockТочные версии всего дерева. В Git обязательно
node_modules/vendor/Сами пакеты. В Git не кладут
dependenciesrequireНужны в проде
devDependenciesrequire-devТолько для разработки: TypeScript, тесты, линтер
npm cicomposer installСтрого по lock-файлу. Для CI и прода
npm installcomposer update (частично)Может обновить lock в рамках диапазонов
npm install на проде — та же ошибка, что composer update. Он может подтянуть версии, которых никто не тестировал, и переписать lock прямо на сервере. В CI и на проде только npm ci: он удаляет node_modules и ставит ровно то, что в lock-файле.
Команды на каждый день
npm init -y                  # создать package.json
npm install express          # поставить в dependencies
npm install -D typescript    # -D → devDependencies
npm uninstall express        # удалить
npm ci                       # установка по lock — прод и CI
npm run dev                  # запустить скрипт из "scripts"
npm outdated                 # что устарело
npm audit                    # известные уязвимости
npx tsc --init               # выполнить пакет без установки
npx — запуск бинарника пакета без глобальной установки. Именно так создают проекты: npx @nestjs/cli new my-app.
I/O: что это и почему вокруг него построен Node
Где мы находимся. Ввод-вывод — то, ради чего в рантайме вообще есть libuv. Этот раздел объясняет, почему ожидание нельзя блокировать; как устроен сам цикл — в разделе «Event loop и фазы».
Что считается вводом-выводом

I/O (input/output) — любой обмен данными с внешним миром относительно процесса: чтение и запись файлов, сетевые запросы, сокеты, работа с базой, stdin/stdout, DNS-запросы. Общее у них одно: нужно ждать ответа от диска, сети или другого процесса, и это ожидание программа не контролирует.

Ключевая мысль. I/O — это медленно, и его нельзя блокировать. Пока один запрос ждёт диск или сеть, Node обязан обслуживать остальные. Ровно этим занимается libuv.
Почему ожидание — это проблема

Процессор работает на порядки быстрее внешних устройств. Отсюда и вся асинхронная модель:

ОперацияПримерное времяВо сколько раз медленнее инструкции CPU
Выполнить инструкцию CPU~0.3 нс1×
Прочитать из RAM~100 нс~300×
Прочитать с SSD~100 000 нс (0.1 мс)~300 000×
Сетевой запрос внутри дата-центра~0.5–1 мс~2 000 000×
Запрос к БД~1–50 мсдо ~150 000 000×
Сеть через интернет~50–500 мсдо ~1 500 000 000×

Если программа «встаёт и ждёт» диск или сеть — процессор простаивает. Классический сервер на потоках решает это так: на каждый запрос отдельный поток, и потоки просто спят в ожидании I/O. При тысячах соединений это дорого и по памяти, и по переключениям контекста.

Блокирующий против неблокирующего

Одно и то же чтение файла двумя способами:

// Блокирующий: поток Node замирает, пока файл читается
const fs = require('node:fs');
const data = fs.readFileSync('big.txt', 'utf8');
console.log('прочитано', data.length);
// следующие строки не выполнятся, пока чтение не закончится

// Неблокирующий: Node работает дальше, результат придёт позже
const fs = require('node:fs');
fs.readFile('big.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log('прочитано', data.length);
});
console.log('эта строка выполнится сразу, не дожидаясь файла');

В асинхронном варианте Node отдаёт чтение системе через libuv, а сам возвращается в event loop и обслуживает другие запросы. Когда данные готовы — вызывается колбэк или резолвится промис.

async/await — тот же механизм, просто записанный линейно:

const fs = require('node:fs/promises');

async function main() {
  const data = await fs.readFile('big.txt', 'utf8'); // event loop не блокируется
  console.log('прочитано', data.length);
}
main();
Частая ошибка. await не делает код «параллельным» — он лишь не блокирует event loop на время ожидания. Два последовательных await выполнятся один за другим; чтобы они шли одновременно, нужен Promise.all.
Две части libuv

libuv — C-библиотека, дающая Node кроссплатформенный неблокирующий I/O. В ней две ключевые части.

1. Event loop — диспетчер. Бесконечный цикл, который забирает у ОС готовые события («пришли данные по сокету», «сработал таймер», «файл прочитан»), вызывает соответствующие JS-колбэки, проверяет отложенные задачи, а если делать нечего — засыпает и просыпается на новом событии. Именно поэтому один поток JS обслуживает тысячи соединений: он не ждёт каждое, а реагирует на события. Подробный разбор фаз — в разделе Event loop и фазы.

2. Пул потоков. Некоторые операции ОС не умеет делать асинхронно — например, чтение файлов на Linux. Для них libuv держит пул фоновых потоков (по умолчанию 4, меняется через UV_THREADPOOL_SIZE): они выполняют блокирующую работу, а event loop получает только результат. Что именно уходит в пул, а что операционная система умеет делать сама — разобрано в разделе Thread pool и блокировка.

Следствие, о которое спотыкаются. Запустите пять тяжёлых fs.readFile одновременно при пуле из четырёх потоков — пятый будет ждать освобождения. Это частая причина «загадочных» задержек, которые не объясняются ни сетью, ни базой.
Путь одного HTTP-запроса через слои

Как части схемы из раздела «Из чего состоит» работают вместе:

1. Запрос приходит по сети └─ ОС сообщает libuv через epoll / kqueue / IOCP 2. Event loop видит событие «данные на сокете» └─ вызывает JS-колбэк через Bindings (мост JS ↔ C++) 3. V8 исполняет твой обработчик (роут Express или Nest) 4. Обработчик читает файл: ├─ fs.readFile → libuv кладёт задачу в пул потоков └─ event loop свободен и обслуживает другие запросы 5. Пул потоков прочитал файл → событие в event loop └─ вызывается колбэк / резолвится промис → формируется ответ 6. Ответ уходит в сокет через libuv → ОС → сеть

Нижние слои схемы — конкретные C-библиотеки, на которых стоят встроенные модули: OpenSSL (node:crypto, HTTPS/TLS), zlib (сжатие, gzip в HTTP), c-ares (асинхронный DNS в dns.resolve*), llhttp (парсер HTTP), undici (HTTP-клиент, на котором работает глобальный fetch).

I/O-bound против CPU-bound

Node оптимизирован под I/O-bound задачи — где время уходит на ожидание сети и диска, а не на вычисления: API-серверы, прокси, real-time, чаты, стриминг. Здесь он силён.

Но JS исполняется в одном потоке V8. Тяжёлые вычисления — сортировка гигантского массива, обработка изображений, криптография на чистом JS — останавливают event loop, и все соединения ждут, хотя никакого I/O нет:

// Блокирует event loop на секунды — все запросы повиснут
function heavyCpu() {
  let sum = 0;
  for (let i = 0; i < 1e10; i++) sum += i;
  return sum;
}

// Уводим в worker thread — event loop свободен
const { Worker } = require('node:worker_threads');
new Worker('./heavy-worker.js');
Куда выносить CPU-работуКогда это правильный выбор
node:worker_threadsНастоящие потоки внутри процесса, есть общая память через SharedArrayBuffer
node:child_processОтдельный процесс — изоляция памяти, можно запускать не-JS
Нативные асинхронные реализацииcrypto и zlib уже уходят в пул потоков — не пишите своё на чистом JS
Короткая формула
I/O — это ожидание внешнего мира. Неблокирующий I/O — способ ждать, не останавливая программу. libuv организует это ожидание: event loop реагирует на события, пул потоков выполняет блокирующую часть, а V8 тем временем спокойно исполняет JavaScript.

Поэтому Node и называют однопоточным, но конкурентным: один поток JS — и множество одновременных I/O-операций.
Event loop и фазы
Где мы находимся. Event loop — цикл внутри libuv, одной из двух опор рантайма. Node входит в него после того, как выполнил ваш файл сверху вниз, и крутит, пока есть незакрытые дела. Фазы — очереди внутри этого цикла, отдельно от него они не существуют.
Кто внутри кого: карта понятий

Короткая иерархия, к которой можно возвращаться при чтении дальнейших разделов:

процесс Node.js └── рантайм ├── V8 ──────────── исполняет ваш JavaScript, держит память и сборщик мусора └── libuv ───────── вся асинхронность ├── event loop ──── бесконечный цикл; обходит фазы по кругу │ └── фазы ────── timers, pending, poll, check, close │ └── очереди колбэков внутри каждой фазы ├── пул потоков ─── 4 потока для файлов, DNS, crypto, zlib └── опрос ОС ────── epoll (Linux), kqueue (macOS), IOCP (Windows) Отдельно от фаз, поверх них: очередь process.nextTick ─┐ опустошаются полностью очередь промисов ─┘ после КАЖДОГО колбэка
ПонятиеЧастью чего являетсяКто это запускает
ФазаEvent loopСам цикл, по кругу
Event looplibuvNode — после того, как выполнил ваш файл
Пул потоковlibuvlibuv, когда операцию нельзя сделать асинхронно
МикрозадачиV8 (промисы) и Node (nextTick)Сливаются после каждого колбэка
Ваш колбэкОчередь внутри фазыEvent loop, когда дошёл до этой фазы
Почему процесс не завершается — и когда завершается

Цикл работает, пока у libuv есть незакрытые дела: открытый сокет сервера, живой таймер, незавершённая операция с файлом. Как только счётчик таких дел обнуляется, цикл выходит и процесс завершается сам.

// Скрипт завершится сразу: дел не осталось
console.log('привет');

// А этот будет жить: открытый сервер — незакрытое дело
http.createServer(handler).listen(3000);

// И этот: интервал держит цикл вечно
setInterval(() => {}, 1000);

// unref() снимает удержание: таймер сработает, но держать цикл не будет
const t = setInterval(tick, 1000);
t.unref();
Частый симптом. Скрипт отработал, а процесс висит и не отдаёт терминал. Значит что-то осталось незакрытым: подключение к базе, пул соединений, интервал. Найти можно через process.getActiveResourcesInfo() — он покажет, что именно удерживает цикл.
Связь с остальными разделами. Из этого свойства растут сразу две темы: Graceful shutdown — как закрыть дела правильно, чтобы не оборвать запросы на полуслове, и Утечки памяти — почему оставленное состояние живёт неделями, а не умирает с запросом, как в PHP.
Почему Node однопоточный, но держит тысячи соединений

JS-код выполняется в одном потоке. Ввод-вывод — сеть, сокеты — отдаётся операционной системе через libuv неблокирующе. Когда операция завершается, её колбэк ставится в очередь, и event loop его подбирает.

┌───────────────────────────┐ ┌─→│ pending callbacks │ отложенные системные колбэки │ ├───────────────────────────┤ │ │ idle, prepare │ служебная, используется самим Node │ ├───────────────────────────┤ │ │ poll │ ждёт I/O, исполняет его колбэки │ ├───────────────────────────┤ │ │ check │ setImmediate │ ├───────────────────────────┤ │ │ timers │ setTimeout, setInterval │ ├───────────────────────────┤ │ │ close callbacks │ socket.on('close') │ └───────────────────────────┘ └──────────────┘ Очереди микрозадач НЕ являются фазами. Они стоят поверх цикла и опустошаются после каждой операции, в любой фазе: 1) очередь process.nextTick 2) очередь промисов
Порядок фаз изменился в Node 20. Раньше цикл начинался с timers. Начиная с libuv 1.45.0, который вошёл в Node 20, таймеры выполняются после фазы poll на каждом обороте. Схемы в старых статьях показывают прежний порядок — на Node 20 и новее они устарели.
Пока один колбэк работает, всё остальное ждёт. Тяжёлая синхронная работа блокирует весь сервер — не «этот запрос», а все соединения процесса.
Классическая задача на порядок
console.log('1');
setTimeout(() => console.log('2'), 0);
setImmediate(() => console.log('3'));
Promise.resolve().then(() => console.log('4'));
process.nextTick(() => console.log('5'));
console.log('6');
ПорядокЧто выводитсяПочему
1–21, 6Синхронный код идёт первым, целиком
35process.nextTick — отдельная очередь, приоритетнее промисов
44Очередь промисов
5–62 и 3Порядок не гарантирован
Почему 2 и 3 плавают. В главном модуле результат зависит от того, успел ли истечь таймер к моменту входа в фазу timers — а это зависит от скорости запуска процесса. Но внутри I/O-колбэка setImmediate всегда раньше setTimeout: цикл уже находится в фазе poll, и следующая фаза — check.
Зачем вообще фазы

Event loop — не одна общая очередь колбэков, а цикл из нескольких фаз. Каждая фаза держит собственную очередь задач своего типа. Node проходит фазы по кругу и на каждой обрабатывает только свою очередь. Смысл разделения — предсказуемость порядка и возможность дать разным видам работы разный приоритет.

timers → pending callbacks → idle/prepare → poll → check → close callbacks → (снова timers)

Фаза idle/prepare — внутренняя, она нужна самому Node, и в прикладном коде вы с ней не сталкиваетесь. Остальные разбираем по порядку.

Фазы по порядку
ФазаЧто выполняетЧем важна
pending callbacksСистемные колбэки, отложенные ОС на следующий проход — в документации приведены «некоторые типы ошибок TCP»В прикладном коде встречается редко
idle, prepareСлужебная фазаВ документации помечена «used only internally» — вашего кода здесь не бывает
pollЗабирает готовые события ввода-вывода и выполняет их колбэки — все, кроме close-колбэков, таймеров и setImmediateСердце цикла. Именно здесь процесс «спит», не тратя процессор, пока ждёт сеть или диск
checkКолбэки setImmediateСледующая фаза после poll — отсюда вся разница с setTimeout(fn, 0)
timersКолбэки setTimeout и setInterval, у которых истёк срокТаймер задаёт порог, после которого колбэк может выполниться, а не точное время
close callbacksСобытия закрытия: socket.on('close')Замыкает круг, дальше цикл начинается заново

Фаз на самом деле семь или восемь; в документации разбирают те, которые реально использует Node — они перечислены выше.

Как фаза poll решает, ждать ли. У неё две работы: посчитать, сколько времени блокироваться на ожидании ввода-вывода, и обработать события из своей очереди. Дальше поведение зависит от того, что её ждёт:

очередь poll не пуста └─ выполняет колбэки по очереди, пока она не кончится или пока не упрётся в системный лимит очередь poll пуста ├─ есть запланированные setImmediate │ └─ poll завершается немедленно, цикл идёт в check │ └─ setImmediate нет └─ цикл ждёт здесь, пока не придут новые события (но не дольше, чем до срабатывания ближайшего таймера)
Почему таймеры «неточные». В документации сказано прямо: таймер задаёт порог, после которого колбэк может быть выполнен. Если в момент истечения порога цикл занят колбэком ввода-вывода, таймер подождёт. Пример из документации: таймер на 100 мс при чтении файла, занявшем 95 мс, срабатывает примерно через 105 мс.
setTimeout(fn, 0) — это не «немедленно». Минимальная задержка в Node фактически равна 1 мс, и колбэк всё равно ждёт захода в фазу timers. Если нужно «как можно скорее, но после текущего ввода-вывода» — это setImmediate.
setImmediate против setTimeout(0)

Самая известная задача на понимание фаз. Внутри колбэка ввода-вывода порядок строго определён, а на верхнем уровне — нет.

const fs = require('node:fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
// Всегда: immediate → timeout
// Мы внутри poll, а check идёт сразу за ней;
// timers наступит только на следующем проходе цикла.
// А на верхнем уровне гарантии нет — зависит от того, успел ли
// истечь таймер к моменту первого захода в фазу timers
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Порядок может отличаться от запуска к запуску
Микрозадачи: две очереди между колбэками

Это самое важное для практики. Микрозадачи — очереди, которые опустошаются полностью, прежде чем цикл пойдёт дальше. Очередей две, и порядок между ними строгий:

Важная оговорка из документации. process.nextTick() формально не является частью event loop. Его очередь обрабатывается после завершения текущей операции, независимо от того, в какой фазе цикл сейчас находится. Операцией здесь называют переход от обработчика на C/C++ к исполнению связанного с ним JavaScript. Поэтому на схеме цикла обе очереди микрозадач стоят отдельно, а не внутри фаз.
ОчередьЧто попадаетПриоритет
process.nextTickТолько process.nextTickПервая — выше промисов
Микрозадачи промисов.then, .catch, .finally, queueMicrotask, продолжение после awaitВторая
console.log('1 — синхронно');

setTimeout(() => console.log('5 — timers'), 0);

Promise.resolve().then(() => console.log('3 — promise'));

process.nextTick(() => console.log('2 — nextTick'));

queueMicrotask(() => console.log('4 — queueMicrotask'));

console.log('1.5 — синхронно');

// Вывод:
// 1 — синхронно
// 1.5 — синхронно
// 2 — nextTick
// 3 — promise
// 4 — queueMicrotask
// 5 — timers

Сначала до конца отрабатывает синхронный код, затем вся очередь nextTick, затем все промисы — и только потом цикл уходит в следующую фазу.

Почему nextTick раньше промисов. Исторически: он появился в Node задолго до промисов, и внутренний код платформы опирался на него для гарантий согласованности — например, чтобы событие 'close' точно возникало после завершения текущей операции. Когда добавили промисы, их поставили после, чтобы не сломать существующее поведение.

Деталь, которую чаще всего упускают. Микрозадачи опустошаются после каждого отдельного колбэка, а не только на границе фаз. Выполнился один таймер → слили все микрозадачи → выполнился следующий таймер → снова слили. То же самое для каждого колбэка ввода-вывода в фазе poll.
Как заморить event loop микрозадачами

Прямое следствие предыдущего правила: цепочка микрозадач, которая пополняет сама себя, никогда не закончится — и цикл просто не дойдёт до ввода-вывода.

function starve() {
  process.nextTick(starve);   // добавляет новый nextTick прямо во время слива
}
starve();
// Очередь nextTick никогда не опустеет →
// ни ввод-вывод, ни таймеры, ни setImmediate больше не выполнятся.

function loop() {
  Promise.resolve().then(loop);   // то же самое с промисами
}
loop();   // сервер встанет: до фазы poll дело не дойдёт

Поэтому nextTick и промисы хороши для «сразу после текущей операции», но не годятся для длинных циклов обработки. Чтобы отдавать управление циклу между итерациями, берут setImmediate:

// Правильно: между порциями работы цикл успевает обслужить ввод-вывод
function processChunk() {
  // ...немного работы...
  setImmediate(processChunk);   // уходим в фазу check, даём шанс poll
}
Сводная шпаргалка приоритетов
ЧтоКогда выполняетсяОчередь
Синхронный кодСразу—
process.nextTickПосле текущего колбэка, до промисовМикрозадачи, приоритет 1
.then, await, queueMicrotaskПосле очереди nextTickМикрозадачи, приоритет 2
setTimeout, setIntervalФаза timersМакрозадачи
Колбэки ввода-вывода (fs, сеть)Фаза pollМакрозадачи
setImmediateФаза check — следующая после pollМакрозадачи
События закрытияФаза close callbacksМакрозадачи
Короткая формула. Одна макрозадача (колбэк из фазы) → полный слив очереди nextTick → полный слив очереди промисов → следующая макрозадача. Понимание этого порядка снимает большую часть вопросов вида «почему лог вывелся не там» и «почему сервер завис».
Thread pool и блокировка
Где мы находимся. Пул потоков — вторая часть libuv, рядом с event loop. Сам цикл однопоточный; в пул уходят операции, которые операционная система не умеет делать асинхронно. Результат возвращается в цикл обычным событием.
Что уходит в пул потоков libuv
Идёт в пул потоковЧто именно, по документации
fsВсе файловые операции, кроме fs.FSWatcher() и явно синхронных (*Sync)
dnsdns.lookup() и dns.lookupService() — они идут через системный резолвер. Остальные dns.resolve* работают через c-ares и пул не занимают
cryptopbkdf2, scrypt, randomBytes, randomFill, generateKeyPair
zlibВсе операции сжатия, кроме явно синхронных
Нативные дополненияМодули на C++ могут отправлять в пул собственные задачи

Сеть в пул не идёт. TCP, HTTP и сокеты операционная система умеет обслуживать сама: она сообщает процессу, когда на сокете появились данные, и отдельный поток для ожидания не нужен. Поэтому тысячи открытых соединений не стоят Node почти ничего, а вот сотня одновременных чтений файлов упирается в четыре потока пула.
Механизмы, которыми это сделано, называются epoll в Linux, kqueue в macOS и IOCP в Windows — знать их названия для работы не нужно.

По умолчанию в пуле 4 потока, меняется переменной окружения UV_THREADPOOL_SIZE.

# так — работает
UV_THREADPOOL_SIZE=8 node app.js
Изнутри процесса переменную менять бесполезно. В документации прямо сказано: присваивание process.env.UV_THREADPOOL_SIZE в коде не гарантирует эффекта, потому что пул создаётся при инициализации рантайма — задолго до того, как начнёт выполняться ваш файл. Задавать нужно при запуске процесса.
Распространённое заблуждение. «Node асинхронный, значит всё параллельно». Нет: сеть действительно обрабатывается ОС, а вот файловые и crypto-операции делят между собой 4 потока. Пять одновременных pbkdf2 — и пятый ждёт.
Чем нельзя блокировать event loop
  • JSON.parse огромных тел запроса
  • Тяжёлые регулярные выражения (катастрофический бэктрекинг)
  • Синхронные вызовы fs: readFileSync в обработчике
  • Циклы на миллионы итераций
  • Синхронный bcrypt
РешениеКогда применять
worker_threadsCPU-задача внутри того же процесса
Отдельный сервис или очередьДолгая обработка, которую не ждёт пользователь
Разбить на части через setImmediateДлинный цикл, который можно нарезать
cluster или несколько репликИспользовать все ядра. В Docker/K8s обычно реплики, а не cluster
Ошибки в async-коде
Где мы находимся. Исключение внутри колбэка всплывает не к вашему коду, а к event loop — вызвал-то обработчик он. Отсюда и правила, где ставить try/catch и зачем нужны обработчики на уровне процесса.
Где ловить
// Основной способ — try/catch вокруг await
try {
  const user = await fetchUser(id);
} catch (e) {
  // обработка
}
СитуацияЧто происходит
Промис без catchunhandledRejection. В современных версиях Node процесс по умолчанию падает
process.on('uncaughtException')Последний рубеж только для логирования и аккуратного завершения
В NestJSException filters превращают исключения в HTTP-ответы
После uncaughtException продолжать работу нельзя. Состояние процесса неопределённо: могли остаться незакрытые транзакции, повреждённые структуры в памяти, полузаписанные файлы. Правильное поведение — залогировать и завершиться, дав супервизору поднять новый процесс.
Комбинаторы промисов
Четыре способа дождаться нескольких операций
МетодПоведениеКогда применять
Promise.allПадает на первой ошибкеНужны все результаты, и любая ошибка критична
Promise.allSettledЖдёт всех, возвращает статус каждогоСобрать результаты, часть из которых может упасть
Promise.raceПервый завершившийся — успех или ошибкаТаймауты
Promise.anyПервый успешныйНесколько поставщиков, подходит любой ответ
Разница race и any — в отношении к ошибкам. race завершится ошибкой, если первым пришёл отказ; any будет ждать успеха и упадёт, только если провалились все.
Streams и backpressure
Где мы находимся. Потоки — надстройка стандартной библиотеки над тем же вводом-выводом. Под капотом те же события libuv, просто с удобным интерфейсом и встроенным контролем переполнения.
Зачем потоки

Поток отдаёт данные кусками и не держит всё в памяти. Отдать CSV-отчёт на гигабайт без потоков — значит сначала собрать этот гигабайт в память процесса.

Backpressure — обратное давление. Если писатель не успевает за читателем, write() возвращает false. Это сигнал прекратить писать и дождаться события drain. Игнорировать его — значит копить данные в буфере и прийти к той же утечке памяти, от которой уходили.
import { pipeline } from 'node:stream/promises';

// pipeline сам обрабатывает backpressure, ошибки и закрытие потоков
await pipeline(
  source,
  transform,
  destination,
);
Соединять потоки руками через .pipe() и .on('error') — источник незакрытых дескрипторов. stream.pipeline() закрывает всю цепочку при ошибке в любом звене.
Утечки памяти
Где мы находимся. Память принадлежит V8 — второй опоре рантайма. Тема существует именно потому, что процесс живёт неделями: состояние не умирает с запросом, как в PHP-FPM.
Откуда берутся
ПричинаКак выглядит
Глобальный кеш без лимитаMap, который только растёт и никогда не чистится
Неотписанные слушателиemitter.on(...) без парного off
Замыкания на большие объектыКолбэк держит ссылку на весь ответ, а нужен один id
Таймеры без очисткиsetInterval без clearInterval
Почему это болезнь именно Node, а не PHP. PHP-процесс умирает после запроса и уносит с собой всё, что натворил. Node-процесс живёт неделями: любая не освобождённая ссылка накапливается от запроса к запросу.
# Запуск с инспектором
node --inspect app.js

# Дальше: Chrome DevTools → Memory → два heap snapshot с интервалом,
# сравнить и посмотреть, что выросло
// Быстрая проверка в рантайме
setInterval(() => console.log(process.memoryUsage()), 10_000);
CommonJS и ESM
Где мы находимся. Загрузчик модулей поднимается на этапе внутреннего bootstrap, до выполнения вашего файла. Поэтому выбор между CommonJS и ESM влияет на то, как ваш код вообще попадёт в процесс.
CommonJSES Modules
Синтаксисrequire / module.exportsimport / export
ЗагрузкаСинхроннаяАсинхронная
АнализВ рантаймеСтатический — отсюда tree shaking
Top-level awaitНетДа
В NestJS-проектах TypeScript обычно компилируется в CommonJS — из-за декораторов и emitDecoratorMetadata, на которых держится DI.
Graceful shutdown
Где мы находимся. Прямое следствие того, что цикл работает, пока есть незакрытые дела. Корректное завершение — это закрыть их в правильном порядке, не оборвав запросы на полуслове.
Порядок завершения
  1. Поймать SIGTERM
  2. Перестать принимать новые запросы — server.close()
  3. Дать текущим запросам завершиться
  4. Закрыть пул БД и подключения к очередям
  5. Выйти
// NestJS
app.enableShutdownHooks();

// И хуки в провайдерах:
async onModuleDestroy() { /* закрыть ресурсы */ }
async beforeApplicationShutdown(signal?: string) { /* ... */ }
Для воркеров, обрабатывающих платежи, это критично. Процесс, убитый посреди транзакции, оставляет задачу в статусе «выполняется» без исполнителя. Контейнер при деплое получает SIGTERM и через 10 секунд SIGKILL — за эти 10 секунд нужно успеть доработать текущую задачу и не взять новую.
Реальное время: WebSocket, SSE, Long Polling
Три способа, чем отличаются
ХарактеристикаWebSocketSSELong Polling
НаправлениеДвунаправленное: клиент ⇄ серверТолько сервер → клиентСервер → клиент, но запрос инициирует клиент
ПротоколСвой, поверх TCP: ws:// / wss://Обычный HTTP, text/event-streamОбычный HTTP
ПрименениеЧаты, игры, совместное редактированиеЛенты, уведомления, дашборды, котировкиФоллбэк, легаси, редкие обновления
СложностьСредняя — нужно вести жизненный циклНизкая — переподключение встроеноНизкая, но накладно
Накладные расходыНизкиеНизкиеВысокие — каждый запрос полный HTTP-цикл
ПереподключениеПишешь самБраузер самСам по природе — цикл запросов
Бинарные данныеДаНет, только текстДа
Правило выбора в одну строку. Данные идут в одну сторону → SSE. Нужно слать и обратно → WebSocket. Редкие обновления или окружение режет всё остальное → Long Polling.
WebSocket — полноценный двусторонний канал

Соединение начинается как обычный HTTP-запрос с просьбой сменить протокол, после чего тот же TCP-сокет работает уже по WebSocket.

// Запрос клиента
GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

// Ответ сервера
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
import { WebSocketServer, WebSocket } from 'ws';  // WebSocket нужен ради константы OPEN

const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', (ws) => {
  ws.send('Добро пожаловать!');

  ws.on('message', (raw) => {
    const text = raw.toString();

    // Разослать всем живым клиентам
    for (const client of wss.clients) {
      if (client.readyState === WebSocket.OPEN) {
        client.send(text);
      }
    }
  });
});
// Клиент
const ws = new WebSocket('wss://example.com/ws');
ws.onopen    = () => ws.send('привет');
ws.onmessage = (e) => console.log(e.data);
ws.onclose   = () => /* переподключение пишешь сам */;
Переподключения из коробки нет. Обрыв связи, спящий ноутбук, смена Wi-Fi — соединение закрылось, и клиент молча перестал получать сообщения. Нужен свой reconnect с экспоненциальной задержкой плюс досылка пропущенного по last_message_id. Именно за это берут socket.io: там это уже реализовано.
Мёртвые соединения не определяются сами. TCP может считать сокет живым часами после того, как клиент исчез. Нужен heartbeat: сервер шлёт ping, клиент отвечает pong, не ответил за два интервала — закрываем.
SSE — поток событий от сервера

Сервер открывает один долгоживущий HTTP-ответ и пишет в него текстовые события. Клиент только слушает.

// Примеры ниже используют import, поэтому в package.json нужно
// "type": "module". Без этой строки Node ждёт CommonJS и упадёт
// с «Cannot use import statement outside a module».
import express from 'express';
const app = express();

app.get('/events', (req, res) => {
  res.setHeader('Content-Type',  'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  // Connection здесь только для HTTP/1.1. В HTTP/2 этот заголовок запрещён
  if (req.httpVersionMajor === 1) res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no');  // чтобы Nginx не копил ответ в буфере
  res.flushHeaders();

  const timer = setInterval(() => {
    const payload = { ts: Date.now(), value: Math.random() };

    // Формат строгий: поле, двоеточие, значение. Событие закрывают ДВА \n
    res.write(`id: ${payload.ts}\n`);
    res.write(`data: ${JSON.stringify(payload)}\n\n`);
  }, 2000);

  req.on('close', () => { clearInterval(timer); res.end(); });
});
// Клиент — всё встроено в браузер
const es = new EventSource('/events');
es.onmessage = (e) => console.log(JSON.parse(e.data));
es.onerror   = () => /* браузер переподключится сам */;
ПолеЧто делает
data:Полезная нагрузка. Несколько строк подряд склеиваются через \n
id:Идентификатор события. Браузер запоминает последний
event:Имя события — слушается через addEventListener('имя', ...)
retry:Задержка переподключения в миллисекундах
: в начале строкиКомментарий. Используют как heartbeat, чтобы прокси не закрыл соединение
Главное преимущество SSE — восстановление без потерь. Браузер переподключается сам и присылает заголовок Last-Event-ID с последним полученным id. Сервер по нему досылает пропущенное. С WebSocket эту логику пишут руками.
Лимит 6 соединений на домен в HTTP/1.1. Каждая вкладка держит открытое SSE-соединение. Семь вкладок — и седьмая зависнет, а вместе с ней и обычные запросы к тому же домену. По HTTP/2 лимита практически нет, поэтому SSE в проде почти всегда ставят за HTTP/2.
Long Polling — ожидание на обычном HTTP

Клиент шлёт обычный запрос, сервер не отвечает сразу, а держит его открытым, пока не появятся данные или не истечёт таймаут. Получив ответ, клиент немедленно шлёт следующий.

const waiting = new Map();   // res -> timer

app.get('/poll', (req, res) => {
  const since = Number(req.query.since ?? 0);
  const missed = getEventsAfter(since);

  // Если что-то уже накопилось — отдаём сразу, без ожидания
  if (missed.length) return res.json(missed);

  // Таймаут обязателен: иначе прокси оборвёт соединение сам
  const timer = setTimeout(() => {
    waiting.delete(res);
    res.json([]);          // пустой ответ — клиент сразу спросит снова
  }, 25_000);

  // Храним ответ ВМЕСТЕ с его таймером, иначе погасить его потом нечем
  waiting.set(res, timer);

  req.on('close', () => { clearTimeout(timer); waiting.delete(res); });
});

function publish(event) {
  store(event);
  for (const [res, timer] of waiting) {
    clearTimeout(timer);   // иначе через 25 с сработает res.json([]) на уже отвеченном ответе
    res.json([event]);
  }
  waiting.clear();
}
Таймер надо гасить при ответе. Если в publish() просто ответить и очистить список, запланированные setTimeout останутся жить. Через 25 секунд каждый из них попытается ответить второй раз — и Express упадёт с Cannot set headers after they are sent. Поэтому waiting здесь Map, а не Set: ответ хранится вместе со своим таймером.
Между ответом и следующим запросом есть окно. Событие, случившееся в этот момент, потеряется, если хранить «последнее значение» в переменной. Поэтому клиент передаёт курсор (since / last_event_id), а сервер отдаёт всё, что после него — а не только свежее.
Каждый висящий запрос занимает ресурс. В Node это дёшево — соединение не держит поток. В PHP-FPM тот же приём съедает воркер целиком, поэтому там long polling почти не применяют.
Что ломается в проде
Nginx рвёт соединение через 60 секунд. Дефолтный proxy_read_timeout закроет и WebSocket, и SSE, если в них нет трафика. Нужны proxy_http_version 1.1, проброс Upgrade / Connection для WebSocket и увеличенный таймаут. Разобрано в разделе Nginx → Reverse proxy.
Буферизация прокси ломает SSE. Nginx копит ответ в буфере и отдаёт пачкой — события приходят с задержкой или не приходят вовсе. Лечится proxy_buffering off; или заголовком X-Accel-Buffering: no из приложения.
Несколько инстансов не видят друг друга. Клиент А подключён к первому процессу, клиент Б ко второму — сообщение от А до Б не дойдёт. Нужен общий канал: Redis pub/sub (для socket.io есть готовый адаптер) плюс sticky sessions на балансировщике, чтобы переподключение попадало в тот же инстанс.
Медленный клиент копит данные в памяти сервера. Если читатель не успевает, сокет буферизует — и процесс растёт. Нужно следить за ws.bufferedAmount и отключать безнадёжно отстающих.
Как выбрать
ЗадачаВыбор
Чат, игра, совместное редактированиеWebSocket
Лента уведомлений, прогресс задачи, котировкиSSE — проще и переподключается сам
Обновление раз в минутуОбычный опрос по таймеру, без всего этого
Окружение режет WebSocket (корпоративный прокси)Long Polling как фоллбэк
Частая ошибка — взять WebSocket там, где хватало SSE. Двусторонний канал приносит с собой переподключение, heartbeat, синхронизацию между инстансами и отслеживание состояния. Если клиенту нужно только получать, всё это лишняя работа.
Сквозной пример: модуль заказов с нуля
Зачем этот раздел. Отдельные понятия Nest по одному выглядят как набор декораторов. Здесь они собираются в один рабочий модуль: приходит запрос на создание заказа, проходит проверку доступа, валидацию тела, попадает в сервис и списывает деньги в транзакции. Весь код приведён целиком и запускается.

Шаг 0. Создаём проект

Nest CLI создаёт каркас и умеет генерировать части модуля. Для примера понадобятся сам Nest, валидация и TypeORM с драйвером PostgreSQL.

npm i -g @nestjs/cli
nest new shop            # каркас приложения
cd shop

npm i @nestjs/typeorm typeorm pg
npm i class-validator class-transformer

nest g module orders     # создаст orders.module.ts и подключит его к AppModule
nest g controller orders
nest g service orders
Аналогия с Laravel. nest g module orders — это примерно php artisan make:provider плюс создание папки под фичу. Разница в том, что в Laravel сервис-провайдер лишь регистрирует связки в общем контейнере, а модуль Nest ещё и задаёт границу видимости: что он берёт извне и что отдаёт наружу.
Шаг 1. Сущности базы

Две таблицы: счёт пользователя и заказ. На них мы потом покажем транзакцию — списание денег и создание заказа должны происходить вместе или не происходить вовсе.

// src/orders/account.entity.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity()
export class Account {
  @PrimaryGeneratedColumn()
  id: number;

  // numeric вместо float: деньги нельзя хранить в плавающей точке
  @Column({ type: 'numeric', precision: 12, scale: 2 })
  balance: string;
}
// src/orders/order.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';

@Entity()
export class Order {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  accountId: number;

  @Column({ type: 'numeric', precision: 12, scale: 2 })
  total: string;

  // ключ идемпотентности: повторный запрос не создаст второй заказ
  @Column({ unique: true })
  requestId: string;

  @CreateDateColumn()
  createdAt: Date;
}
Шаг 2. DTO — описание тела запроса

DTO здесь ровно то же, что FormRequest в Laravel: описание того, что мы согласны принять. Разница в том, что правила пишутся декораторами прямо над полями, а не массивом в методе rules().

// src/orders/dto/create-order.dto.ts
import { IsInt, IsPositive, IsNumberString, IsUUID } from 'class-validator';

export class CreateOrderDto {
  @IsInt()
  @IsPositive()
  accountId: number;

  // строка, а не number: иначе float испортит копейки
  @IsNumberString()
  total: string;

  @IsUUID()
  requestId: string;
}

Чтобы декораторы начали работать, валидацию включают глобально. Заодно просим отсекать лишние поля — иначе клиент пришлёт isAdmin: true, и оно доедет до сервиса.

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,             // выкинуть поля, которых нет в DTO
    forbidNonWhitelisted: true,  // и вернуть 400, если такие были
    transform: true,             // привести типы: '5' из params станет number
  }));

  app.enableShutdownHooks();     // без этого SIGTERM не доедет до onModuleDestroy
  await app.listen(3000);
}
bootstrap();
Шаг 3. Guard — пускать или нет

Guard отвечает на один вопрос: продолжать обработку или отказать. Он срабатывает до валидации — нет смысла разбирать тело запроса от того, кого мы всё равно не пустим.

// src/orders/api-key.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';

@Injectable()
export class ApiKeyGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const key = request.headers['x-api-key'];

    if (key !== process.env.API_KEY) {
      throw new UnauthorizedException('Неверный ключ');
    }
    return true;
  }
}
Аналогия с Laravel. Guard — это Gate или middleware авторизации. Ключевое отличие от middleware Express: guard получает ExecutionContext и потому знает, какой контроллер и какой метод сейчас вызывается. Обычный middleware этого не знает, поэтому проверки прав на нём не делают.
Шаг 4. Контроллер — только маршруты

В контроллере не должно быть бизнес-логики. Его работа — принять запрос, передать данные в сервис и вернуть результат. Ровно как тонкий контроллер в Laravel.

// src/orders/orders.controller.ts
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { ApiKeyGuard } from './api-key.guard';

@Controller('orders')
export class OrdersController {
  // зависимость объявляется в конструкторе — Nest подставит её сам
  constructor(private readonly orders: OrdersService) {}

  @Post()
  @UseGuards(ApiKeyGuard)
  create(@Body() dto: CreateOrderDto) {
    return this.orders.create(dto);
  }

  @Get(':id')
  findOne(@Param('id') id: number) {
    return this.orders.findOne(id);
  }
}
Шаг 5. Сервис и транзакция

Здесь живёт логика. Списание денег и создание заказа обязаны быть атомарными: если заказ не создался, деньги должны вернуться. В TypeORM это делают через DataSource.transaction — аналог DB::transaction() в Laravel.

// src/orders/orders.service.ts
import { Injectable, BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Account } from './account.entity';
import { Order } from './order.entity';
import { CreateOrderDto } from './dto/create-order.dto';

@Injectable()
export class OrdersService {
  constructor(@InjectDataSource() private readonly dataSource: DataSource) {}

  async create(dto: CreateOrderDto) {
    return this.dataSource.transaction(async (manager) => {

      // Блокируем строку счёта до конца транзакции.
      // Без этого два одновременных запроса прочитают один и тот же
      // баланс и спишут дважды — классическая гонка.
      const account = await manager.findOne(Account, {
        where: { id: dto.accountId },
        lock: { mode: 'pessimistic_write' },
      });

      if (!account) throw new NotFoundException('Счёт не найден');

      const balance = Number(account.balance);
      const total   = Number(dto.total);
      if (balance < total) throw new BadRequestException('Недостаточно средств');

      account.balance = (balance - total).toFixed(2);
      await manager.save(account);

      try {
        return await manager.save(Order, {
          accountId: dto.accountId,
          total: dto.total,
          requestId: dto.requestId,
        });
      } catch (e: any) {
        // 23505 — нарушение уникальности: такой requestId уже был.
        // Значит это повтор, а не новая покупка.
        if (e.code === '23505') throw new ConflictException('Заказ уже создан');
        throw e;
      }
    });
  }

  async findOne(id: number) {
    const order = await this.dataSource.getRepository(Order).findOneBy({ id });
    if (!order) throw new NotFoundException();
    return order;
  }
}
Три вещи, которые ломают такой код в проде. Первая: деньги в float — копейки начнут теряться, поэтому колонка numeric, а в коде строка. Вторая: чтение баланса без блокировки — два параллельных запроса спишут с одного остатка дважды, спасает pessimistic_write. Третья: отсутствие ключа идемпотентности — клиент повторит запрос по таймауту и купит дважды, поэтому requestId с уникальным индексом.
Шаг 6. Собираем модуль

Модуль перечисляет, из чего состоит фича и что она отдаёт наружу. Всё, чего нет в exports, остаётся внутренним — это и есть граница, которой нет у сервис-провайдера Laravel.

// src/orders/orders.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { Order } from './order.entity';
import { Account } from './account.entity';

@Module({
  imports: [TypeOrmModule.forFeature([Order, Account])],
  controllers: [OrdersController],
  providers: [OrdersService],
  exports: [OrdersService],   // другие модули смогут его внедрить
})
export class OrdersModule {}
// src/app.module.ts — подключение базы и нашего модуля
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrdersModule } from './orders/orders.module';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      username: 'postgres',
      password: 'postgres',
      database: 'shop',
      autoLoadEntities: true,
      synchronize: true,   // только для разработки: в проде — миграции
    }),
    OrdersModule,
  ],
})
export class AppModule {}
Шаг 7. Запускаем и проверяем
npm run start:dev

# без ключа — 401 от guard
curl -X POST localhost:3000/orders \
  -H 'Content-Type: application/json' \
  -d '{"accountId":1,"total":"100.00","requestId":"3f1a..."}'

# с ключом, но с лишним полем — 400 от ValidationPipe (forbidNonWhitelisted)
curl -X POST localhost:3000/orders \
  -H 'x-api-key: secret' -H 'Content-Type: application/json' \
  -d '{"accountId":1,"total":"100.00","requestId":"3f1a...","isAdmin":true}'

# корректный запрос — 201 и созданный заказ
curl -X POST localhost:3000/orders \
  -H 'x-api-key: secret' -H 'Content-Type: application/json' \
  -d '{"accountId":1,"total":"100.00","requestId":"3f1a..."}'

# тот же requestId ещё раз — 409, второй заказ не создан
Что здесь произошло по шагам. Запрос попал в guard, тот проверил ключ. Дальше ValidationPipe разобрал тело по DTO и отсёк лишние поля. Контроллер передал готовый объект в сервис, не зная ничего о базе. Сервис открыл транзакцию, заблокировал строку счёта, списал деньги и создал заказ — либо откатил всё целиком. Это ровно тот порядок, который описан в разделе Жизненный цикл запроса.
Что спросят по этой теме
ВопросКороткий ответ
Чем модуль Nest отличается от сервис-провайдера Laravel?Провайдер только регистрирует связки в общем контейнере, модуль дополнительно задаёт границу: что импортирует и что экспортирует
Почему guard срабатывает раньше pipe?Чтобы не тратить разбор и валидацию тела на запрос, который всё равно будет отклонён
Как не списать деньги дважды?Блокировка строки счёта в транзакции плюс ключ идемпотентности с уникальным индексом
Зачем whitelist в ValidationPipe?Отсекает поля, которых нет в DTO, иначе лишние данные из запроса доедут до сервиса
NestJS: строительные блоки
Где мы находимся. NestJS — фреймворк поверх рантайма, а не замена ему. Всё, что было сказано про event loop, пул потоков и память, продолжает действовать под ним без изменений.
Что чему соответствует в Laravel
NestJSАналог в LaravelЧто делает
@ModuleService ProviderЧетыре поля: providers — что создаёт инжектор, controllers — какие контроллеры поднять, imports — чужие модули, чьи провайдеры нужны здесь, exports — то из своих провайдеров, что отдаётся наружу
@Injectable провайдерКласс в контейнереСервис, который DI создаёт и внедряет через конструктор
ControllerControllerМаршруты: @Get, @Post, @Param, @Body
DTO + ValidationPipeFormRequestВалидация входа через class-validator
GuardMiddleware auth / Gate / PolicyРешает, пускать ли запрос: JWT, роли, подпись вебхука
InterceptorMiddleware до и послеОбёртка вокруг хендлера: логи, кеш, таймаут, преобразование ответа
Exception filterException HandlerПревращает исключения в HTTP-ответы
MiddlewareGlobal middlewareНизкоуровнево, до guards: request-id, raw body, CORS
События жизненного цикла приложения

Кроме цикла запроса у Nest есть цикл самого приложения: поднятие при старте и остановка при сигнале. Это то место, где подключают пулы соединений и где их корректно закрывают.

ХукКогда срабатывает
onModuleInitЗависимости конкретного модуля разрешены. Nest идёт по модулям снизу вверх — от самых глубоких в графе импортов к корневому
onApplicationBootstrapВсе модули проинициализированы, но сервер ещё не начал принимать соединения
onModuleDestroyПолучен сигнал завершения, например SIGTERM
beforeApplicationShutdownВсе onModuleDestroy отработали, соединения ещё не закрыты. Получает имя сигнала
onApplicationShutdownСоединения закрыты — последний шаг app.close(). Получает имя сигнала

Если хук вернул промис, Nest дождётся его перед переходом к следующему шагу — поэтому в них можно спокойно делать асинхронную работу.

const app = await NestFactory.create(AppModule);

// без этой строки хуки по сигналам не сработают
app.enableShutdownHooks();

await app.listen(3000);
Слушатели сигналов выключены по умолчанию. В документации сказано прямо: они потребляют системные ресурсы, поэтому не включены. Без enableShutdownHooks() хуки отработают только при явном app.close(), а на SIGTERM от Docker или Kubernetes — нет. Контейнер будет убит по таймауту вместе с незавершёнными запросами. На Windows SIGTERM не работает в принципе, SIGINT — работает.
Связь с разделом «Graceful shutdown». Там описано, почему процесс обязан закрывать дела сам; эти пять хуков — готовые точки, куда такую логику вешают в Nest, не изобретая своих обработчиков сигналов.
NestJS: жизненный цикл запроса
Где мы находимся. Жизненный цикл запроса в NestJS — это то, что происходит внутри одного колбэка event loop. Ближайший аналог — конвейер Kernel::handle() в Laravel, только поднятый один раз при старте, а не на каждый запрос.
Полный порядок по документации

Запрос проходит фиксированную последовательность. У каждого типа обработчиков есть три уровня — глобальный, контроллера и маршрута, — и они тоже идут в строгом порядке.

входящий запрос │ 1. Middleware глобальные → модульные (по пути) │ 2. Guards глобальные → контроллера → маршрута │ 3. Interceptors (до) глобальные → контроллера → маршрута │ 4. Pipes глобальные → контроллера → маршрута → параметров │ 5. Controller метод-обработчик маршрута │ 6. Service то, что вызывает обработчик │ 7. Interceptors (после) маршрута → контроллера → глобальные │ порядок обратный пункту 3 │ 8. Exception filters маршрута → контроллера → глобальные │ только если возникло необработанное исключение │ 9. Ответ клиенту
Обратите внимание на пункт 7. На пути «туда» перехватчики идут от глобальных к маршруту, на пути «обратно» — в обратном порядке. Это и делает их обёрткой: глобальный перехватчик начинает работу первым и заканчивает последним, как внешний слой у матрёшки.
Тонкости порядка, о которые спотыкаются
ЧтоПравило из документации
Middleware между модулямиСначала middleware глобального модуля, затем корневого, затем остальных — в порядке удалённости от корня
Пайпы параметровЕсли пайпы стоят у нескольких параметров, последний параметр обрабатывается первым
Фильтры исключенийРазрешаются снизу вверх: от маршрута к глобальному. Исключение, пойманное на одном уровне, дальше не передаётся
Фильтры и успешный запросЗапускаются только при необработанном исключении — в обычном сценарии их в цепочке нет
Middleware — не Nest-сущность. Это обычный механизм Express или Fastify, поэтому middleware ничего не знает о контексте выполнения: ему недоступны ExecutionContext, класс контроллера и метод-обработчик. Всё, что опирается на эти данные — проверка ролей по декоратору, кеш по маршруту — должно быть guard или interceptor, а не middleware.
Почему порядок именно такой
ПараПочему в этом порядке
Guard раньше PipeНет смысла разбирать и валидировать тело запроса от того, кого мы не пустим. Сначала отсекаем, потом тратим время на данные
Interceptor раньше PipeПерехватчик должен охватывать весь вызов, включая валидацию: иначе таймаут или замер времени не учтут разбор входных данных
Middleware раньше GuardMiddleware работает на уровне HTTP-сервера, до того как Nest вообще определил, какой обработчик вызывать
Фильтры в самом концеОни ловят исключения всего, что было выше, — включая сами guard-ы и пайпы
Перевод на Laravel. Middleware соответствует глобальному middleware, guard — Gate или Policy, pipe — FormRequest, interceptor — middleware с кодом до и после $next($request), exception filter — Handler::render(). Главное отличие: в Laravel всё это конвейер middleware, а в Nest — четыре разных типа с собственным порядком.
NestJS: DI и scope
Как работает внедрение

Провайдер регистрируется в модуле, Nest строит граф зависимостей по типам параметров конструктора — ровно как контейнер Laravel по рефлексии.

ScopeВремя жизниНюанс
DEFAULTОдин экземпляр на всё приложениеДействует по умолчанию, объявлять не нужно
REQUESTСвой экземпляр на каждый входящий запрос; после обработки собирается сборщиком мусораДокументация прямо предупреждает, что такие провайдеры влияют на производительность, и советует без необходимости их не брать
TRANSIENTСвой экземпляр у каждого, кто его внедряет — потребители не делят один объектВ отличие от REQUEST, вверх не поднимается: синглтон получит собственный экземпляр transient-провайдера, но сам останется синглтоном
REQUEST-scope поднимается по цепочке. В документации это названо «bubbles up»: если контроллер зависит от request-scoped сервиса, контроллер тоже становится request-scoped. И так дальше вверх — незаметно всё приложение начинает пересоздаваться на каждый запрос.
Кастомные провайдеры и циклы
// Четыре формы — аналог bind/singleton/instance в Laravel
{ provide: PaymentGateway, useClass: StripeGateway }
{ provide: 'CONFIG', useValue: { timeout: 5000 } }
{ provide: Client, useFactory: (cfg) => new Client(cfg), inject: [Config] }
{ provide: Alias, useExisting: Original }

Через них подменяют реализации — например, мок платёжного шлюза в тестах.

Циклическая зависимость. Технически лечится forwardRef(() => OtherService) в обоих местах. Но это признак неверной границы модулей: правильнее вынести общую логику в третий сервис или общаться через события (EventEmitter2).
NestJS: DTO, валидация, guards
ValidationPipe глобально
app.useGlobalPipes(new ValidationPipe({
  whitelist: true,              // выкинуть поля, которых нет в DTO
  forbidNonWhitelisted: true,   // или вообще отклонить запрос
  transform: true,              // привести типы: '5' → 5
}));
whitelist: true — прямой аналог $fillable в Laravel: защита от mass assignment. Лишние поля не доедут до сервиса.
export class CreateOrderDto {
  @IsUUID()      productId: string;
  @IsInt() @Min(1) quantity: number;
  @IsString() @IsOptional() comment?: string;
}
Авторизация

@nestjs/passport + @nestjs/jwt: JwtStrategy валидирует токен, JwtAuthGuard вешается на контроллер или глобально.

// Роли: свой декоратор через SetMetadata + guard, читающий их через Reflector
@Roles('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Delete(':id')
remove(@Param('id') id: string) { /* ... */ }
Access-токен короткий, refresh хранится в БД или Redis — чтобы его можно было отозвать. Без хранения отозвать JWT нельзя в принципе.
NestJS: ORM и транзакции
TypeORM и Prisma
// TypeORM: все запросы внутри идут через manager
await dataSource.transaction(async (manager) => {
  const order = await manager.findOneOrFail(Order, {
    where: { id },
    lock: { mode: 'pessimistic_write' },   // SELECT ... FOR UPDATE
  });
  // ...
});

// Prisma
await prisma.$transaction(async (tx) => { /* ... */ });
Главное правило транзакций в Node. Внутри транзакции должен использоваться один и тот же клиент из пула. Если вызвать репозиторий не через manager / tx, запрос уйдёт по другому соединению и окажется вне транзакции — молча, без ошибки. В PHP такой проблемы почти нет, потому что там одно соединение на запрос.
NestJS: очереди
РешениеХранилищеЧем хорошо
BullMQ (@nestjs/bullmq)RedisСтандарт экосистемы: ретраи, backoff, отложенные задачи, приоритеты
pg-boss, graphile-workerPostgreSQLОчередь в той же БД — задачу можно поставить в одной транзакции с бизнес-операцией
Почему для денег очередь в Postgres удобнее. Если платёж записывается в БД, а задача на выдачу ставится в Redis, между двумя операциями есть окно: БД закоммитилась, Redis не принял — заказ оплачен, но задача потеряна. Очередь в той же БД коммитится вместе с платежом, и такого окна нет.
NestJS: тестирование
const moduleRef = await Test.createTestingModule({
  providers: [PaymentsService, LedgerService],
})
  .overrideProvider(PaymentGateway)
  .useValue(mockGateway)
  .compile();
УровеньИнструмент
Unit — сервисыJest с подменой провайдеров
E2E — HTTPsupertest на поднятое приложение
ИнтеграционныеНастоящий Postgres в Docker, testcontainers
Идемпотентность нужно тестировать конкурентно. Последовательные повторы ничего не доказывают — проблема возникает именно при параллельных вызовах. Тест: Promise.all на 50 одинаковых вебхуков, проверка что обработка произошла ровно один раз.
PostgreSQL: изоляция и блокировки
Уровни изоляции
УровеньЧто видит транзакцияЦена
READ COMMITTED — по умолчаниюКаждый запрос видит закоммиченное на момент своего стартаДёшево
REPEATABLE READСнимок на всю транзакциюПри конфликте записи — ошибка сериализации, нужен ретрай
SERIALIZABLEКак будто транзакции шли по очередиТоже с ретраями, дороже всего
Для балансов обычно хватает READ COMMITTED плюс явных блокировок строк или атомарных UPDATE с условием. Поднимать изоляцию до SERIALIZABLE ради одного места — дорого и приносит ретраи во всё приложение.
Как не допустить двойного списания
-- Вариант 1: атомарный UPDATE с условием, затем проверка rowCount
UPDATE accounts SET balance = balance - $1
WHERE id = $2 AND balance >= $1;

-- Вариант 2: блокировка строки в транзакции
SELECT * FROM accounts WHERE id = $1 FOR UPDATE;

Плюс идемпотентность операции по ключу: уникальный индекс на idempotency_key.

Почему вариант 1 безопасен без явной блокировки. UPDATE в Postgres сам берёт блокировку строки, а условие balance >= $1 проверяется уже на заблокированной строке. Если денег не хватило, обновится ноль строк — и это надо проверить по rowCount, иначе списание «пройдёт» молча.
Ловля дублей и SKIP LOCKED
-- Нарушение уникальности в Postgres = код 23505
-- Либо ловим его, либо:
INSERT INTO processed_events (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING RETURNING event_id;
-- Вернулась строка → обрабатываем впервые. Пусто → уже обработано.
-- SKIP LOCKED: берём строку и блокируем, занятые другими — пропускаем
SELECT id FROM jobs
WHERE status = 'pending' AND run_at <= now()
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;
На SKIP LOCKED построены pg-boss и graphile-worker. Несколько воркеров разбирают одну таблицу задач без конфликтов и без двойной обработки — без внешнего брокера.
Паттерны надёжности
Идемпотентная обработка вебхука

Платёжные провайдеры доставляют вебхуки как минимум один раз: повторы при таймауте — норма, а не сбой. Обработчик обязан быть идемпотентным.

@Injectable()
export class PaymentsService {
  constructor(
    private readonly ds: DataSource,
    private readonly ledger: LedgerService,
  ) {}

  async applyEvent(evt: PspEvent): Promise<void> {
    await this.ds.transaction(async (m) => {

      // 1. Дедупликация: второй такой же вебхук ничего не вставит
      const fresh = await m.query(
        `INSERT INTO processed_events (event_id) VALUES ($1)
         ON CONFLICT (event_id) DO NOTHING RETURNING event_id`,
        [evt.id],
      );
      if (fresh.length === 0) return;   // уже обработан

      // 2. Блокируем заказ — параллельный вебхук подождёт
      const order = await m.findOneOrFail(Order, {
        where: { id: evt.orderId },
        lock: { mode: 'pessimistic_write' },
      });
      if (order.status !== 'pending') return;

      order.status = 'paid';
      await m.save(order);

      // 3. Проводки и задача коммитятся ВМЕСТЕ с оплатой
      await this.ledger.post(m, [
        { account: 'psp_clearing', debit: evt.amountMinor },
        { account: 'revenue',      credit: evt.amountMinor },
      ]);
      await m.insert(Job, { type: 'deliver_key', orderId: order.id });
    });
  }
}
Три защиты работают слоями: уникальный индекс отсекает повтор события, блокировка строки упорядочивает параллельные обработчики, проверка статуса ловит логически неприменимые переходы.
Таймаут внешнего сервиса
async requestKey(orderId: string, requestId: string) {
  try {
    const res = await fetch(`${this.baseUrl}/keys`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Idempotency-Key': requestId,
      },
      body: JSON.stringify({ orderId }),
      signal: AbortSignal.timeout(5_000),
    });
    if (!res.ok) return { status: 'error', code: res.status };
    return { status: 'ok', key: (await res.json()).key };
  } catch (e) {
    // Таймаут НЕ значит отказ: операция могла пройти.
    if (e instanceof Error && e.name === 'TimeoutError') {
      return { status: 'unknown' };
    }
    throw e;
  }
}
Таймаут — это «не знаю», а не «нет». Запрос мог дойти и выполниться, а оборвался только ответ. Трактовать таймаут как отказ и повторять операцию без ключа идемпотентности — прямой путь к двойной выдаче. Поэтому статус unknown, ретрай с тем же Idempotency-Key и сверка.
Double-entry ledger

Баланс не хранится изменяемым числом, а считается из проводок. Каждая операция — набор проводок, где сумма дебетов равна сумме кредитов; иначе транзакция не коммитится.

ПравилоЗачем
Суммы в минорных единицах (bigint)Никаких float для денег
Проводки не изменяются и не удаляютсяИстория неизменна и проверяема
Возврат — новая обратная проводкаНе правка старой записи
СверкаИщет заказы «оплачен, но не выдан» и расхождения с выпиской провайдера
События приходят не по порядку
Что если refund придёт раньше payment. Вебхуки не гарантируют порядок. Решение — машина состояний заказа с допустимыми переходами. Событие, которое пока нельзя применить, сохраняется и обрабатывается позже (или ставится в очередь с задержкой), а не отбрасывается. Сверка работает страховкой на случай, если что-то всё же потерялось.
Подводные камни
Node.js
Синхронная работа в обработчике. Блокирует не один запрос, а весь процесс со всеми соединениями.
Промис без catch. unhandledRejection в современных версиях Node роняет процесс.
Продолжение работы после uncaughtException. Состояние процесса неопределённо — только логирование и выход.
Кеш без лимита. В долгоживущем процессе любая несбрасываемая Map — утечка.
Игнор backpressure. write() вернул false, а мы пишем дальше — данные копятся в памяти.
NestJS
Запрос в транзакции мимо manager. Уйдёт по другому соединению и окажется вне транзакции — без единой ошибки.
REQUEST-scope, протёкший вверх. Один такой провайдер делает request-scoped всю цепочку, которая его использует.
forwardRef как норма. Лечит симптом циклической зависимости, но означает неверную границу модулей.
ValidationPipe без whitelist. Лишние поля из тела запроса доедут до сервиса и до ORM.
Отсутствие enableShutdownHooks. Контейнер убьёт воркер посреди транзакции.
Конкурентность
UPDATE ... WHERE balance >= $1 без проверки rowCount. Списание не произошло, а код считает, что прошло.
Таймаут трактуется как отказ. Повтор без ключа идемпотентности даёт двойную выдачу.
Очередь в Redis, а платёж в Postgres. Между двумя коммитами есть окно, в котором задача теряется.
Тест идемпотентности последовательными повторами. Ничего не проверяет — нужен Promise.all на параллельные одинаковые запросы.