Colas de Procesamiento en Segundo Plano en Next.js con BullMQ y Redis: Arquitectura para Tareas Pesadas

En aplicaciones web modernas construidas sobre Next.js (App Router y Server Actions), ejecutar operaciones lentas —como generación de PDFs, compresión de video, sincronización de catálogos o despachos de correo masivo— directamente en el ciclo de solicitud HTTP bloquea la respuesta al usuario y agota los tiempos de espera (timeouts) en plataformas de despliegue como Vercel o VPS. La solución definitiva consiste en implementar una arquitectura asíncrona desacoplada mediante colas de trabajo (Message Queues) con BullMQ y Redis. En esta guía técnica exploramos su diseño, concurrencia, reintentos exponenciales y monitoreo en producción.

El Problema del Bloqueo HTTP en Server Actions y APIs#
Cuando un usuario solicita la exportación de un reporte financiero con miles de transacciones o el procesamiento de una orden de compra, esperar sincrónicamente a que el servidor complete la tarea degrada la experiencia de usuario (INP - Interaction to Next Paint) y genera cuellos de botella en la base de datos:
[Cliente Web / App]
│
│ POST /api/generar-reporte (HTTP Request)
▼
[Next.js Server Action] ──► [Generación de PDF pesada (12 segundos)] ──► [Bloqueo de conexión]
▲ │
└────────────────────────────────── Timeout 504 / Fallo de red ────────┘
Con una cola asíncrona distribuida, la petición HTTP responde en milisegundos (202 Accepted), encolando un mensaje que será procesado por workers en segundo plano:
[Cliente Web / App]
│
│ POST /api/generar-reporte
▼
[Next.js Server Action] ──► [Redis Queue (BullMQ)] ──► Respuesta 200 OK (en 15ms)
│
▼ (Despacho asíncrono)
[Worker Pool en Node.js]
├── Worker #1: Exportación PDF
├── Worker #2: Enlace a S3 / Supabase Storage
└── Worker #3: Notificación Push / Webhook
Configuración del Cliente BullMQ y la Conexión Redis#
Para garantizar alto rendimiento sin fugas de memoria (connection leaks), configuramos la conexión compartida con ioredis:
// lib/queue/connection.ts
import { RedisOptions } from "ioredis";
export const redisConnection: RedisOptions = {
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
maxRetriesPerRequest: null, // Requisito crítico de BullMQ para bloqueo de conexiones
enableReadyCheck: false,
};
A continuación, creamos la definición de la cola tipada para Next.js:
// lib/queue/reportQueue.ts
import { Queue } from "bullmq";
import { redisConnection } from "./connection";
export interface ReportJobData {
userId: string;
reportType: "sales" | "inventory" | "tax_audit";
dateRange: {
start: string;
end: string;
};
destinationEmail: string;
}
export const REPORT_QUEUE_NAME = "report-generation-queue";
// Instancia singleton para encolar desde Server Actions y API Routes
export const reportQueue = new Queue<ReportJobData>(REPORT_QUEUE_NAME, {
connection: redisConnection,
defaultJobOptions: {
attempts: 3, // Reintentos automáticos
backoff: {
type: "exponential",
delay: 2000, // 2s, 4s, 8s
},
removeOnComplete: {
age: 3600, // Mantener historial 1 hora
count: 1000,
},
removeOnFail: {
age: 86400, // Mantener errores 24 horas para auditoría
},
},
});
Encolado Asíncrono desde Server Actions en Next.js 15#
Desde cualquier componente de React con Server Actions podemos despachar el trabajo de forma inmediata y no bloqueante:
// app/actions/reports.ts
"use server";
import { reportQueue, ReportJobData } from "@/lib/queue/reportQueue";
import { revalidatePath } from "next/cache";
export async function requestReportAction(formData: FormData) {
const userId = formData.get("userId") as string;
const reportType = formData.get("reportType") as ReportJobData["reportType"];
const email = formData.get("email") as string;
if (!userId || !reportType || !email) {
return { success: false, error: "Datos incompletos para procesar el reporte." };
}
// Encolar trabajo en Redis
const job = await reportQueue.add(
`report-${reportType}-${userId}`,
{
userId,
reportType,
dateRange: {
start: formData.get("startDate") as string,
end: formData.get("endDate") as string,
},
destinationEmail: email,
},
{
priority: reportType === "tax_audit" ? 1 : 3, // Prioridad alta para auditorías
}
);
revalidatePath("/dashboard/reports");
return {
success: true,
jobId: job.id,
message: "Tu reporte está en cola. Recibirás una notificación cuando esté listo.",
};
}
Implementación del Worker Independiente con Control de Concurrencia#
El procesamiento real del trabajo no debe ejecutarse en el proceso web de Next.js, sino en un proceso Node.js dedicado o contenedor Docker (worker.ts):
// workers/reportWorker.ts
import { Worker, Job } from "bullmq";
import { redisConnection } from "../lib/queue/connection";
import { REPORT_QUEUE_NAME, ReportJobData } from "../lib/queue/reportQueue";
export const reportWorker = new Worker<ReportJobData>(
REPORT_QUEUE_NAME,
async (job: Job<ReportJobData>) => {
console.log(`[Worker] Iniciando procesamiento de Job ID: ${job.id} para usuario ${job.data.userId}`);
// 1. Reportar progreso reactivo
await job.updateProgress(10);
// 2. Simulación de extracción de base de datos pesada
await new Promise((resolve) => setTimeout(resolve, 3000));
await job.updateProgress(50);
// 3. Generación de PDF / Excel
await new Promise((resolve) => setTimeout(resolve, 2000));
await job.updateProgress(90);
// 4. Enviar notificación por email / webhook
console.log(`[Worker] Enlace de descarga enviado a: ${job.data.destinationEmail}`);
await job.updateProgress(100);
return {
status: "COMPLETED",
fileUrl: `https://storage.empresa.com/reports/${job.id}.pdf`,
processedAt: new Date().toISOString(),
};
},
{
connection: redisConnection,
concurrency: 5, // Procesa hasta 5 reportes en paralelo por worker
limiter: {
max: 20, // Máximo 20 trabajos por segundo para proteger APIs de terceros
duration: 1000,
},
}
);
reportWorker.on("completed", (job) => {
console.log(`✅ Job ${job.id} completado con éxito.`);
});
reportWorker.on("failed", (job, err) => {
console.error(`❌ Job ${job?.id} falló tras ${job?.attemptsMade} intentos. Causa: ${err.message}`);
});
Monitoreo en Tiempo Real con Bull-Board#
Para tener visibilidad total de las tareas activas, pausadas, fallidas y completadas, integramos la UI administrativa de @bull-board:
// app/api/admin/queues/route.ts
import { createBullBoard } from "@bull-board/api";
import { BullMQAdapter } from "@bull-board/api/bullMQAdapter";
import { HonoAdapter } from "@bull-board/hono";
import { reportQueue } from "@/lib/queue/reportQueue";
// Integración con panel de monitoreo protegido por sesión y roles
const serverAdapter = new HonoAdapter();
serverAdapter.setBasePath("/api/admin/queues");
createBullBoard({
queues: [new BullMQAdapter(reportQueue)],
serverAdapter: serverAdapter,
});
Mejores Prácticas en Producción#
- Idempotencia en los Trabajos: Si un worker se reinicia a mitad de la ejecución, el reintento no debe duplicar cobros ni registros. Utiliza
jobIddeterminísticos. - Aislamiento de Recursos: Ejecuta el proceso de Next.js y los Workers de BullMQ en contenedores Docker separados para que un pico de carga de procesamiento no afecte el tiempo de respuesta web.
- Dead Letter Queue (DLQ): Configura colas de descarte para inspeccionar y reintentar manualmente trabajos que agotaron sus intentos máximos.
Conclusión#
Implementar colas de segundo plano con BullMQ y Redis transforma una aplicación Next.js en un sistema empresarial robusto capaz de manejar cargas masivas, procesamiento asíncrono fiable y tiempos de respuesta ultrarrápidos.
¿Necesitas diseñar arquitecturas de alta disponibilidad, sistemas SaaS escalables o procesamiento en segundo plano para tu empresa? Descubre nuestros servicios de Desarrollo de Software a Medida o contáctanos para una asesoría técnica.
¿Te gustaría profundizar en estos temas?
Aprende sobre desarrollo de software, apps a medida, automatizaciones con N8N, Next.js y Cloud con casos reales.


