Очереди в Node.js: что реально работает на проде
Отправка письма прямо в обработчике HTTP кладёт время ответа в зависимость от чужого SMTP. Разбираем, как вынести долгие задачи в очередь и не потерять их при перезапуске процесса.

Обработчик регистрации отправляет приветственное письмо, генерирует превью аватара и пишет событие в аналитику. На локальной машине это 200 мс, на проде под нагрузкой SMTP-сервер отвечает за 4 секунды, и клиент видит спиннер всё это время. Хуже того: если Node.js-процесс упадёт между записью пользователя в базу и отправкой письма, письмо не уйдёт никогда — а вы об этом не узнаете. Ниже — практические варианты выноса таких задач в очередь и цена каждого из них.
Речь о Node.js 20/22 LTS. Код на CommonJS и ESM ведёт себя одинаково, различия отмечены отдельно.
Почему setImmediate и «просто не ждать промис» — не очередь
Первое, что приходит в голову — не дожидаться промиса:
// антипример: fire-and-forget
app.post('/register', async (req, res) => {
const user = await createUser(req.body);
// отправляем письмо и НЕ ждём — ответ уходит сразу
sendWelcomeEmail(user).catch((err) => {
console.error('письмо не ушло', err);
});
res.json({ ok: true });
});Ответ клиенту действительно ускорится. Но у этого решения три дыры, которые вскрываются именно под нагрузкой:
- Задача живёт в памяти процесса.
SIGTERMпри деплое или падение — и незавершённые письма пропали без следа. - Нет ограничения параллельности. Тысяча регистраций за минуту — тысяча одновременных SMTP-соединений, и сервер провайдера начинает отдавать
421 Too many connections. - Нет повторов. Разовая ошибка сети означает потерянную задачу.
Очередь — это не про «сделать асинхронно». Это про то, чтобы задача пережила перезапуск процесса и получила гарантированное число повторов. Всё остальное — детали реализации.
Три уровня решений
1. In-memory очередь: только для необязательных задач
Пакет p-queue ограничивает параллельность внутри процесса. Он не переживает перезапуск, поэтому подходит только там, где потеря задачи некритична — например, отправка необязательной аналитики.
// p-queue 8.x, ESM
import PQueue from 'p-queue';
// максимум 5 задач одновременно
const queue = new PQueue({ concurrency: 5 });
app.post('/register', async (req, res) => {
const user = await createUser(req.body);
queue.add(() => sendAnalyticsEvent('register', user.id));
res.json({ ok: true });
});Плюс — ноль инфраструктуры. Минус — при рестарте всё, что стояло в очереди, исчезает. Для писем и платежей не годится.
2. Redis + BullMQ: рабочая лошадка
Если задачу нельзя терять, нужна очередь с хранением состояния вне процесса. Стандарт де-факто в экосистеме — BullMQ поверх Redis. Задачи сохраняются в Redis, воркер забирает их и подтверждает выполнение; при падении воркера задача вернётся в очередь.
Разделяйте код на две части: продюсер (кладёт задачу, живёт в HTTP-процессе) и воркер (обрабатывает, запускается отдельным процессом).
// queue.js — общий модуль, BullMQ 5.x
import { Queue } from 'bullmq';
const connection = { host: '127.0.0.1', port: 6379 };
export const emailQueue = new Queue('email', { connection });// в HTTP-обработчике — только кладём задачу
import { emailQueue } from './queue.js';
app.post('/register', async (req, res) => {
const user = await createUser(req.body);
await emailQueue.add(
'welcome',
{ userId: user.id, email: user.email },
{
attempts: 3, // до 3 попыток
backoff: { type: 'exponential', delay: 1000 }, // 1с, 2с, 4с
},
);
res.json({ ok: true });
});// worker.js — отдельный процесс
import { Worker } from 'bullmq';
import { sendWelcomeEmail } from './mailer.js';
const connection = { host: '127.0.0.1', port: 6379 };
const worker = new Worker(
'email',
async (job) => {
// job.data — то, что положили в add()
await sendWelcomeEmail(job.data.email, job.data.userId);
},
{ connection, concurrency: 5 },
);
worker.on('failed', (job, err) => {
// сюда попадём после исчерпания attempts
console.error(`задача ${job?.id} упала:`, err.message);
});Воркер запускается как отдельная команда (node worker.js) и масштабируется горизонтально: три процесса-воркера разберут очередь втрое быстрее, Redis сам распределит задачи между ними.
3. Внешний брокер: RabbitMQ, когда очередь перерастает Redis
Если нужны сложная маршрутизация сообщений, гарантии доставки уровня AMQP или очередь общая для сервисов на разных языках — Redis-обёртки становятся тесны. Тогда смотрят в сторону RabbitMQ (клиент amqplib) или Kafka. Это уже отдельная инфраструктура со своим сопровождением; заводить её ради писем при регистрации — избыточно.
Сравнение
| Критерий | p-queue | BullMQ + Redis | RabbitMQ |
|---|---|---|---|
| Переживает рестарт процесса | Нет | Да | Да |
| Инфраструктура | Не нужна | Redis | Брокер + сопровождение |
| Повторы и backoff | Вручную | Из коробки | Из коробки |
| Отложенные задачи | Нет | Да | Через плагины |
| Порог входа | Минимальный | Средний | Высокий |
Когда что выбирать
- Задачу не жалко потерять (аналитика, прогрев кеша) — p-queue, чтобы просто не забить event loop и ограничить параллельность.
- Задачу терять нельзя, но всё в одном стеке на Node.js — BullMQ. Покрывает 90% реальных случаев: письма, картинки, вебхуки, отложенные напоминания.
- Очередь общая между сервисами или нужна сложная маршрутизация — RabbitMQ или Kafka.
Что ломается на практике
Idempotency. При attempts: 3 задача может выполниться повторно, если воркер упал уже после отправки письма, но до подтверждения. Обработчик должен быть идемпотентным: проверяйте, не отправляли ли уже это письмо, по ключу задачи. Иначе клиент получит три приветствия.
Graceful shutdown. На SIGTERM нужно дать воркеру дообработать текущие задачи, а не убить процесс мгновенно. У Worker есть метод close(), который дожидается завершения активных задач — вызывайте его в обработчике сигнала. Точную сигнатуру сверяйте в документации BullMQ вашей версии, API менялся между мажорными релизами.
Разросшийся Redis. По умолчанию завершённые и упавшие задачи остаются в Redis и копят память. Ограничивайте их хранение опциями removeOnComplete и removeOnFail при добавлении задачи — например, хранить последние 1000 или задачи за сутки.
Один Redis на всё. BullMQ активно использует блокирующие команды. Если тот же Redis обслуживает кеш приложения, очередь под нагрузкой начнёт мешать кешу. Разносите по разным инстансам или базам.
Что настроить дальше
После того как базовая очередь заработала: поднимите dashboard для наблюдения за задачами — есть bull-board, который показывает активные, упавшие и отложенные задачи через веб-интерфейс. Настройте алерт на рост числа задач в состоянии failed — это первый признак, что внешний сервис (SMTP, платёжный шлюз) лёг. И заведите отдельную очередь под медленные задачи: смешивать быструю отправку письма и генерацию тяжёлого отчёта в одной очереди — значит, что отчёт будет держать воркеров и тормозить письма.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: BullMQ — официальная документация, p-queue — npm
Частые вопросы
А если у меня нет Redis и поднимать его не хочется?
Что будет, если воркер упадёт посреди обработки задачи?
Можно ли запускать воркер в том же процессе, что и HTTP-сервер?
У меня старая версия BullMQ / раньше был пакет bull — код подойдёт?
Как понять, сколько воркеров и какую concurrency ставить?
Не сработало: воркер запущен, но задачи висят необработанными. Что проверить?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.