AsyncLocalStorage в production: как не потерять traceId через очереди, таймеры и connection pooling
Базовый слой
Создавайте контекст на входе в систему: HTTP, consumer, cron tick. Не ждите, что ALS сам перенесет его через процесс или брокер.
Очереди
Для in-memory очереди сохраняйте
Для Kafka, RabbitMQ, BullMQ, SQS ALS не участвует. Практический совет: кладите
Таймеры и scheduler'ы
Таймер, созданный внутри контекста, обычно его сохранит. Опасность появляется, когда callback зарегистрирован в одном месте, а вызван позже из scheduler'а.
Для cron/background задач создавайте новый
Connection pooling
Типичная ошибка - записывать
Connection живет дольше запроса и переиспользуется другим request'ом. Логируйте query через текущий ALS-контекст, а trace в БД передавайте на уровне конкретной операции или транзакции, например через transaction-local setting.
Вывод:
AsyncLocalStorage надежно держит контекст внутри await, промисов и I/O. Но в production traceId чаще теряется на границах: очереди, scheduler'ы, worker'ы и connection pool.Базовый слой
Создавайте контекст на входе в систему: HTTP, consumer, cron tick. Не ждите, что ALS сам перенесет его через процесс или брокер.
const als = new AsyncLocalStorage();
const withTrace = (traceId, fn) =>
als.run(Object.freeze({ traceId }), fn);
const getTraceId = () => als.getStore()?.traceId;
Очереди
Для in-memory очереди сохраняйте
store в момент enqueue и восстанавливайте при выполнении.function enqueue(payload) {
queue.push({ payload, store: als.getStore() });
}
function processJob(job) {
return job.store
? als.run(job.store, () => handleJob(job.payload))
: handleJob(job.payload);
}Для Kafka, RabbitMQ, BullMQ, SQS ALS не участвует. Практический совет: кладите
traceId в headers или payload и считайте это частью контракта сообщения.Таймеры и scheduler'ы
Таймер, созданный внутри контекста, обычно его сохранит. Опасность появляется, когда callback зарегистрирован в одном месте, а вызван позже из scheduler'а.
Для cron/background задач создавайте новый
traceId на каждый tick. Иначе получите "вечный" traceId процесса или случайный traceId старого запроса.Connection pooling
Типичная ошибка - записывать
traceId в client или connection object.client.traceId = getTraceId(); // плохо
Connection живет дольше запроса и переиспользуется другим request'ом. Логируйте query через текущий ALS-контекст, а trace в БД передавайте на уровне конкретной операции или транзакции, например через transaction-local setting.
Вывод:
AsyncLocalStorage работает хорошо только внутри async chain, а на production-границах traceId нужно явно фиксировать, передавать и восстанавливать.Graceful shutdown в Kubernetes: почему одного SIGTERM мало для keep-alive, HTTP/2 и фоновых задач
В production Pod редко умирает мгновенно: ingress, kube-proxy, LB и клиенты с keep-alive могут ещё слать трафик в terminating Pod. Частая ошибка - считать, что
Что реально нужно drain'ить
* HTTP/1.1 keep-alive соединения
* HTTP/2-сессии и активные stream'ы
* фоновые задачи: BullMQ, RabbitMQ, Kafka, cron, batch jobs
Иначе на rolling deploy получаются редкие 502/499, оборванные stream'ы и повторная обработка сообщений.
Staged shutdown для Node.js
Практический порядок:
* включить режим
*
* дать ingress/LB несколько секунд убрать Pod из ротации
* остановить новые TCP-соединения
* закрыть idle keep-alive
* дождаться активных запросов и задач в рамках бюджета
Важное предупреждение
HTTP/2 и фоновые задачи
Для HTTP/2 нельзя просто рубить socket: одна TCP-сессия содержит много stream'ов. Сначала отправляйте
Для workers на SIGTERM нужно остановить получение новых задач, не запускать новые cron-итерации, дождаться текущих и уметь отменять долгие операции через
Вывод:
Graceful shutdown - это не обработчик SIGTERM, а управляемый drain всего, что может продолжать работу после удаления Pod из ротации.
В production Pod редко умирает мгновенно: ingress, kube-proxy, LB и клиенты с keep-alive могут ещё слать трафик в terminating Pod. Частая ошибка - считать, что
server.close() уже дождался всех клиентов и задач.Что реально нужно drain'ить
* HTTP/1.1 keep-alive соединения
* HTTP/2-сессии и активные stream'ы
* фоновые задачи: BullMQ, RabbitMQ, Kafka, cron, batch jobs
Иначе на rolling deploy получаются редкие 502/499, оборванные stream'ы и повторная обработка сообщений.
Staged shutdown для Node.js
Практический порядок:
* включить режим
draining*
/readyz начинает отдавать 503* дать ingress/LB несколько секунд убрать Pod из ротации
* остановить новые TCP-соединения
* закрыть idle keep-alive
* дождаться активных запросов и задач в рамках бюджета
process.on('SIGTERM', async () => {
draining = true;
await sleep(5000);
server.close();
server.closeIdleConnections?.();
h2sessions.forEach(s => s.goaway());
await waitInFlight(25_000);
workers.abort();
server.closeAllConnections?.();
process.exit(0);
});Важное предупреждение
server.close() не означает "все клиенты ушли". Он только перестаёт принимать новые соединения. Уже открытые keep-alive соединения нужно отдельно закрывать через closeIdleConnections(), а в крайнем случае - через closeAllConnections().HTTP/2 и фоновые задачи
Для HTTP/2 нельзя просто рубить socket: одна TCP-сессия содержит много stream'ов. Сначала отправляйте
GOAWAY, чтобы запретить новые stream'ы, затем ждите завершения активных.Для workers на SIGTERM нужно остановить получение новых задач, не запускать новые cron-итерации, дождаться текущих и уметь отменять долгие операции через
AbortController.Вывод:
Graceful shutdown - это не обработчик SIGTERM, а управляемый drain всего, что может продолжать работу после удаления Pod из ротации.
🔥3
Undici в production: почему исходящие HTTP-запросы должны иметь пулы, deadline и собственный backpressure
Пул создаётся на уровне приложения
Не заводите клиент внутри handler'а. Для критичных интеграций держите
Важно: тело ответа нужно дочитать или корректно сбросить, даже если оно не нужно. Иначе socket может не вернуться в пул вовремя.
connections - это capacity, а не магическое ускорение
Маленький пул даёт очередь и рост latency. Слишком большой пул перегружает upstream, плодит открытые socket'ы, упирается в NAT / ephemeral ports и ухудшает tail latency.
Практический ориентир: если upstream отвечает за 100 ms, то 32 соединения дают примерно 320 RPS без pipelining. Но финальный размер выбирайте по SLO, payload size, TLS, сети и rate limits.
Таймауты должны быть разными
Один общий timeout скрывает причину деградации.
-
-
-
-
Предупреждение:
Backpressure делайте до Undici
Не запускайте
Ограничивайте concurrency явно, например
Вывод:
Надёжный исходящий HTTP в Node.js - это не просто
fetch() в Node.js использует Undici, но в production часто нужен явный контроль: сколько socket'ов держим, сколько ждём upstream и что делаем, если входящий трафик быстрее внешнего API. Типичная ошибка - создавать запросы без лимитов и надеяться, что пул всё спасёт.Пул создаётся на уровне приложения
Не заводите клиент внутри handler'а. Для критичных интеграций держите
Pool per-origin и переиспользуйте его:import { Pool } from 'undici'
const api = new Pool('https://api.example.com', {
connections: 32,
pipelining: 1,
connectTimeout: 2_000,
headersTimeout: 3_000,
bodyTimeout: 10_000
})
async function callApi(payload) {
const { statusCode, body } = await api.request({
method: 'POST',
path: '/v1/process',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(12_000)
})
const text = await body.text()
if (statusCode >= 500) throw new Error(upstream ${statusCode})
return JSON.parse(text)
}Важно: тело ответа нужно дочитать или корректно сбросить, даже если оно не нужно. Иначе socket может не вернуться в пул вовремя.
connections - это capacity, а не магическое ускорение
Маленький пул даёт очередь и рост latency. Слишком большой пул перегружает upstream, плодит открытые socket'ы, упирается в NAT / ephemeral ports и ухудшает tail latency.
Практический ориентир: если upstream отвечает за 100 ms, то 32 соединения дают примерно 320 RPS без pipelining. Но финальный размер выбирайте по SLO, payload size, TLS, сети и rate limits.
Таймауты должны быть разными
Один общий timeout скрывает причину деградации.
-
connectTimeout - TCP/TLS connect-
headersTimeout - ожидание заголовков-
bodyTimeout - пауза между чанками body-
AbortSignal.timeout() - общий deadline операцииПредупреждение:
bodyTimeout не равен лимиту на весь response body.Backpressure делайте до Undici
Не запускайте
Promise.all(items.map(callApi)) на тысячи элементов. Undici поставит часть запросов в очередь, но память и давление на приложение уже выросли.Ограничивайте concurrency явно, например
MAX_IN_FLIGHT = 64. Он не обязан равняться connections: это лимит нагрузки на ваш сервис, а не только на socket pool.Вывод:
Надёжный исходящий HTTP в Node.js - это не просто
fetch(), а управляемые пулы, раздельные таймауты и backpressure до попадания запросов в Undici.👍4
Сатурация libuv thread pool в Node.js: как crypto, fs и dns.lookup ломают latency в production
В Node.js
Где возникает проблема
По умолчанию
Первые 4
Production-сценарий
Логин считает password hash, HTTP-клиент часто резолвит hostname, а hot path читает сертификат или metadata с диска. CPU может быть не 100%, event loop - "здоровым", но p99 растет из-за очереди к worker'ам.
DNS-нюанс
Что делать
* выносить тяжелый crypto в
* не читать файлы на hot path - грузить и кэшировать заранее;
* включать keep-alive, pooling и DNS cache;
* менять
Больше thread'ов - не silver bullet: CPU-bound задачи могут ухудшить p99 из-за context switching.
Вывод:
libuv thread pool - маленький общий ресурс процесса, поэтому latency-sensitive I/O нельзя смешивать с тяжелым crypto и частым
В Node.js
async не значит "без очередей": часть операций конкурирует за общий libuv thread pool. Частая ошибка в production - смотреть только на event loop lag и не видеть очередь внутри пула.Где возникает проблема
По умолчанию
UV_THREADPOOL_SIZE=4 на процесс. Туда попадают crypto.pbkdf2, scrypt, часть randomBytes, многие fs.*, dns.lookup, zlib.for (let i = 0; i < 8; i++)
crypto.pbkdf2(p, s, 500_000, 64, 'sha512', cb);
await fs.readFile('./config.json');
await dns.lookup('api.internal');
Первые 4
pbkdf2 занимают весь пул, остальные операции ждут. Маленький config-файл или DNS lookup внезапно дают сотни миллисекунд.Production-сценарий
Логин считает password hash, HTTP-клиент часто резолвит hostname, а hot path читает сертификат или metadata с диска. CPU может быть не 100%, event loop - "здоровым", но p99 растет из-за очереди к worker'ам.
DNS-нюанс
dns.lookup использует системный resolver через getaddrinfo и libuv pool. dns.resolve4/6/Mx используют c-ares и не конкурируют с fs/crypto так же. Предупреждение: массовый dns.lookup легко принять за "медленную сеть".Что делать
* выносить тяжелый crypto в
worker_threads, отдельный сервис, процессы или queue;* не читать файлы на hot path - грузить и кэшировать заранее;
* включать keep-alive, pooling и DNS cache;
* менять
UV_THREADPOOL_SIZE только после load test:UV_THREADPOOL_SIZE=32 node server.js
Больше thread'ов - не silver bullet: CPU-bound задачи могут ухудшить p99 из-за context switching.
Вывод:
libuv thread pool - маленький общий ресурс процесса, поэтому latency-sensitive I/O нельзя смешивать с тяжелым crypto и частым
dns.lookup без изоляции и измерений.❤1
AbortController в Node.js production: сквозные дедлайны, cleanup ресурсов и защита от «висящих» операций
AbortController важен не как таймаут для
Сквозной дедлайн
У HTTP-запроса, джобы или RPC должен быть один бюджет, который прокидывается вниз: handler - сервис - БД - внешний API - stream - timer.
Типичная ошибка
Не ставьте независимые
Abort кооперативен
Он работает только там, где API принимает
Cleanup обязателен
Вывод:
Сильный production-паттерн - один сквозной
AbortController важен не как таймаут для
fetch, а как контракт жизненного цикла операции. В production частая ошибка - клиент уже ушел, SLA истек, а сокеты, стримы и временные файлы продолжают жить.Сквозной дедлайн
У HTTP-запроса, джобы или RPC должен быть один бюджет, который прокидывается вниз: handler - сервис - БД - внешний API - stream - timer.
const client = new AbortController();
req.on('close', () => {
client.abort(new Error('client disconnected'));
});
const signal = AbortSignal.any([
client.signal,
AbortSignal.timeout(1500)
]);
try {
const r = await fetch(reportUrl, { signal });
await pipeline(r.body, createWriteStream(file), { signal });
} finally {
await rm(tmpDir, {
recursive: true,
force: true
}).catch(() => {});
}
Типичная ошибка
Не ставьте независимые
timeout: 1000 в каждом слое. Так запрос легко живет 3+ секунды, хотя общий SLA был 1 секунда. Практический совет: передавайте сверху signal, а локальный меньший бюджет делайте через AbortSignal.any().Abort кооперативен
Он работает только там, где API принимает
signal: fetch, timers/promises, stream.pipeline, часть fs, http, child_process, клиенты БД. Предупреждение: Promise.race() с таймаутом не отменяет реальную работу, он только перестает ждать результат.Cleanup обязателен
abort() не удалит временные файлы, не вернет connection в pool и не снимет listeners. Освобождайте ресурсы в finally и прокидывайте внутрь signal, а не controller.Вывод:
Сильный production-паттерн - один сквозной
AbortSignal, понятный signal.reason и гарантированный cleanup каждой операции.👍3
WeakRef, FinalizationRegistry и утечки в нативных коллбэках: ловушки памяти в V8
Node.js и GC V8 делают много работы за нас, но есть сценарии, где автоматическое управление памятью отключается. Особенно когда в дело входят нативные аддоны на C++ с N-API и коллбэки, которые живут дольше, чем само JS-окружение. WeakRef и FinalizationRegistry часто подают как спасение, но на практике это западня, если не понимать деталей.
WeakRef: иллюзия безопасности
WeakRef хранит слабую ссылку на объект и не мешает GC. Выглядит удобно для кэширования, но deref() может вернуть undefined в любой момент:
* Практический совет: никогда не рассчитывай на то, что объект жив после deref().
* Типичная ошибка: думать, что WeakRef продлевает жизнь объекту. Нет, GC может уничтожить его сразу.
FinalizationRegistry: не деструктор
FinalizationRegistry вызывает коллбэк после сборки мусора, но это не гарантированный cleanup:
* Подводный камень: время вызова не определено, и если процесс завершился, коллбэк может не случиться.
* Практический совет: используй FinalizationRegistry только для логирования или диагностики, а не как основной механизм освобождения ресурсов.
Утечки в нативных коллбэках: скрытый убийца
Нативные аддоны с N-API могут захватить JS-коллбэк через napi_ref и забыть его освободить. В результате объект зависает навсегда:
* Production-oriented пример: обработчик событий в long-running процессе, где каждый вызов on создает новую ссылку без очистки.
* Решение: в аддонах используй napi_create_reference с 0 — это слабая ссылка, и явно удаляй ссылку при завершении работы.
Вывод:
Утечки в нативных коллбэках проще предотвратить на этапе проектирования, чем отлавливать с помощью слабых ссылок — дисциплина и явные cleanup-паттерны остаются единственной надежной защитой.
Node.js и GC V8 делают много работы за нас, но есть сценарии, где автоматическое управление памятью отключается. Особенно когда в дело входят нативные аддоны на C++ с N-API и коллбэки, которые живут дольше, чем само JS-окружение. WeakRef и FinalizationRegistry часто подают как спасение, но на практике это западня, если не понимать деталей.
WeakRef: иллюзия безопасности
WeakRef хранит слабую ссылку на объект и не мешает GC. Выглядит удобно для кэширования, но deref() может вернуть undefined в любой момент:
const cache = new Map();
function cachedGet(key, factory) {
let ref = cache.get(key);
if (ref && ref.deref()) return ref.deref();
const obj = factory();
cache.set(key, new WeakRef(obj));
return obj;
}
* Практический совет: никогда не рассчитывай на то, что объект жив после deref().
* Типичная ошибка: думать, что WeakRef продлевает жизнь объекту. Нет, GC может уничтожить его сразу.
FinalizationRegistry: не деструктор
FinalizationRegistry вызывает коллбэк после сборки мусора, но это не гарантированный cleanup:
const registry = new FinalizationRegistry((heldValue) => {
console.log(Cleanup for ${heldValue});
});
registry.register(obj, 'some-id');* Подводный камень: время вызова не определено, и если процесс завершился, коллбэк может не случиться.
* Практический совет: используй FinalizationRegistry только для логирования или диагностики, а не как основной механизм освобождения ресурсов.
Утечки в нативных коллбэках: скрытый убийца
Нативные аддоны с N-API могут захватить JS-коллбэк через napi_ref и забыть его освободить. В результате объект зависает навсегда:
nativeModule.on('data', (msg) => console.log(msg));
// В C++: napi_create_reference(env, callback, 1, &ref);
// И никогда не вызывается napi_delete_reference(ref);* Production-oriented пример: обработчик событий в long-running процессе, где каждый вызов on создает новую ссылку без очистки.
* Решение: в аддонах используй napi_create_reference с 0 — это слабая ссылка, и явно удаляй ссылку при завершении работы.
Вывод:
Утечки в нативных коллбэках проще предотвратить на этапе проектирования, чем отлавливать с помощью слабых ссылок — дисциплина и явные cleanup-паттерны остаются единственной надежной защитой.
❤2
Стратегии борьбы с throttle в event loop при работе с Native Addons и FFI через worker_threads
Node.js живёт на одном потоке — это знают все. Но когда в дело вступают Native Addons (napi, C++ модули) и FFI (Foreign Function Interface), event loop может неожиданно "зависнуть". Даже если ты используешь
FFI тут особенно неприятен. Библиотеки типа
Что с этим делать?
1. Вынос в dedicated worker.
Создаёшь отдельный worker, грузишь туда аддон, общаешься через
Внутри worker:
2. Асинхронные API для аддонов.
Если пишешь аддон сам — используй n-api async work (
3. Пул worker'ов с очередью.
Задачи распределяются, и один тяжёлый вызов не может монополизировать все ресурсы. Стандартный паттерн — берёшь задачу из очереди, отдаёшь свободному воркеру.
4. Atomics и SharedArrayBuffer.
Блокировка без захвата event loop. Но требует поддержки
5. Мониторинг.
Замеряй время вызовов через
Коротко:
-
- Для своих аддонов — async n-api.
- Для FFI — пулы и очереди.
Вывод:
Изоляция нативных вызовов через worker_threads с асинхронными API и мониторингом — единственный способ избежать throttling event loop в production.
Node.js живёт на одном потоке — это знают все. Но когда в дело вступают Native Addons (napi, C++ модули) и FFI (Foreign Function Interface), event loop может неожиданно "зависнуть". Даже если ты используешь
worker_threads, throttle всё равно случается. Почему? Потому что нативные вызовы не всегда изолируются полностью. Блокирующий системный вызов в аддоне может зацепить libuv thread pool или V8, и основной поток начинает тормозить.FFI тут особенно неприятен. Библиотеки типа
ffi-napi или koffi вызывают shared library синхронно — event loop просто стоит и ждёт, пока вызов вернётся. Все остальные задачи замораживаются.Что с этим делать?
1. Вынос в dedicated worker.
Создаёшь отдельный worker, грузишь туда аддон, общаешься через
MessageChannel. Главное — не передавай shared memory без синхронизации, если аддон не thread-safe.const { Worker } = require('worker_threads');
const worker = new Worker('./native-worker.js', { workerData: {} });
worker.postMessage({ task: 'heavyCalc', data: [1, 2, 3] });
worker.on('message', result => console.log('Результат:', result));Внутри worker:
const { parentPort } = require('worker_threads');
const nativeAddon = require('native-addon');
parentPort.on('message', msg => {
const result = nativeAddon.heavySync(msg.data);
parentPort.postMessage(result);
});2. Асинхронные API для аддонов.
Если пишешь аддон сам — используй n-api async work (
napi_create_async_work). Для FFI — заворачивай вызовы в setImmediate или setTimeout с нулевой задержкой. Это даёт event loop шанс обработать другие задачи.3. Пул worker'ов с очередью.
Задачи распределяются, и один тяжёлый вызов не может монополизировать все ресурсы. Стандартный паттерн — берёшь задачу из очереди, отдаёшь свободному воркеру.
4. Atomics и SharedArrayBuffer.
Блокировка без захвата event loop. Но требует поддержки
SharedArrayBuffer и аккуратной реализации.5. Мониторинг.
Замеряй время вызовов через
process.hrtime.bigint() или performance.now(). Если вызов длится дольше 50ms — это красный флаг. Логируй и переключай на worker.Коротко:
-
worker_threads — база, но не панацея.- Для своих аддонов — async n-api.
- Для FFI — пулы и очереди.
Вывод:
Изоляция нативных вызовов через worker_threads с асинхронными API и мониторингом — единственный способ избежать throttling event loop в production.
❤2
Diagnostics Channel: Загляни внутрь Node.js в production без console.log и перезаливок
Когда приложение падает в production, а логи пусты - начинается классика: залипаешь в терминал с
Что за зверь?
Модуль
Где выстрелит?
- Утечки HTTP-соединений - канал
- HTTP/2 сессии и их жизненный цикл
- Работа DNS-резолвера
- Создание и завершение Worker Threads
- Зависания событийного цикла - канал
Пример: ловим утечки HTTP-соединений
Кладёшь этот код в
Как тащить в production?
- Условный require:
- Или динамически подключай по
- Только помни: подписка - синхронная операция. Не делай внутри тяжёлых вычислений или блокирующих I/O, иначе забьёшь event loop и ухудшишь производительность
Вывод:
Diagnostics Channel позволяет заглянуть внутрь Node.js в реальном времени под нагрузкой, чтобы поймать утечки и задержки до того, как они уронят production, причём без единой правки кода приложения.
Когда приложение падает в production, а логи пусты - начинается классика: залипаешь в терминал с
console.log, комментируешь половину кода и перезаливаешь. Но у Node.js есть встроенный механизм для подписки на внутренние события движка и модулей без правки исходников. Называется diagnostics_channel.Что за зверь?
Модуль
diagnostics_channel даёт каналы, именуемые по схеме модуль.событие.подсобытие. Через них V8, libuv и сам Node.js эмитят структурированные данные о внутренних операциях. Подписаться можно без правки кода приложения - просто инициализируешь слушателя в отдельном файле и подключаешь через флаг --require.Где выстрелит?
- Утечки HTTP-соединений - канал
http.client.request.create- HTTP/2 сессии и их жизненный цикл
- Работа DNS-резолвера
dns- Создание и завершение Worker Threads
- Зависания событийного цикла - канал
tickПример: ловим утечки HTTP-соединений
const diagnosticsChannel = require('diagnostics_channel');
const clientRequestChannel = diagnosticsChannel.channel('http.client.request.create');
clientRequestChannel.subscribe(({ request, socket }) => {
const startTime = Date.now();
request.on('response', (res) => {
console.log(Request to ${request.hostname} completed (${Date.now() - startTime}ms));
});
socket.on('close', () => {
if (Date.now() - startTime > 5000) {
console.warn(Long-lived socket detected: ${socket.remotePort});
}
});
});Кладёшь этот код в
diagnostics.js, запускаешь с флагом node --require ./diagnostics.js app.js - и никаких изменений в исходниках приложения.Как тащить в production?
- Условный require:
NODE_DEBUG_DIAGNOSTICS=1 node app.js с проверкой в коде- Или динамически подключай по
process.env при старте, чтобы не плодить лишние подписки- Только помни: подписка - синхронная операция. Не делай внутри тяжёлых вычислений или блокирующих I/O, иначе забьёшь event loop и ухудшишь производительность
Вывод:
Diagnostics Channel позволяет заглянуть внутрь Node.js в реальном времени под нагрузкой, чтобы поймать утечки и задержки до того, как они уронят production, причём без единой правки кода приложения.
Event Loop Lag при тысячах setTimeout: как очередь таймеров убивает latency в production
Когда на сервере больше 1000 активных setTimeout или setInterval, latency неожиданно растет, I/O начинает тормозить, а пользователи жалуются на задержки. Многие разработчики считают, что таймеры безвредны, но на практике их перебор в одной фазе event loop блокирует весь tick.
Всё упирается в фазы цикла событий:
- timers — коллбеки от setTimeout и setInterval
- pending callbacks — I/O ошибки
- idle, prepare — внутренняя кухня libuv
- poll — новые I/O события
- check — очередь setImmediate
- close callbacks — закрытие соединений
Проблема: когда у вас 10 000 активных setTimeout, фаза timers отрабатывает десятки миллисекунд. Всё остальное стоит в очереди. I/O, новые запросы, ответы — всё ждёт, пока переберутся таймеры.
Реальный случай из практики
Был сервис с heartbeats для клиентов. Каждый клиент держал рекурсивный setTimeout:
Для 5000 клиентов каждый tick на фазе timers прогонял до 5000 коллбеков. Попутно лагали все остальные запросы — пользователи чувствовали задержки, мониторинг показывал event loop lag > 100ms.
Что с этим делать?
Во-первых, отлаживать. Берём профайлер Node.js:
Смотрим лог — находим, сколько времени уходит на фазу timers. Часто это открытие.
Во-вторых, оптимизировать.
- Ограничьте количество активных таймеров. Сгруппируйте несколько heartbeat-задач в один setInterval.
- Там, где возможна замена, используйте setImmediate. Он живёт на фазе check — не блокирует poll и timers.
- Мониторьте event loop lag через
Можно ещё раскидать тяжёлые таймерные задачи по worker_threads — чтобы они не тормозили главный поток.
Вывод:
Большое количество таймеров — тихий убийца производительности, который часто замечают слишком поздно, но своевременный профилинг и рефакторинг очередей могут снизить latency в разы.
Когда на сервере больше 1000 активных setTimeout или setInterval, latency неожиданно растет, I/O начинает тормозить, а пользователи жалуются на задержки. Многие разработчики считают, что таймеры безвредны, но на практике их перебор в одной фазе event loop блокирует весь tick.
Всё упирается в фазы цикла событий:
- timers — коллбеки от setTimeout и setInterval
- pending callbacks — I/O ошибки
- idle, prepare — внутренняя кухня libuv
- poll — новые I/O события
- check — очередь setImmediate
- close callbacks — закрытие соединений
Проблема: когда у вас 10 000 активных setTimeout, фаза timers отрабатывает десятки миллисекунд. Всё остальное стоит в очереди. I/O, новые запросы, ответы — всё ждёт, пока переберутся таймеры.
Реальный случай из практики
Был сервис с heartbeats для клиентов. Каждый клиент держал рекурсивный setTimeout:
function heartBeat(clientId) {
setTimeout(() => {
// Тяжёлая операция с БД + проверка состояния
heartBeat(clientId);
}, 1000);
}Для 5000 клиентов каждый tick на фазе timers прогонял до 5000 коллбеков. Попутно лагали все остальные запросы — пользователи чувствовали задержки, мониторинг показывал event loop lag > 100ms.
Что с этим делать?
Во-первых, отлаживать. Берём профайлер Node.js:
node --prof app.js
Смотрим лог — находим, сколько времени уходит на фазу timers. Часто это открытие.
Во-вторых, оптимизировать.
- Ограничьте количество активных таймеров. Сгруппируйте несколько heartbeat-задач в один setInterval.
- Там, где возможна замена, используйте setImmediate. Он живёт на фазе check — не блокирует poll и timers.
- Мониторьте event loop lag через
process.hrtime или модуль event-loop-stats. Если lag > 50ms — это звоночек.Можно ещё раскидать тяжёлые таймерные задачи по worker_threads — чтобы они не тормозили главный поток.
Вывод:
Большое количество таймеров — тихий убийца производительности, который часто замечают слишком поздно, но своевременный профилинг и рефакторинг очередей могут снизить latency в разы.
❤2
process.nextTick в дебаунсерах: как сделать Event Loop неубиваемым?
Кажется, что
Почему nextTick опасен в дебаунсере?
*
* Это убивает производительность при частых событиях, например, в WebSocket или resize.
Что происходит под капотом?
Когда вы пишете:
То при каждом вызове кладется микрозадача. Если событие приходит каждую миллисекунду, Event Loop не успевает обработать I/O, таймеры или setImmediate. Это классический starvation.
Как диагностировать проблему?
* Используйте
* Clinic.js покажет, как микрозадачи занимают 100% цикла.
* Старый трюк — замер времени через
Правильный дебаунс — через setTimeout
Это макрозадача, которая не блокирует Event Loop.
Когда nextTick оправдан?
Только для синхронной обработки ошибок или действий, которые должны выполниться строго до следующей фазы. В дебаунсерах — никогда.
Если приложение тормозит на событиях, первым делом проверяйте дебаунсеры на наличие
Вывод:
Кажется, что
process.nextTick быстрее setTimeout(fn, 0), поэтому его часто используют в дебаунсерах. Но в production это может заблокировать Event Loop, превратив приложение в тормознутый монолит. Ошибка в том, что разработчики путают микрозадачи с макрозадачами и не видят последствий.Почему nextTick опасен в дебаунсере?
process.nextTick выполняется до всех фаз Event Loop, включая I/O и таймеры. Если дебаунсер вызывает nextTick рекурсивно или на каждое событие, микрозадачи забивают очередь, и Event Loop не может перейти к другим фазам. Результат: CPU 100%, запросы висят, приложение умирает.*
nextTick никогда не даст выполниться I/O, пока его очередь не опустеет.* Это убивает производительность при частых событиях, например, в WebSocket или resize.
Что происходит под капотом?
Когда вы пишете:
function badDebounce(fn) {
let pending = false;
return function() {
if (!pending) {
pending = true;
process.nextTick(() => {
pending = false;
fn();
});
}
};
}То при каждом вызове кладется микрозадача. Если событие приходит каждую миллисекунду, Event Loop не успевает обработать I/O, таймеры или setImmediate. Это классический starvation.
Как диагностировать проблему?
* Используйте
node --inspect и CPU profiling: ищите много вызовов nextTick в стеке.* Clinic.js покажет, как микрозадачи занимают 100% цикла.
* Старый трюк — замер времени через
process.hrtime.bigint(): если setImmediate выполняется с большой задержкой, значит, nextTick блокирует.Правильный дебаунс — через setTimeout
function debounce(fn, delay) {
let timer = null;
return function() {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, arguments), delay);
};
}Это макрозадача, которая не блокирует Event Loop.
setTimeout корректно обрабатывает события на I/O фазе и не вызывает starvation.Когда nextTick оправдан?
Только для синхронной обработки ошибок или действий, которые должны выполниться строго до следующей фазы. В дебаунсерах — никогда.
Если приложение тормозит на событиях, первым делом проверяйте дебаунсеры на наличие
process.nextTick. Это частая причина падения производительности в Node.js.Вывод:
process.nextTick в дебаунсере блокирует Event Loop и приводит к starvation I/O, используйте setTimeout как единственно правильный вариант, чтобы не убить производительность в production.⚡1
Когда нет утечки, а OOM приходит: дефрагментация кучи V8
Внезапные Out of Memory в долгоживущих Node.js-процессах при работе с буферами и стримами — частая проблема в production, особенно при файловой обработке или проксировании данных. Разработчики ищут утечки, но код часто чист — настоящая причина скрыта в фрагментации Old Space кучи V8.
Как выглядит изнутри
Интенсивная работа с
Production-oriented пример
Рассмотрим типичный стрим для обработки данных:
Каждый вызов
Типичная ошибка
V8 не гарантирует дефрагментацию: GC двигает объекты только в Young Generation, а буферы как внешние ресурсы дополнительно блокируются в Old Space. Усложняет ситуацию objectMode в стримах — он генерирует тысячи мелких объектов, ускоряя фрагментацию.
Практические советы
* Используйте пулы буферов — переиспользуйте через
* Ограничьте
* Отключайте objectMode, если он не критичен — он создает избыточные объекты.
* Применяйте
* Мониторьте
Вывод:
Дефрагментация кучи V8 — редкая, но реальная причина OOM в долгоживущих процессах с интенсивным выделением буферов, которая лечится нормальными паттернами управления памятью и проектированием потоков.
Внезапные Out of Memory в долгоживущих Node.js-процессах при работе с буферами и стримами — частая проблема в production, особенно при файловой обработке или проксировании данных. Разработчики ищут утечки, но код часто чист — настоящая причина скрыта в фрагментации Old Space кучи V8.
Как выглядит изнутри
Интенсивная работа с
Buffer.alloc, Buffer.concat и стримами создает непрерывный цикл выделения и освобождения памяти. Со временем живые объекты оставляют мелкие "дыры" в Old Space. GC отлично собирает мусор, но не может дефрагментировать неподвижные объекты в старом пространстве. Результат — FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed, хотя суммарно свободной памяти ещё много.Production-oriented пример
Рассмотрим типичный стрим для обработки данных:
const { Readable } = require('stream');
let data = Buffer.alloc(0);
const stream = new Readable({
read(size) {
data = Buffer.concat([data, Buffer.alloc(1024)]);
this.push(Buffer.alloc(1024));
}
});
stream.on('data', () => {});Каждый вызов
Buffer.concat создает новый буфер, старый закрепляется в памяти. Через часы работы — OOM при попытке выделить следующий буфер.Типичная ошибка
V8 не гарантирует дефрагментацию: GC двигает объекты только в Young Generation, а буферы как внешние ресурсы дополнительно блокируются в Old Space. Усложняет ситуацию objectMode в стримах — он генерирует тысячи мелких объектов, ускоряя фрагментацию.
Практические советы
* Используйте пулы буферов — переиспользуйте через
Buffer.allocUnsafe с ручным обнулением или свой pool.* Ограничьте
--max-old-space-size — это не панацея, но даёт запас до падения.* Отключайте objectMode, если он не критичен — он создает избыточные объекты.
* Применяйте
stream.pipeline вместо ручного управления для снижения накопления мусора.* Мониторьте
heapStats: стабильный рост malloced_memory с does_zap_garbage — явный признак фрагментации.Вывод:
Дефрагментация кучи V8 — редкая, но реальная причина OOM в долгоживущих процессах с интенсивным выделением буферов, которая лечится нормальными паттернами управления памятью и проектированием потоков.
Скрытые убийцы Event Loop: ICU и Regex в Unicode-строках
Многие знают, что JSON.parse или fs.readFileSync блокируют Event Loop. Но есть враги похитрее - синхронные вызовы ICU и регулярных выражений на unicode-строках. Типичная ошибка: разработчики не подозревают, что методы нормализации или unicode-регулярки могут висеть десятки миллисекунд, оставаясь незамеченными до production-нагрузки.
Где прячется ICU
ICU тихо сидит в методах:
*
*
*
Регулярки с флагами
Пример скрытой блокировки
Как профилировать
* Используй
* Разбрасывай логи с
* Clinic.js:
Как устранять
Для ICU:
* Выноси нормализацию в Worker Threads
* Режь на чанки по 1000 символов, обрабатывай порциями
* Предобрабатывай данные на этапе загрузки, а не в рантайме
Для RegExp:
* Типичная ошибка: паттерны с backtracking, например
* Попробуй
* Разбивай строку через
Практический совет
Используй
Вывод: ICU и unicode-регекспы выглядят безобидно, но регулярно просаживают производительность - профилируйте их отдельно под нагрузкой, а не только в изолированных тестах.
Многие знают, что JSON.parse или fs.readFileSync блокируют Event Loop. Но есть враги похитрее - синхронные вызовы ICU и регулярных выражений на unicode-строках. Типичная ошибка: разработчики не подозревают, что методы нормализации или unicode-регулярки могут висеть десятки миллисекунд, оставаясь незамеченными до production-нагрузки.
Где прячется ICU
ICU тихо сидит в методах:
*
String.prototype.normalize()*
String.prototype.toLocaleLowerCase()*
Intl APIРегулярки с флагами
/u или /s на больших строках (эмодзи, иероглифы) могут висеть синхронно десятки миллисекунд. И ты даже не сразу поймешь, почему твой сервер тупит.Пример скрытой блокировки
const hugeText = '🦄'.repeat(100000);
const start = performance.now();
hugeText.normalize('NFC'); // ICU молча обрабатывает 100к символов
console.log(Блокировка: ${performance.now() - start}ms);
Как профилировать
* Используй
--prof флаг Node.js, потом node --prof-process.* Разбрасывай логи с
performance.now() до и после подозрительных операций.* Clinic.js:
npx clinic doctor -- node app.jsКак устранять
Для ICU:
* Выноси нормализацию в Worker Threads
* Режь на чанки по 1000 символов, обрабатывай порциями
* Предобрабатывай данные на этапе загрузки, а не в рантайме
const { Worker } = require('worker_threads');
const worker = new Worker('./normalize-worker.js');
worker.postMessage(hugeText);Для RegExp:
* Типичная ошибка: паттерны с backtracking, например
(a+)+b. Меняй на a+b.* Попробуй
re2 - без backtracking, работает предсказуемо* Разбивай строку через
string.slice, если можноconst safePattern = /^[a-z]+$/u;
Практический совет
Используй
monitorEventLoopDelay() из perf_hooks - он показывает задержки Event Loop в наносекундах. Запусти его в фоне и сравнивай с пиками нагрузки.Вывод: ICU и unicode-регекспы выглядят безобидно, но регулярно просаживают производительность - профилируйте их отдельно под нагрузкой, а не только в изолированных тестах.
❤2
Неявный pinning в V8: как скрытые ссылки в прототипах и замыканиях мешают GC собирать мусор в production
Замечали, как Node.js внезапно начинает жрать память, хотя все объекты вроде уже не нужны? Чаще всего проблема в неявном pinning — GC просто не видит, что объекты можно собирать, из-за скрытых ссылок, живых прототипов и замыканий, которые V8 ошибочно считает нужными.
Прототипы и скрытые классы
У каждого объекта в V8 есть скрытый класс (HiddenClass) и ссылка на прототип. Пока жив экземпляр, весь прототип со всеми методами и данными остаётся в памяти. Это особенно критично для утечек через кеши:
Замыкания и живые контексты
V8 оптимизирует замыкания, но ошибается: если внутри замыкания объявлена большая переменная, а используется только часть контекста, GC всё равно держит всю аллокацию:
Неявные ссылки через WeakMap и Symbol.species
Слабая карта не гарантирует сборку — если ключ жив, значение тоже живёт. Symbol.species в подклассах может создать неявную ссылку на конструктор, удерживая целый прототип. Типичная ошибка — использовать Map для кеша, когда нужно WeakMap.
Что делать в production:
* Для кеширования используй WeakMap, а не Map — это единственный безопасный способ.
* В замыканиях явно зануляй большие объекты:
* Снимай heap snapshots через
* После создания массива экземпляров можно занулить
Вывод:
Одна невидимая ссылка через прототип или замыкание может закрепить сотни мегабайт в куче — используй WeakMap, явное зануление и профилирование, чтобы production не упал по памяти.
Замечали, как Node.js внезапно начинает жрать память, хотя все объекты вроде уже не нужны? Чаще всего проблема в неявном pinning — GC просто не видит, что объекты можно собирать, из-за скрытых ссылок, живых прототипов и замыканий, которые V8 ошибочно считает нужными.
Прототипы и скрытые классы
У каждого объекта в V8 есть скрытый класс (HiddenClass) и ссылка на прототип. Пока жив экземпляр, весь прототип со всеми методами и данными остаётся в памяти. Это особенно критично для утечек через кеши:
function Data(id) {
this.id = id;
this.buffer = new ArrayBuffer(100);
}
const obj = new Data(1);
// Если obj утёк через замыкание — Data.prototype не соберётся GCЗамыкания и живые контексты
V8 оптимизирует замыкания, но ошибается: если внутри замыкания объявлена большая переменная, а используется только часть контекста, GC всё равно держит всю аллокацию:
function createProcessor() {
const hugeData = new ArrayBuffer(500); // утечка
return {
process: () => console.log('ok'),
release: () => {}
};
}
// Пока жив processor, hugeData не соберётсяНеявные ссылки через WeakMap и Symbol.species
Слабая карта не гарантирует сборку — если ключ жив, значение тоже живёт. Symbol.species в подклассах может создать неявную ссылку на конструктор, удерживая целый прототип. Типичная ошибка — использовать Map для кеша, когда нужно WeakMap.
Что делать в production:
* Для кеширования используй WeakMap, а не Map — это единственный безопасный способ.
* В замыканиях явно зануляй большие объекты:
hugeData = null после создания.* Снимай heap snapshots через
node --inspect или Chrome DevTools — ищи пачки объектов с одинаковым прототипом.* После создания массива экземпляров можно занулить
Constructor.prototype.constructor = null — это ломает ссылку на конструктор, но рискованно.Вывод:
Одна невидимая ссылка через прототип или замыкание может закрепить сотни мегабайт в куче — используй WeakMap, явное зануление и профилирование, чтобы production не упал по памяти.
Atomics.wait заблокировал event loop — как не убить Node.js в production
Общая память через
Как блокировка ломает Event Loop
Когда worker вызывает
Production-сценарий: дедлок коммуникации
Часто main thread посылает worker задачу и ждёт ответа через
Типичная ошибка: забыть про альтернативы
Многие пишут
Практический совет:
Используйте экспериментальный
Вывод:
Общая память через
SharedArrayBuffer даёт мощную межпоточную синхронизацию, но некорректное использование Atomics.wait в worker_threads способно полностью парализовать приложение. Частая ошибка: разработчики забывают, что Atomics.wait — синхронный блокирующий вызов, который останавливает весь поток, включая event loop.Как блокировка ломает Event Loop
Когда worker вызывает
Atomics.wait(buf, 0, 0), он зависает, пока другой поток не изменит значение. В main thread такой вызов выбрасывает TypeError, но внутри worker это законно. Поток перестаёт обрабатывать сообщения и таймеры — worker становится мёртвым.Production-сценарий: дедлок коммуникации
Часто main thread посылает worker задачу и ждёт ответа через
postMessage. Если worker перед этим вызвал Atomics.wait и заблокировался — ответ никогда не придёт. Приложение зависает без краша или явной ошибки.// worker.js
const { parentPort, workerData } = require('worker_threads');
const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);
Atomics.wait(view, 0, 0); // Worker hangs forever
parentPort.postMessage('done'); // Never reached
Типичная ошибка: забыть про альтернативы
Многие пишут
Atomics.wait без таймаута или не проверяют, что воркеры уже заняты. Всегда указывайте значение таймаута: Atomics.wait(buf, 0, 0, 1000) — хотя бы не бесконечное ожидание.Практический совет:
Atomics.waitAsync или изоляцияИспользуйте экспериментальный
Atomics.waitAsync() для неблокирующего ожидания. Либо выносите всю логику блокирующих вызовов в отдельные воркеры, которые общаются только через Atomics.store и Atomics.load без синхронных ожиданий.Вывод:
Atomics.wait в worker_threads может полностью убить event loop, если не контролировать время блокировки или связь с main thread.Гонки данных в AsyncLocalStorage при HTTP/2: подводный камень, который ловит даже опытных
Используешь
В чём подвох
Как это выглядит на практике
Обычно думают: раз
Почему это ломается
Что делать
* Не используй общие изменяемые объекты внутри ALS для разных потоков. Каждый поток - свой Map, свой instance.
* Проверяй идемпотентность: если
* После завершения потока зови
* Для воркеров передавай контекст явно через
Безопасный вариант
Вывод: Даже с async_hooks явное создание изолированного состояния на каждый поток - единственный надёжный способ избежать гонок данных при HTTP/2 мультиплексировании.
Используешь
AsyncLocalStorage для проброса контекста запроса через всю цепочку обработки? Нормальная практика. Но когда приходит HTTP/2 с мультиплексированием, привычная схема als.run(new Map(), () => next()) может дать сбой. И доки про это молчат.В чём подвох
AsyncLocalStorage в Node.js работает через async_hooks - он привязан к цепочке асинхронных операций. HTTP/2 потоки - это отдельные асинхронные цепочки, которые выполняются конкурентно. Если в коде есть общие ресурсы (пул соединений с БД, общий кеш, переменные на уровне модуля), начинается гонка. Поток A записывает userId в хранилище, поток B его перезаписывает, и A при чтении получает данные B.Как это выглядит на практике
const als = new AsyncLocalStorage();
const server = http2.createServer();
server.on('stream', (stream) => {
als.run(new Map(), async () => {
const store = als.getStore();
store.set('userId', await getUserId(stream));
});
});
Обычно думают: раз
async/await сохраняет контекст через хуки, всё чисто. Но проблема не в разрыве контекста, а в том, что несколько потоков одновременно пишут в один и тот же Map. Асинхронная изоляция работает только для одной цепочки, а не для нескольких параллельных.Почему это ломается
async_hooks корректно отслеживает цепочки Promise. Но HTTP/2 потоки могут попасть в один тик event loop. Если код использует синхронные побочные эффекты после await (вроде store.set), хуки не спасут - данные уже перемешались. Контекст сохраняется, но параллельные set() делают своё дело.Что делать
* Не используй общие изменяемые объекты внутри ALS для разных потоков. Каждый поток - свой Map, свой instance.
* Проверяй идемпотентность: если
setUserId вызывается дважды для одного потока - ок. Но если два потока пишут в один объект - ошибка.* После завершения потока зови
als.disable() - чтобы не копить утечки памяти.* Для воркеров передавай контекст явно через
postMessage. Надеяться, что ALS дотянется до другого процесса, не стоит.Безопасный вариант
server.on('stream', (stream) => {
const store = new Map(); // локальный для потока
store.set('start', Date.now());
als.run(store, () => processStream(stream));
});Вывод: Даже с async_hooks явное создание изолированного состояния на каждый поток - единственный надёжный способ избежать гонок данных при HTTP/2 мультиплексировании.
Как WeakRef в EventEmitter блокирует GC на секунды и что с этим делать
В high-load Node.js приложениях EventEmitter с подписками через WeakRef часто приводит к pause-блокировкам GC до 2-5 секунд. Разработчики ошибочно полагают, что WeakRef автоматически решает утечки памяти, но на деле он продлевает жизнь long-lived объектам в old-space.
Причина пауз
WeakRef.deref() возвращает объект только при наличии сильной ссылки. Если такой объект попадает в old-space, V8 на этапе Mark-Sweep-Compact задерживается на его трассировке. Пример:
Пока Listener жив через другую ссылку, GC видит его как long-lived, вызывая блокировку.
Диагностика в production
Запусти
Практический совет
Не используй WeakRef в горячих путях EventEmitter. Вместо этого явно управляй подписками:
Это убирает deref и GC не тратит время на weak-ссылки.
Типичная ошибка
Держать сильную ссылку на объект, подписанный через WeakRef. Правило "never hold weak" нарушено — GC не может собрать ни weak, ни strong объект. Решение: если WeakRef обязателен, объединяй обработчики в один WeakSet для контроля.
Вывод: Проблема не в производительности GC, а в архитектуре — либо откажись от WeakRef в EventEmitter, либо делай подписчики короткоживущими, иначе GC-паузы неизбежны.
В high-load Node.js приложениях EventEmitter с подписками через WeakRef часто приводит к pause-блокировкам GC до 2-5 секунд. Разработчики ошибочно полагают, что WeakRef автоматически решает утечки памяти, но на деле он продлевает жизнь long-lived объектам в old-space.
Причина пауз
WeakRef.deref() возвращает объект только при наличии сильной ссылки. Если такой объект попадает в old-space, V8 на этапе Mark-Sweep-Compact задерживается на его трассировке. Пример:
const { EventEmitter } = require('events');
const emitter = new EventEmitter();
class Listener { handleEvent() { /* тяжелая логика */ } }
const ref = new WeakRef(new Listener());
emitter.on('data', () => ref.deref()?.handleEvent());Пока Listener жив через другую ссылку, GC видит его как long-lived, вызывая блокировку.
Диагностика в production
Запусти
node --trace-gc app.js и ищи строки Mark-sweep с временем более 100мс. Сравни heapUsed через process.memoryUsage() — неравномерный рост указывает на застревание объектов. Используй Chrome DevTools: Allocation Timeline покажет, как объекты переходят из young в old-space.Практический совет
Не используй WeakRef в горячих путях EventEmitter. Вместо этого явно управляй подписками:
class SafeListener { handle() {} }
const l = new SafeListener();
emitter.on('data', l.handle.bind(l));
// Освобождение
emitter.off('data', l.handle.bind(l));Это убирает deref и GC не тратит время на weak-ссылки.
Типичная ошибка
Держать сильную ссылку на объект, подписанный через WeakRef. Правило "never hold weak" нарушено — GC не может собрать ни weak, ни strong объект. Решение: если WeakRef обязателен, объединяй обработчики в один WeakSet для контроля.
Вывод: Проблема не в производительности GC, а в архитектуре — либо откажись от WeakRef в EventEmitter, либо делай подписчики короткоживущими, иначе GC-паузы неизбежны.
❤1
Деоптимизация ESM: глубокий импорт против Lazy Loading
Один из частых антипаттернов в Node.js — «островная» организация импортов, когда файл A статически импортирует B, B → C и так до 10+ уровней. Для CommonJS это было не так критично, но с ESM и строгой статической структурой модулей (resolution до выполнения кода) такая цепочка напрямую влияет на cold start.
Как это работает
При
Пример: холодок и стек импортов
На cold start все 4 резолвятся синхронно до выполнения кода. Если что-то не используется немедленно — деоптимизация.
Кейсы с lazy loading (динамический import)
Положительный пример: превращаем статический импорт в динамический.
Но! Если
Решение: lazy экспорт всего модуля целиком или ESM-дерево с вершинным
Ошибка: игнорировать цепочки при lazy loading
Типичная ошибка — думать, что любой
Как применять на практике
В production не злоупотребляйте статическими импортами в long chains, если модули не нужны на старте. Lazy loading спасает cold start, но требует ревизии цепочек: load time может мигрировать. Best practice: в entry point оставлять только абсолютно необходимые модули. Всё остальное — под
Вывод: Статические цепочки ESM синхронно резолвят весь граф на cold start, а lazy loading переносит лаг на первый запрос при наличии внутренних статических импортов.
Один из частых антипаттернов в Node.js — «островная» организация импортов, когда файл A статически импортирует B, B → C и так до 10+ уровней. Для CommonJS это было не так критично, но с ESM и строгой статической структурой модулей (resolution до выполнения кода) такая цепочка напрямую влияет на cold start.
Как это работает
При
import в ESM рантайм строит граф зависимостей до выполнения. Чем глубже цепочка, тем больше модулей нужно зарезолвить, прочитать с диска, спарсить и скомпилировать. Каждый шаг — I/O (особенно без кэша ФС), в больших проектах — сотни файлов. Результат: старт приложения может проседать на 30-50%.Пример: холодок и стек импортов
// entry.js → app.js → services/user.js → utils/validation.js → helpers/time.js
import './app.js' // тащит 4 файла за раз
На cold start все 4 резолвятся синхронно до выполнения кода. Если что-то не используется немедленно — деоптимизация.
Кейсы с lazy loading (динамический import)
Положительный пример: превращаем статический импорт в динамический.
// было:
import { expensiveFunction } from './heavy-module.js'
// стало:
const { expensiveFunction } = await import('./heavy-module.js')
await import() исполняется в момент вызова, не блокируя холодный старт. Полезно для: роутеров, сборщиков, SDK с тяжёлой инициализацией.Но! Если
heavy-module сам статически импортирует другие модули, при динамическом вызове весь его граф резолвится в момент выполнения — лаг переносится на первый запрос (cold start → warm request).// heavy-module.js
import { superHeavy } from 'super-heavy-lib' // статический импорт
// при await import('./heavy-module.js') — superHeavy загружается синхронно
Решение: lazy экспорт всего модуля целиком или ESM-дерево с вершинным
export, где все зависимости — динамические.Ошибка: игнорировать цепочки при lazy loading
Типичная ошибка — думать, что любой
await import() магически решает проблему cold start. На деле, если внутри модуля есть статические импорты, они все равно загрузятся синхронно при первой загрузке. Всегда проверяйте граф зависимостей под massive import.Как применять на практике
В production не злоупотребляйте статическими импортами в long chains, если модули не нужны на старте. Lazy loading спасает cold start, но требует ревизии цепочек: load time может мигрировать. Best practice: в entry point оставлять только абсолютно необходимые модули. Всё остальное — под
await import() с экспортами, где зависимости тоже динамические.Вывод: Статические цепочки ESM синхронно резолвят весь граф на cold start, а lazy loading переносит лаг на первый запрос при наличии внутренних статических импортов.
WebSocket backpressure в Node.js: когда ws.send() без контроля ведёт к OOM
Игнорирование скорости потребления данных клиентом при push-рассылках — одна из частых причин падения Node.js-процесса из-за нехватки памяти. Проблема кроется в том, что библиотека
Через bufferedAmount
Каждый сокет библиотеки
Событие drain
После освобождения буфера сокет генерирует событие
Типичная ошибка — навешивать
Собственная очередь с лимитом
Если число неотправленных сообщений превышает лимит, отключайте клиента через
В production при push-рассылках на 10 тысяч клиентов с
Мониторинг
Логируйте клиентов с
Вывод: Проверяйте
Игнорирование скорости потребления данных клиентом при push-рассылках — одна из частых причин падения Node.js-процесса из-за нехватки памяти. Проблема кроется в том, что библиотека
ws буферизует неотправленные фреймы в очереди, которая растёт линейно при медленных или спящих клиентах.Через bufferedAmount
Каждый сокет библиотеки
ws имеет свойство bufferedAmount, показывающее количество байт в очереди на отправку. Перед вызовом ws.send() проверяйте порог. Если он превышен — пауза отправки.if (ws.bufferedAmount > 1_000_000) {
ws.pause();
}Событие drain
После освобождения буфера сокет генерирует событие
drain. Его можно использовать для возобновления отправки, выстраивая собственную очередь с контролем размера.function sendOrQueue(ws, data) {
if (ws.bufferedAmount > 0) {
ws.once('drain', () => ws.send(data));
} else {
ws.send(data);
}
}Типичная ошибка — навешивать
drain на каждый send() без очистки предыдущих обработчиков. Это приводит к утечкам слушателей.Собственная очередь с лимитом
Если число неотправленных сообщений превышает лимит, отключайте клиента через
ws.terminate(). Иначе память растёт неконтролируемо.const MAX_QUEUE = 2000;
if (ws.bufferedAmount > MAX_QUEUE * 1000) {
ws.terminate();
return;
}
В production при push-рассылках на 10 тысяч клиентов с
bufferedAmount > 1 MB каждый, процесс потребляет 10+ GB RAM и падает с OOM.Мониторинг
Логируйте клиентов с
bufferedAmount > 512 KB. Это ранний индикатор проблем. В библиотеке ws до версии 8.x отсутствовал встроенный контроль потока, а net.Socket использует backpressure через write() + drain, но ws его не учитывает по умолчанию.Вывод: Проверяйте
bufferedAmount, используйте drain и задавайте жёсткие лимиты очереди — иначе незаметный OOM гарантирован при любой расходящейся нагрузке.Утечка памяти, которую вы не заметите: rejections в for await...of и стримы
Обработка ошибок в асинхронных итераторах и стримах кажется интуитивной, но неочевидные rejections внутри генератора или метода
Почему это происходит?
Когда вы используете
Пример с генератором:
Хотя ошибка пробросится, внутренние ресурсы — открытые соединения, таймеры — могут остаться висеть.
В стримах ситуация опаснее:
Если
Практические советы:
* Всегда обрабатывайте rejections через try-catch внутри итератора и явно завершайте итерацию —
* Для стримов используйте
* Добавьте
* Проверяйте утечки через heap snapshots или
Вывод:
Обработка ошибок в асинхронных итераторах и стримах кажется интуитивной, но неочевидные rejections внутри генератора или метода
_read() могут привести к зависанию итерации и неосвобождению памяти. В production это проявляется как постепенный рост heap-usage без видимых причин.Почему это происходит?
Когда вы используете
for await...of, движок ожидает объект { value, done } от итератора. Если внутри асинхронного генератора возникает rejection (например, от необработанного промиса), итерация может зависнуть в бесконечном ожидании, а буферы стрима или данные генератора останутся в памяти.Пример с генератором:
async function* gen() {
yield 1;
await Promise.reject(new Error('fail'));
yield 2; // не достигнется
}
for await (const v of gen()) { } // зависнет?Хотя ошибка пробросится, внутренние ресурсы — открытые соединения, таймеры — могут остаться висеть.
В стримах ситуация опаснее:
const { Readable } = require('stream');
const stream = new Readable({
async read() {
const data = await riskyOp();
this.push(data);
// если rejection без this.destroy() - утечка
}
});
for await (const chunk of stream) { }Если
read() не завершится вызовом this.destroy(err), стрим не сгенерирует событие 'error' и не очистит внутренний буфер.Практические советы:
* Всегда обрабатывайте rejections через try-catch внутри итератора и явно завершайте итерацию —
throw или return.* Для стримов используйте
this.destroy(err) при ошибках в _read или предпочтительно pipeline для автоматической обработки.* Добавьте
finally для гарантированной очистки ресурсов (закрытие соединений, таймеров).* Проверяйте утечки через heap snapshots или
process.on('warning') — не стесняйтесь мониторить.Вывод:
for await...of не обеспечивает автоматической очистки, и необработанные rejections — прямой путь к утечке памяти, поэтому всегда завершайте итерацию явно.❤1👍1
Внутри статьи она подробно расписывает этапы собеседований, лайфхаки и делится учебными ресурсами, которые ей помогли.
Плюс девушка великодушно оставила ссылки на свой Notion с полезными заметками по математике и LLM.
Please open Telegram to view this post
VIEW IN TELEGRAM
❤2