Из HTTP-запроса выносят всё, что медленно, зависит от чужих сервисов и не нужно пользователю для ответа: письма и уведомления, вызовы CRM и исходящие вебхуки, отчёты и выгрузки. API кладёт задачу в очередь и сразу отвечает, а отдельный процесс — воркер — выполняет её и при сбое повторяет с растущими паузами. Чтобы повтор не отправил второе сообщение, обработчик делают идемпотентным, а задачи, исчерпавшие попытки, собирают и разбирают отдельно.
После материала вы сможете
- определить, какие операции вынести из HTTP-запроса в фоновую очередь, а какие оставить в запросе
- поставить задачу в очередь BullMQ из Express-обработчика и обработать её воркером с повторами и экспоненциальным backoff
- защитить обработку от дублей:
Idempotency-Keyв API,jobIdв очереди и отметка «уже отправлено» в воркере - найти «мёртвые» задачи через
getFailed()и вернуть их в очередь после устранения причины
Что знать заранее
Проверено на
Node.js 24 LTS (24.18)BullMQ 6.3ioredis 6.0Express 5.3Redis 7.4 (redis:7-alpine)TypeScript 7.0
Сверено с документацией: 11 октября 2026 г.
Содержание
- Почему долгий запрос — это проблема
- Очередь, продюсер и воркер
- Продюсер: API ставит задачу и сразу отвечает
- Воркер: повторы, backoff и ошибки, которые не лечатся повтором
- Идемпотентность: повтор не должен отправить второе сообщение
- «Мёртвые» задачи и наблюдаемость
- Отложенные и периодические задачи; альтернативы
- Упражнения
Почему долгий запрос — это проблема
Возьмём API заявок из упражнения к уроку по REST API на Express: обработчик POST /api/leads проверяет данные, сохраняет заявку и отвечает. Соблазн — дописать туда же письмо менеджеру, сообщение в Telegram и передачу в CRM. Тогда время ответа становится суммой времени всех внешних сервисов, а надёжность формы — надёжностью самого слабого из них.
- Пользователь ждёт и нажимает кнопку ещё раз — появляется дубль заявки.
- У прокси, балансировщика и самого клиента есть таймауты: запрос обрывается, когда часть работы уже сделана, и непонятно, какая.
- Внешний сервис лежит — и форма отвечает ошибкой, хотя заявка в базе уже есть.
- Повторы внутри запроса («попробуем ещё три раза») умножают ожидание, а не решают проблему.
Проверка данных, сохранение заявки
- Где выполнять
- В запросе
- Почему
- Без этого нельзя честно ответить «заявка принята»
Письмо или сообщение менеджеру
- Где выполнять
- В фоне
- Почему
- Пользователю не нужно ждать доставки; сервис может быть недоступен
Передача в CRM или 1С, исходящий вебхук
- Где выполнять
- В фоне
- Почему
- Чужой сервис со своими лимитами, сбоями и задержками
PDF-отчёт, выгрузка в Excel, обработка изображений
- Где выполнять
- В фоне + статус задачи
- Почему
- Может занимать минуты и нагружать процессор
Подтверждение оплаты
- Где выполнять
- По правилам провайдера
- Почему
- Результат нужен пользователю, а финальный статус обычно приходит вебхуком провайдера
Очередь, продюсер и воркер
В схеме три роли. Продюсер (producer) — код, который ставит задачу в очередь; у нас это обработчик API. Очередь хранит задачи до выполнения; в BullMQ это структуры данных в Redis. Воркер (worker) — отдельный процесс, который забирает задачи по одной или пачкой и выполняет. Если воркер остановлен, задачи не теряются, а копятся и ждут — при условии, что Redis хранит данные на диске (об этом ниже).
- 01Форма
- 02API — продюсер
- 03Redis — очередь
- 04Воркер
- 05Сервис уведомлений
Задача в BullMQ проходит состояния: waiting — ждёт воркера, active — выполняется, completed или failed — завершена. Между неудачными попытками задача лежит в delayed: ждёт паузу перед повтором.
Код ниже проверен на BullMQ 6.3. В шестой версии клиент Redis ioredis стал необязательной peer-зависимостью: его нужно установить явно, иначе при создании очереди вы получите ошибку «BullMQ could not load the optional 'ioredis' package». Redis для разработки удобно поднять в Docker.
npm init -y && npm pkg set type=module
npm install bullmq ioredis express
npm install -D typescript @types/express @types/node
docker run -d --rm --name kb-redis-jobs -p 6379:6379 redis:7-alpineNode.js 24 запускает .ts-файлы напрямую, просто удаляя аннотации типов, поэтому в импортах указано расширение .ts. Компилятор нужен только для проверки: npx tsc --noEmit.
{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src"]
}import { Redis } from 'ioredis';
const url = process.env.REDIS_URL ?? 'redis://localhost:6379';
// The producer runs inside an HTTP handler: if Redis is down,
// fail fast instead of holding the client's request.
export const producerConnection = () =>
new Redis(url, { maxRetriesPerRequest: 1, enableOfflineQueue: false });
// The worker must ride out disconnects: BullMQ requires null here.
export const workerConnection = () => new Redis(url, { maxRetriesPerRequest: null });import { Queue } from 'bullmq';
import type { Redis } from 'ioredis';
export type LeadNotification = { leadId: string; name: string; phone: string };
export const NOTIFICATIONS = 'notifications';
export function createNotificationsQueue(connection: Redis) {
return new Queue<LeadNotification>(NOTIFICATIONS, {
connection,
defaultJobOptions: {
attempts: 4, // first try + 3 retries
backoff: { type: 'exponential', delay: 1000 }, // waits 1 s, 2 s, 4 s
removeOnComplete: { count: 1000 }, // keep the last 1000 successful jobs
removeOnFail: { age: 7 * 24 * 3600 }, // keep failed jobs for a week
},
});
}Продюсер: API ставит задачу и сразу отвечает
Обработчик сохраняет заявку (в примере — в памяти процесса, в проекте — в таблице БД), ставит задачу lead-created и отвечает 201, не дожидаясь отправки уведомления.
import { randomUUID } from 'node:crypto';
import express from 'express';
import { createNotificationsQueue } from './queue.ts';
import { producerConnection } from './redis.ts';
type Lead = { id: string; name: string; phone: string; createdAt: string };
const leads = new Map<string, Lead>(); // a database table in a real project
const queue = createNotificationsQueue(producerConnection());
queue.on('error', (err) => console.error('[queue]', err.message || err.name));
// While Redis is down, queue.add waits for a reconnect: don't let the request hang.
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`timeout after ${ms} ms`)), ms);
});
promise.catch(() => {}); // a late rejection must not crash the process
try {
return await Promise.race([promise, timeout]);
} finally {
clearTimeout(timer);
}
}
const app = express();
app.use(express.json());
app.post('/api/leads', async (req, res) => {
const { name, phone } = req.body ?? {};
if (typeof name !== 'string' || typeof phone !== 'string') {
res.status(400).json({ error: 'name and phone are required' });
return;
}
// The client generates the key once per form submission:
// a repeated request creates neither a second lead nor a second job.
const id = req.get('Idempotency-Key') ?? randomUUID();
if (leads.has(id)) {
res.status(200).json({ id, duplicate: true });
return;
}
leads.set(id, { id, name, phone, createdAt: new Date().toISOString() });
try {
// jobId dedupes at the queue level: a job with an existing id is ignored
const job = queue.add('lead-created', { leadId: id, name, phone }, { jobId: `lead-${id}` });
await withTimeout(job, 2000);
} catch (err) {
// The lead is saved but the notification is not queued: make it visible in logs.
console.error('[api] enqueue failed', id, (err as Error).message);
}
res.status(201).json({ id }); // the response does not wait for the notification
});
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`[api] http://localhost:${port}`));Idempotency-Key— ключ, который клиент генерирует один раз на отправку формы. Повтор того же запроса (двойной клик, повтор после обрыва связи) вернёт ту же заявку, а не создаст новую.jobId: lead-<id>— защита на уровне очереди: по документации BullMQ задача с уже существующим id просто игнорируется. Id не должен содержать двоеточие и не может состоять из одних цифр.withTimeoutпоявился после проверки: когда Redis недоступен,queue.addждёт переподключения, и в нашем прогоне запрос висел дольше 30 секунд даже сenableOfflineQueue: false. Ограничение в 2 секунды возвращает клиенту ответ вовремя.
$ curl -s -X POST localhost:3000/api/leads … -w ' HTTP %{http_code}, %{time_total}s\n'
{"id":"ab6a5c91-26cb-4c2a-aac4-f5fe5842624c"} HTTP 201, 2.019723s
# api
[api] enqueue failed ab6a5c91-26cb-4c2a-aac4-f5fe5842624c timeout after 2000 msВоркер: повторы, backoff и ошибки, которые не лечатся повтором
Обработчик воркера — асинхронная функция. Вернула значение — задача completed, бросила исключение — попытка неудачна. С attempts: 4 и backoff: { type: 'exponential', delay: 1000 } BullMQ ждёт перед повтором 2^(n-1) × delay миллисекунд: 1, 2 и 4 секунды. Без backoff повтор идёт сразу — для упавшего внешнего сервиса это бесполезно.
import { UnrecoverableError, Worker, type Job } from 'bullmq';
import { Redis } from 'ioredis';
import { NOTIFICATIONS, type LeadNotification } from './queue.ts';
import { workerConnection } from './redis.ts';
const NOTIFY_URL = process.env.NOTIFY_URL ?? 'http://localhost:4001/notify';
const store = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379'); // "already sent" marks
const log = (msg: string) => console.log(new Date().toISOString().slice(11, 19), msg);
async function notifyManager(job: Job<LeadNotification>): Promise<string> {
const doneKey = `lead-notified:${job.data.leadId}`;
// A retry after a successful send (e.g. the worker died before completing the job)
if (await store.exists(doneKey)) return 'already sent';
const res = await fetch(NOTIFY_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text: `New lead: ${job.data.name}, ${job.data.phone}` }),
signal: AbortSignal.timeout(5000), // a hung request is an error, not an endless wait
});
// A 4xx (except 429) won't be fixed by retrying: fail now, skip the remaining attempts
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
throw new UnrecoverableError(`notify rejected: HTTP ${res.status}`);
}
if (!res.ok) throw new Error(`notify unavailable: HTTP ${res.status}`);
await store.set(doneKey, '1', 'EX', 7 * 24 * 3600);
return 'sent';
}
const worker = new Worker<LeadNotification, string>(NOTIFICATIONS, notifyManager, {
connection: workerConnection(),
concurrency: 5,
});
worker.on('completed', (job, result) => {
log(`${job.id} ${result} (attempt ${job.attemptsMade})`);
});
worker.on('failed', async (job, err) => {
if (!job) return;
if ((await job.getState()) === 'failed') {
// Out of attempts or unrecoverable: a dead job that needs a human
log(`DEAD ${job.id} after ${job.attemptsMade} attempt(s): ${err.message}`);
} else {
log(`retry ${job.id} attempt ${job.attemptsMade} failed: ${err.message}`);
}
});
worker.on('error', (err) => console.error('[worker]', err.message));
async function shutdown() {
log('shutting down');
await worker.close(); // finish active jobs, take no new ones
await store.quit();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
log('waiting for jobs');2xx
- Что делает воркер
- Ставит отметку, задача
completed - Почему
- Уведомление доставлено
5xx, таймаут, сетевая ошибка
- Что делает воркер
throw new Error→ повтор с паузой- Почему
- Сбой временный, через несколько секунд может пройти
429 Too Many Requests
- Что делает воркер
- Повтор с паузой
- Почему
- Лимит запросов; лучше учитывать
Retry-After(см. упражнение)
Другие 4xx
- Что делает воркер
UnrecoverableError→ сразуfailed- Почему
- Неверные данные или ключ — повтор даст тот же ответ
Чтобы проверить повторы без реального Telegram или почты, поднимем заглушку: первые FAIL_FIRST запросов она отвечает 503, а сообщение без телефона отклоняет с 400.
// A local stand-in for the notification service (Telegram, email, CRM).
import { createServer } from 'node:http';
let failFirst = Number(process.env.FAIL_FIRST ?? 2); // how many first requests fail
createServer(async (req, res) => {
let body = '';
for await (const chunk of req) body += chunk;
if (failFirst > 0) {
failFirst--;
console.log('[notify] 503 Service Unavailable');
res.writeHead(503).end();
return;
}
const { text } = JSON.parse(body);
if (!/\d{10,}/.test(text)) {
console.log('[notify] 400 Bad Request');
res.writeHead(400).end();
return;
}
console.log(`[notify] 200 delivered: ${text}`);
res.writeHead(200).end();
}).listen(4001, () => console.log('[notify] http://localhost:4001'));FAIL_FIRST=2 node src/fake-notify.ts # terminal 1: the "notification service"
node src/worker.ts # terminal 2: the worker
node src/api.ts # terminal 3: the API on :3000$ curl -s -X POST localhost:3000/api/leads -H 'content-type: application/json' \
-H 'Idempotency-Key: 7f3c2a10' --data-binary @lead.json -w ' HTTP %{http_code}, %{time_total}s\n'
{"id":"7f3c2a10"} HTTP 201, 0.015741s
$ curl …the same command again…
{"id":"7f3c2a10","duplicate":true} HTTP 200, 0.004159s
# worker
23:24:53 waiting for jobs
23:24:55 retry lead-7f3c2a10 attempt 1 failed: notify unavailable: HTTP 503
23:24:56 retry lead-7f3c2a10 attempt 2 failed: notify unavailable: HTTP 503
23:24:58 lead-7f3c2a10 sent (attempt 3)
# fake-notify
[notify] 503 Service Unavailable
[notify] 503 Service Unavailable
[notify] 200 delivered: New lead: Aigerim, +77011234567По времени в логе видны паузы: первая попытка в :55, вторая через секунду, третья — ещё через две. Сервис уведомлений получил одно успешное сообщение, хотя запрос пришёл дважды. Теперь заявка с некорректным телефоном:
$ curl -s -X POST localhost:3000/api/leads -H 'content-type: application/json' \
-H 'Idempotency-Key: 9b1e44d0' -d '{"name":"Test","phone":"none"}'
{"id":"9b1e44d0"}
# worker
23:25:03 DEAD lead-9b1e44d0 after 1 attempt(s): notify rejected: HTTP 400Идемпотентность: повтор не должен отправить второе сообщение
Документация BullMQ прямо советует проектировать задачи с расчётом на сбой: результат должен быть одинаковым, выполнилась задача с первой попытки или после повтора. Это и есть идемпотентность. Повторный запуск реален: кроме явных повторов, задача, воркер которой завис или упал и не продлил блокировку, считается «застрявшей» (stalled) и возвращается в очередь.
- Уровень API:
Idempotency-Keyот клиента — одна заявка на одно нажатие. - Уровень очереди:
jobId— одна задача на одну заявку, пока задача хранится в Redis. После удаления задачи (removeOnComplete,removeOnFail) тот же id можно добавить снова. - Уровень обработчика: отметка
lead-notified:<id>— если уведомление уже ушло, повтор ничего не отправляет.
Проверим третий уровень: поставим задачу для уже уведомлённой заявки под новым jobId, как если бы её прислал другой источник.
$ node check/replay.ts # the same leadId under a new jobId
# worker
23:25:28 lead-7f3c2a10-replay already sent (attempt 1)
# fake-notify: no new requestsДля другой задачи — «не чаще раза в N секунд», например пересчёт сводки при каждой новой заявке — в BullMQ есть опция deduplication с id и ttl: новые задачи с тем же id в течение окна игнорируются.
«Мёртвые» задачи и наблюдаемость
Когда попытки кончились или брошен UnrecoverableError, задача переходит в набор failed. Это аналог dead letter queue (очереди «мёртвых» писем) в других брокерах: сами по себе такие задачи больше не выполнятся, нужен человек или скрипт. По умолчанию BullMQ хранит завершённые задачи бессрочно, поэтому removeOnComplete и removeOnFail в queue.ts обязательны — иначе Redis постепенно заполнится.
# fake-notify started with FAIL_FIRST=100: the service is "down"
23:25:09 retry lead-c5d2e9a1 attempt 1 failed: notify unavailable: HTTP 503
23:25:10 retry lead-c5d2e9a1 attempt 2 failed: notify unavailable: HTTP 503
23:25:12 retry lead-c5d2e9a1 attempt 3 failed: notify unavailable: HTTP 503
23:25:16 DEAD lead-c5d2e9a1 after 4 attempt(s): notify unavailable: HTTP 503
$ node src/dead-jobs.ts
lead-c5d2e9a1 attempts=4 reason="notify unavailable: HTTP 503"
lead-9b1e44d0 attempts=1 reason="notify rejected: HTTP 400"
failed: 2import { createNotificationsQueue } from './queue.ts';
import { producerConnection } from './redis.ts';
const connection = producerConnection();
const queue = createNotificationsQueue(connection);
const failed = await queue.getFailed();
for (const job of failed) {
console.log(`${job.id} attempts=${job.attemptsMade} reason="${job.failedReason}"`);
if (process.argv.includes('--retry')) await job.retry(); // back to the queue after a fix
}
console.log(`failed: ${failed.length}`);
await queue.close();
await connection.quit(); // we created this connection, so we close it# the service is back (FAIL_FIRST=0)
$ node src/dead-jobs.ts --retry
lead-c5d2e9a1 attempts=4 reason="notify unavailable: HTTP 503"
lead-9b1e44d0 attempts=1 reason="notify rejected: HTTP 400"
failed: 2
# worker
23:25:21 lead-c5d2e9a1 sent (attempt 5)
23:25:21 DEAD lead-9b1e44d0 after 2 attempt(s): notify rejected: HTTP 400- Redis с
maxmemory-policy noeviction: если Redis вытесняет ключи, очередь ломается (у образаredis:7-alpineэто значение по умолчанию — провереноCONFIG GET). - Включена запись на диск:
appendonly yesсappendfsync everysec— по документации Redis при аварии теряется не больше секунды записей. - Обработчики
errorна очереди и воркере — иначе ошибки подключения остаются необработанными. - Корректная остановка по
SIGINTиSIGTERM:worker.close()дожидается текущих задач. В контейнереdocker stopзавершил наш воркер с кодом 0. - Автоочистка
removeOnCompleteиremoveOnFailнастроена. - В
dataзадачи нет персональных данных: BullMQ хранит её в Redis открытым текстом. В примере имя и телефон лежат в задаче для наглядности — в проекте передавайте толькоleadId, а данные воркер читает из БД. - Число задач в
failed(queue.getFailedCount()) уходит в метрики, и на его рост настроено оповещение.
$ docker stop <container with node src/worker.ts>
23:25:56 waiting for jobs
23:25:57 shutting down
container exit code: 0Отложенные и периодические задачи; альтернативы
Отложенная задача — обычная задача с опцией delay: она лежит в delayed и становится доступна воркеру по истечении паузы. Периодические задачи в BullMQ 6 создаются через Job Schedulers: метод upsertJobScheduler создаёт расписание или обновляет существующее с тем же id. Старый способ — опция repeat в queue.add — в шестой версии удалён, это описано в руководстве по миграции с v5.
import { Queue } from 'bullmq';
import { producerConnection } from './redis.ts';
const connection = producerConnection();
const reports = new Queue('reports', { connection });
// Repeatable job: a lead summary every day at 09:00 Almaty time.
// upsert: running this on every deploy updates the schedule instead of duplicating it.
await reports.upsertJobScheduler(
'daily-leads-report',
{ pattern: '0 0 9 * * *', tz: 'Asia/Almaty' },
{ name: 'daily-leads-report', data: {} },
);
// Delayed job: remind the manager if nobody picks up the lead within 30 minutes.
await reports.add('lead-reminder', { leadId: '7f3c2a10' }, { delay: 30 * 60 * 1000 });
for (const s of await reports.getJobSchedulers()) {
console.log(s.key, s.pattern, s.tz, 'next:', new Date(s.next ?? 0).toISOString());
}
console.log(await reports.getJobCounts('delayed'));
await reports.close();
await connection.quit();$ node src/schedule.ts
daily-leads-report 0 0 9 * * * Asia/Almaty next: 2026-10-12T04:00:00.000Z
{ delayed: 2 }Cron-выражение здесь из шести полей: первое — секунды. Расписанием сводка только ставится в очередь; выполняет её воркер очереди reports, который пишется так же, как воркер уведомлений.
Cron (системный, CronJob в Kubernetes, планировщик хостинга)
- Когда подходит
- Периодический скрипт: ночная выгрузка, очистка
- Ограничения
- Нет очереди задач, повторов и учёта неудач — всё это пишете сами
Управляемые очереди облаков (Amazon SQS, Google Cloud Tasks и аналоги)
- Когда подходит
- Не хочется поддерживать свой Redis; инфраструктура уже в этом облаке
- Ограничения
- Привязка к провайдеру, свои лимиты и гарантии доставки — читайте их документацию
BullMQ с бэкендом PostgreSQL
- Когда подходит
- Redis в проекте нет и заводить его не хочется
- Ограничения
- По документации BullMQ пропускная способность ниже, основной и самый проверенный вариант — Redis
setTimeout или «отправить без await» в процессе API
- Когда подходит
- Только эксперименты
- Ограничения
- Задача теряется при перезапуске, повторов и учёта нет
Упражнения
Как проверено3 · пройдено1 · частичносверено 11 октября 2026 г.
- запущено, работает
src/redis.ts, src/queue.ts, src/api.ts, src/worker.ts, src/fake-notify.ts, src/dead-jobs.ts, src/schedule.ts, tsconfig.json
временный проект: npm i bullmq@6.3.12 ioredis@6.0.0 express@5.3.0, typescript@7.0.2; tsc -p . без ошибок; Node 24.18 исполняет .ts напрямую; Redis 7.4.10 в docker redis:7-alpine (kb-redis-fonovye, --rm)
- запущено, работает
сценарии запуска producer+worker (выводы в статье)
run-demo.sh: A — два 503, доставка с 3-й попытки, паузы 1 с и 2 с по логам, повтор с тем же Idempotency-Key → 200 duplicate; B — 400 → UnrecoverableError → failed с 1 попытки; C — сервис недоступен → 4 попытки (паузы 1/2/4 с) → DEAD; D — dead-jobs --retry после восстановления → sent (attempt 5), 400 снова failed; E — replay той же заявки под новым jobId → 'already sent', сервис не вызван; F — Redis недоступен → 201 за 2,02 с, лог 'timeout after 2000 ms'; G — schedule.ts: next 2026-10-12T04:00Z (09:00 Asia/Almaty), delayed: 2
- запущено, работает
graceful shutdown воркера (SIGTERM)
node src/worker.ts в контейнере node:24.18.0-bookworm-slim (kb-node-fonovye, --rm), docker stop → 'shutting down', exit code 0
- частично
команды установки (bash-блок)
те же пакеты и образ проверены, но в проверке Redis был на порту 6390, API на 3100 (6379 и 3000 заняты другими проектами); в выводах curl в статье порт заменён на 3000, тело запроса для сценария B показано через -d вместо файла
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.