В современных веб-приложениях значительная часть бизнес-логики не может и не должна выполняться синхронно в рамках жизненного цикла входящего HTTP-запроса: транскодирование медиа, генерация сложных PDF-отчетов, синхронизация с внешними CRM-системами, пакетная рассылка транзакционных писем или расчет аналитики.
Попытка запустить долгую операцию «в фоне» классическим вызовом someAsyncTask().catch(...) без ожидания await внутри Server Action или Route Handler приводит к катастрофическим последствиям в Serverless- и Edge-окружениях (Vercel, AWS Lambda, Cloudflare Workers):
Response, среда выполнения немедленно замораживает контейнер (Execution Freeze) или уничтожает виртуальную машину. Фоновый промис прерывается на произвольной строке кода без возможности сохранения состояния.В Next.js 16 для надежного исполнения длительных процессов применяются две дополняющие друг друга архитектуры:
step.sleep) и гарантией завершения.app/api/cron/**/route.ts) с криптографической валидацией Bearer-токенов планировщика и строгой идемпотентностью операций.Вместо синхронной блокировки HTTP-потока веб-сервер регистрирует задание в персистентной очереди и возвращает клиенту немедленный статус 202 Accepted:
+-----------------------------------------------------------------------------------+
| АРХИТЕКТУРА ASYNC QUEUES & DURABLE WORKFLOWS В NEXT.JS 16 |
+-----------------------------------------------------------------------------------+
| NEXT.JS APP ROUTER БРОКЕР ОЧЕРЕДИ / ШЕДУЛЕР ВОРКЕРЫ / RUNTIME |
| |
| 1. Server Action [ REDIS / BULLMQ ] [ DOCKER WORKER ] |
| queue.add("video_transcode",| - State: 'waiting' | Worker loop |
| { videoId }) | - Delay: 0 | concurrency: 5 |
| -> HTTP 202 Accepted | - Attempts: 5 (Backoff) | FFmpeg процесс |
| +------------------------------> | Job 'completed'|
| |
| 2. Serverless Durable Flow [ INNGEST EVENT BUS ] [ SERVERLESS STEP]|
| inngest.send({ | - Шаг 1: step.run() | POST /api/inngest
| name: "order.created" | - Пауза: step.sleep("2d") | Только активный|
| }) | - Шаг 2: step.run() | короткий шаг! |
| +------------------------------> | Нет зависаний |
| |
| 3. Защищенный Cron [ VERCEL CRON / GITHUB ACTIONS ] |
| GET /api/cron/cleanup | Authorization: Bearer <SECRET>| |
| -> Distributed Lock Redis +------------------------------> | БД Cleanup |
| -> Выполнение транзакции | Релиз Lock |
+-----------------------------------------------------------------------------------+Server Action генерирует уникальный ключ идемпотентности (UUID заказа или хэш параметров) и атомарно записывает задачу в очередь со статусом PENDING.
Воркер захватывает задачу из очереди и устанавливает Redis Distributed Lock (Redlock) по idempotency-key для исключения параллельного двойного исполнения.
Процесс выполняет целевую операцию, транслируя процент готовности через job.updateProgress() или отправляя SSE-события клиенту.
При успехе статус меняется на COMPLETED. При сбое задача повторяется с экспоненциальной задержкой; после 5 неудач она направляется в Dead Letter Queue (DLQ).
Почему в Next.js Route Handler или Server Action при деплое на Vercel (AWS Lambda) категорически запрещено запускать асинхронные задачи без await (fire-and-forget), рассчитывая на их фоновое завершение?
step.sleep('30m') при отсутствии оплаты.Для систем, работающих в Docker/Kubernetes (или на VPS/Dedicated серверах), идеальным решением является связка BullMQ + Redis.
Создаем модуль очереди в Next.js:
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
// Синглтон подключения к Redis
const redisConnection = new IORedis(process.env.REDIS_URL || 'redis://localhost:6379', {
maxRetriesPerRequest: null, // Обязательное требование BullMQ v5
enableReadyCheck: false,
});
export interface EmailJobData {
to: string;
templateId: string;
payload: Record<string, unknown>;
idempotencyKey: string;
}
export const emailQueue = new Queue<EmailJobData>('email-delivery', {
connection: redisConnection,
defaultJobOptions: {
attempts: 5, // 5 попыток при сбоях
backoff: {
type: 'exponential',
delay: 2000, // Повторы через 2с, 4с, 8с, 16с, 32с
},
removeOnComplete: {
age: 24 * 3600, // Хранить успешные логи 24 часа
count: 1000,
},
removeOnFail: {
age: 7 * 24 * 3600, // Хранить ошибки 7 дней для разбора в DLQ
},
},
});Изолированный фоновый воркер (запускается отдельным процессом pnpm tsx workers/emailWorker.ts):
import { Worker, Job } from 'bullmq';
import IORedis from 'ioredis';
import { EmailJobData } from '../lib/queues/emailQueue';
const redisConnection = new IORedis(process.env.REDIS_URL || 'redis://localhost:6379', {
maxRetriesPerRequest: null,
});
export const emailWorker = new Worker<EmailJobData>(
'email-delivery',
async (job: Job<EmailJobData>) => {
console.log(`[Worker] Обработка задачи ${job.id}, попытка ${job.attemptsMade + 1}`);
const { to, templateId, payload, idempotencyKey } = job.data;
// ПРОВЕРКА ИДЕМПОТЕНТНОСТИ В REDIS
const lockKey = `idempotency:email:${idempotencyKey}`;
const alreadyProcessed = await redisConnection.get(lockKey);
if (alreadyProcessed) {
console.warn(`[Worker] Задача ${idempotencyKey} уже была успешно выполнена. Пропуск.`);
return { status: 'skipped_duplicate' };
}
// Имитация отправки через почтовый шлюз (Resend, SendGrid)
await job.updateProgress(50);
// await sendEmailViaGateway({ to, templateId, payload });
// Фиксируем успешное завершение на 48 часов
await redisConnection.set(lockKey, 'PROCESSED', 'EX', 48 * 3600);
await job.updateProgress(100);
return { status: 'sent', sentAt: new Date().toISOString() };
},
{
connection: redisConnection,
concurrency: 10, // До 10 одновременных задач на один процесс воркера
}
);
emailWorker.on('failed', (job, err) => {
console.error(`[Worker Alert] Задача ${job?.id} завершилась ошибкой:`, err.message);
});Если Next.js развернут на Vercel, Netlify или AWS Amplify, постоянных воркеров нет. Решением выступает Inngest, который вызывает короткие шаги вашей функции по HTTP, автоматически сохраняя промежуточное состояние.
Создаем клиент и воркер Inngest:
import { Inngest } from 'inngest';
// Инициализация типизированного клиента Inngest
export const inngest = new Inngest({
id: 'wpskills-enterprise-app',
eventKey: process.env.INNGEST_EVENT_KEY,
});Многошаговый воркфлоу оформления заказа с отложенным действием:
import { inngest } from '../client';
export const processOrderWorkflow = inngest.createFunction(
{
id: 'process-order-durable-workflow',
name: 'Order Lifecycle & Fraud Check',
retries: 3, // Автоповторы каждого отдельного шага
},
{ event: 'shop/order.created' },
async ({ event, step }) => {
const { orderId, customerEmail, amountTotal } = event.data;
// ШАГ 1: Антифрод-проверка (изолированный шаг)
const fraudResult = await step.run('verify-fraud-risk', async () => {
// Имитация вызова скоринга
return { isSuspicious: amountTotal > 500000 };
});
if (fraudResult.isSuspicious) {
await step.run('flag-order-for-review', async () => {
// await db.orders.update({ where: { id: orderId }, data: { status: 'MANUAL_REVIEW' } });
});
return { status: 'flagged_for_review' };
}
// ШАГ 2: Списание средств
const payment = await step.run('charge-payment', async () => {
// await stripe.paymentIntents.capture(...)
return { paymentId: `pi_${orderId}`, status: 'succeeded' };
});
// ШАГ 3: Засыпание воркфлоу на 24 часа без потребления ресурсов серверов!
// Serverless-функция полностью завершает работу, Inngest разбудит её через 24 часа
await step.sleep('wait-before-feedback-email', '24h');
// ШАГ 4: Отправка письма с просьбой оставить отзыв
await step.run('send-customer-review-request', async () => {
// await sendEmail({ to: customerEmail, template: 'review-invitation' });
return { sent: true };
});
return { success: true, orderId, paymentId: payment.paymentId };
}
);Регистрация эндпоинта Inngest в Route Handler:
import { serve } from 'inngest/next';
import { inngest } from '@/inngest/client';
import { processOrderWorkflow } from '@/inngest/functions/orderWorkflow';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// Next.js Route Handler автоматически маршрутизирует шаги выполнения от Inngest Cloud
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [
processOrderWorkflow,
],
});Для периодических задач (очистка устаревших корзин, синхронизация каталогов) создается маршрут app/api/cron/sync-catalog/route.ts:
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
// 1. Авторизация вызова планировщика
const authHeader = request.headers.get('authorization');
const expectedSecret = process.env.CRON_SECRET;
if (!expectedSecret || authHeader !== `Bearer ${expectedSecret}`) {
return NextResponse.json(
{ error: 'Unauthorized: Недействительный или отсутствующий CRON_SECRET' },
{ status: 401 }
);
}
const startTime = Date.now();
console.log('[CRON] Запуск плановой синхронизации каталога...');
try {
// 2. Идемпотентная операция синхронизации
// const result = await executeCatalogSync();
const durationMs = Date.now() - startTime;
return NextResponse.json({
success: true,
durationMs,
timestamp: new Date().toISOString(),
});
} catch (error: unknown) {
console.error('[CRON ERROR]:', error);
return NextResponse.json(
{ error: 'Internal Error', message: (error as Error).message },
{ status: 500 }
);
}
}{
"crons": [
{
"path": "/api/cron/sync-catalog",
"schedule": "0 3 * * *"
}
]
}Вызов inngest.send('shop/order.created') помещает событие в персистентную шину сообщений с подтверждением доставки.
Оркестратор отправляет защищенный HTTP POST запрос на эндпоинт app/api/inngest для выполнения первого блока step.run().
Команда step.sleep('24h') регистрирует таймер в планировщике. Серверный контейнер мгновенно завершает выполнение с 0 расходом памяти.
Спустя 24 часа оркестратор повторно вызывает API Next.js, передавая результаты предыдущих шагов из кэша и выполняя финальный шаг.
Почему при разработке обработчиков задач для фоновых очередей (BullMQ, Inngest, SQS) код обязан проектироваться строго идемпотентным?
Если разместить скрипт очистки базы данных в app/api/cron/cleanup/route.ts без проверки заголовка Authorization: Bearer <CRON_SECRET>, любой внешний бот сможет вызывать этот адрес тысячу раз в секунду, устроив отказ в обслуживании (Denial of Service) или непреднамеренное удаление свежих рабочих данных.
Вызов асинхронных функций без оператора await внутри Server Actions в надежде, что они выполнятся «на фоне». Как только Next.js возвращает ответ клиенту, Serverless-контейнер немедленно замораживается (freeze). Задача зависает и может быть выполнена через 2 часа при следующем случайном запросе или уничтожена навсегда.
Запуск воркера BullMQ с бесконечным пулом параллельности на сервере с 2 ядрами CPU. При поступлении пачки из 5 000 задач воркер попытается выполнить их все разом, вызвав исчерпание пула соединений базы данных (Postgres connection limit exceeded) и падение всей системы. Всегда явно задавайте concurrency: 5 или concurrency: 10.
Повторение упавшей задачи без задержки (Immediate Retry) мгновенно исчерпывает лимиты стороннего API (Rate Limit 429) и загоняет очередь в глухой тупик. Обязательно используйте экспоненциальный бэкофф (backoff: { type: 'exponential', delay: 2000 }).