Команда JavaScript for Devs подготовила перевод статьи о том, как Node.js выжимает максимум из I/O в 2025 году. В ней подробно объясняется цикл событий, порядок фаз и то, как правильно писать код, чтобы серверы оставались отзывчивыми даже под нагрузкой.
Разработчики до сих пор спотыкаются о старые вопросы: если рантайм однопоточный, почему он не зависает?
Почему setTimeout(0)
срабатывает «с задержкой»? Куда на самом деле попадают Promises, process.nextTick
и setImmediate
? В этой статье я разберу цикл событий простыми словами, покажу, куда уходит время, и поделюсь приёмами, которые помогают серверам оставаться отзывчивыми под нагрузкой.
Зачем нужен цикл событий
Рантайм выполняет JavaScript в одном потоке, но не даёт себе увязнуть в медленных операциях ввода-вывода. Долгие задачи передаются операционной системе через нативный слой; когда результат готов, JavaScript вызывается снова. Цикл событий управляет этим процессом: он проходит по чётко определённым фазам, выбирает готовые колбэки из внутренних очередей и выполняет их в порядке приоритета. Поняв этот порядок, вы перестанете удивляться странным задержкам и сможете настраивать производительность осознанно, а не наугад.
Один поток, неблокирующее поведение — никакой магии
Когда программа запускается, выполняется весь код из начального модуля: импорты, объявления, запуск сервера и планирование асинхронных задач. Чтение файла, принятие соединения по сокету или установка таймера не блокируют этот первый проход. Тяжёлая работа выполняется за пределами JavaScript: ОС следит за завершением операции и уведомляет рантайм, который затем ставит ваши колбэки в очередь для следующих фаз цикла.
Представьте это так: ваш код говорит «прочитай этот файл», ОС отвечает «дам знать, когда закончу», а цикл событий через регулярные промежутки проверяет, что готово, и забирает результаты.
Фазы, делающие тайминг предсказуемым
Без таблиц — только порядок и главное, что стоит помнить:
Timers
Колбэки от setTimeout
/setInterval
, у которых истекла минимальная задержка. Задержка — это нижняя граница, а не гарантия; длинная синхронная работа перед этой фазой отодвигает таймеры назад.
Pending Callbacks
Оставшиеся низкоуровневые системные колбэки, отложенные с предыдущего прохода цикла (часто обработка ошибок I/O). Это предотвращает блокировку других фаз тяжёлой системной работой.
Idle/Prepare
Внутренняя подготовка перед проверкой ввода-вывода. Ваш код здесь не выполняется.
Poll
Сердце цикла. Рантайм собирает готовые операции ввода-вывода (входящие запросы, чтение файлов и т. д.) и выполняет их колбэки. Если делать нечего, он может эффективно «подождать» здесь, пока не появятся новые события ввода-вывода, не сработает таймер или не придёт очередь setImmediate
.
Check
Колбэки, запланированные через setImmediate
. Полезно, если нужно выполнить работу после текущего цикла I/O, но до следующей партии таймеров.
Close Callbacks
Финализация при закрытии ресурсов: сокетов, серверов, дескрипторов. Здесь выполняются обработчики вроде 'close'
.
Есть ещё две части, которые встраиваются «между» фазами:
process.nextTick — выполняется сразу после текущей операции, ещё до microtasks и до перехода к фазе цикла событий. Самый высокий приоритет; чрезмерное использование может «задушить» цикл.
Microtasks (Promises) — обработчики
.then
/.catch
/.finally
запускаются после всехnextTick
-колбэков, но всё ещё до перехода к следующей фазе цикла.
Запомните этот ритм:
(ваш синхронный код) → nextTick → microtasks → Timers → nextTick → microtasks → Pending → … → Poll → microtasks → Check → microtasks → Close → …
Три практических сценария (и как их исправить)
1. Дрейф интервала: почему он возникает и как его исправить
Ситуация: нужно опрашивать внешний сервис раз в секунду:
setInterval(tick, 1000);
function tick() {
const t0 = Date.now();
// имитируем нагрузку на CPU
while (Date.now() - t0 < 300) {} // ~300 мс синхронной нагрузки
console.log('Check at', new Date().toISOString());
}
Что происходит на самом деле.
Каждое выполнение блокирует поток примерно на 300 мс. Поскольку цикл не может запустить следующий вызов, пока не завершится текущий, «интервал в 1 секунду» начинает плыть: вызовы всё больше отстают.
Лучший подход (компенсация дрейфа).
let planned = Date.now() + 1000;
function tick() {
const start = Date.now();
// делаем полезную синхронную работу (в разумных пределах)
while (Date.now() - start < 300) {}
console.log('Check at', new Date().toISOString());
const now = Date.now();
planned += 1000; // идеальная метка времени для следующего запуска
const delay = Math.max(0, planned - now);
setTimeout(tick, delay);
}
setTimeout(tick, 1000);
Почему это работает.
Вместо фиксированного интервала мы целимся в идеальный график и каждый раз подстраиваем задержку, чтобы попасть в него. Нагрузка остаётся, но ошибка не накапливается, и средний темп остаётся близким к одной секунде.
2. «Голодание» цикла из-за process.nextTick (и как этого избежать)
Ситуация: хотим мгновенно обработать длинную цепочку задач, поэтому каждое действие ставим через process.nextTick
:
function schedule(i = 0) {
if (i >= 1e5) return;
process.nextTick(() => {
if (i % 20000 === 0) console.log('Processed', i);
schedule(i + 1);
});
}
schedule();
console.log('Start');
Что происходит на самом деле.
nextTick
выполняется раньше микрозадач и любых фаз цикла. Рекурсивная постановка в очередь создаёт бесконечный поток колбэков с наивысшим приоритетом. Цикл не может дойти до Poll (I/O), Timers и даже до Promises: это и есть «голодание».
Лучший подход (пакетная обработка + уступить циклу через setImmediate).
const BATCH = 1000;
function schedule(i = 0) {
const end = Math.min(i + BATCH, 1e5);
while (i < end) {
if (i % 20000 === 0) console.log('Processed', i);
// выполняем небольшой объём синхронной работы
i++;
}
if (i < 1e5) {
setImmediate(() => schedule(i)); // отдаём управление Poll/Timers/Promises
}
}
schedule();
console.log('Start');
Почему это работает.
setImmediate
срабатывает на фазе Check, после Poll. Это даёт движку время принять новые сокеты, обработать готовые таймеры и выполнить микрозадачи. Пропускная способность растёт, а приложение остаётся отзывчивым.
3. Стриминг + CPU без остановки I/O
Ситуация: я читаю большой файл и выполняю нетривиальную обработку. Если запускать тяжёлую CPU-работу внутри обработчика 'data'
, это блокирует поток, который принимает следующие куски данных.
const fs = require('fs');
const chunks = [];
const stream = fs.createReadStream('big.log');
stream.on('data', (buf) => {
chunks.push(buf);
// избегаем тяжёлой CPU-работы прямо здесь
});
stream.on('end', () => {
console.log('File complete');
});
Лучший подход (накапливать небольшие партии; считать после I/O через setImmediate).
const fs = require('fs');
let bucket = [];
let scheduled = false;
function flush() {
// ресурсоёмкая обработка содержимого 'bucket'
bucket = [];
scheduled = false;
}
fs.createReadStream('big.log')
.on('data', (buf) => {
bucket.push(buf);
if (!scheduled) {
scheduled = true;
setImmediate(flush); // запустить после текущего цикла Poll
}
})
.on('end', () => {
if (bucket.length) flush();
console.log('Done');
});
Почему это работает
Не выполняя тяжёлую работу в колбэке 'data'
, я позволяю фазе Poll стабильно продолжать стриминг. Сама CPU-работа идёт на фазе Check, что предсказуемо и не «душит» ввод-вывод.
Практические правила, которые сэкономят часы отладки
setTimeout(fn, 0)
≠ «прямо сейчас». Это значит «как только цикл дойдёт до фазы Timers», а она идёт после текущего синхронного кода,nextTick
и microtasks.Используйте
setImmediate
для «после I/O, до новых таймеров». Отлично подходит, если нужно отреагировать, когда текущий цикл Poll завершился.Используйте
process.nextTick
умеренно. Он идеален для завершения конструктора, выброса ошибки или выполнения кода сразу после разворота текущего стека. Не стройте на нём длинные очереди.Promises выполняются как microtasks. Они идут после всех колбэков
nextTick
и до следующей фазы цикла. Поэтому.then
может сработать раньше таймера.Ограничивайте синхронную работу. Длинные CPU-участки — главная причина скачков задержки. Разбивайте их на части с помощью
setImmediate
, выносите в worker threads или отдельные процессы — или комбинируйте оба подхода.Ожидайте неточности таймеров под нагрузкой. Таймеры имеют минимальную задержку, но не являются real-time alarm. Компенсируйте дрейф, когда важна точность (см. пример 1).
Распространённые антипаттерны (и как их исправить)
Делать «всё» внутри одного колбэка. Разделяйте на короткие шаги и возвращайте управление циклу между ними.
Слепо доверять
setInterval
. Поддерживайте идеальный график и вычисляйте каждую задержку динамически; не позволяйте дрейфу накапливаться.Неограниченный буферинг. Буферы конечны. Старайтесь ограничивать размер и время накопления, применяйте backpressure и обрабатывайте данные партиями в предсказуемые фазы.
Рекурсивные цепочки
nextTick
. Заменяйте их пакетной обработкой и использованиемsetImmediate
или микрозадач там, где это уместно.
Коротко о нативном слое
Под капотом компактная нативная библиотека абстрагирует возможности ОС: файловые дескрипторы, таймеры и уведомления о готовности. Она поддерживает единообразную модель на всех платформах, чтобы вы могли рассуждать в терминах очередей и фаз, а не системных вызовов конкретной ОС. Ценность для повседневной разработки — предсказуемость: как только вы понимаете, как колбэки проходят через фазы, вы можете размещать работу там, где ей место.
Русскоязычное JavaScript сообщество

Друзья! Эту статью перевела команда «JavaScript for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Frontend. Подписывайтесь, чтобы быть в курсе и ничего не упустить!
Главная мысль
Цикл событий — не чёрный ящик. Это фиксированный ритм: Timers → Pending → Idle/Prepare → Poll → Check → Close, с process.nextTick
и микрозадачами Promises, встроенными в определённые точки. Если проектировать код с учётом этого ритма, можно уменьшить задержки, избежать «голодания» и сохранить высокую пропускную способность. Когда вы «мысленно раскладываете код по фазам», баги с таймингом превращаются в простые решения о том, где разместить работу.
pavlushk0
А в каких uv_handle_t обрабатываются микротаски? Из доков libuv не ясно. По схеме из статьи они выполняются между idle check poll и т.д. но если глянуть в uv_run там нет ничего такого. Или нода использует патчиный libuv?