Patrones de procesamiento de trabajos en segundo plano
No todo pertenece al ciclo de solicitud-respuesta: colas, workers, reintentos y patrones de dead letter para un procesamiento en segundo plano fiable.

Enviar correos de confirmación, generar informes en PDF, procesar imágenes subidas, sincronizar datos con APIs de terceros: nada de esto pertenece al ciclo de solicitud-respuesta. Son operaciones lentas, poco fiables, y su fallo no debería bloquear la acción del usuario. El procesamiento de trabajos en segundo plano traslada este trabajo a un pipeline separado donde puede reintentarse, limitarse y monitorizarse de forma independiente.
¿Por qué no usar simplemente setTimeout?
// ❌ "Background" processing that isn't actually reliable
app.post("/api/orders", async (req, res) => {
const order = await createOrder(req.body);
// This runs in the same process — if the server crashes, the email is lost
setTimeout(async () => {
await sendConfirmationEmail(order);
await updateInventory(order);
await notifyWarehouse(order);
}, 0);
res.json(order);
});Si el servidor se reinicia, esos callbacks desaparecen. Sin reintentos, sin visibilidad, sin garantía de que el trabajo se complete. Una cola de trabajos adecuada persiste el trabajo y sobrevive a los fallos.
El patrón de cola de trabajos
Una cola de trabajos tiene tres componentes: productores (encolan trabajo), un almacén persistente (guarda los trabajos) y consumidores/workers (procesan los trabajos).
// Producer: enqueue a job after the main operation
import { Queue } from "bullmq";
import { Redis } from "ioredis";
const connection = new Redis(process.env.REDIS_URL);
const emailQueue = new Queue("email", { connection });
app.post("/api/orders", async (req, res) => {
const order = await createOrder(req.body);
// Job is persisted in Redis — survives server restarts
await emailQueue.add("order-confirmation", {
orderId: order.id,
customerEmail: order.customerEmail,
orderTotal: order.total,
});
res.json(order);
});// Consumer: process jobs from the queue
import { Worker } from "bullmq";
const emailWorker = new Worker(
"email",
async (job) => {
switch (job.name) {
case "order-confirmation":
await sendConfirmationEmail(
job.data.customerEmail,
job.data.orderId,
job.data.orderTotal,
);
break;
case "shipping-notification":
await sendShippingEmail(job.data);
break;
}
},
{
connection,
concurrency: 5, // Process 5 jobs simultaneously
},
);
emailWorker.on("completed", (job) => {
console.log(`Job ${job.id} completed`);
});
emailWorker.on("failed", (job, error) => {
console.error(`Job ${job?.id} failed:`, error.message);
});Estrategias de reintento
Los trabajos fallan. Las APIs agotan su tiempo de espera, los servicios se caen, se alcanzan los límites de tasa. Una buena estrategia de reintento gestiona los fallos transitorios sin empeorar los fallos permanentes.
// Exponential backoff with jitter
await emailQueue.add(
"order-confirmation",
{ orderId: order.id },
{
attempts: 5,
backoff: {
type: "exponential",
delay: 2000, // 2s, 4s, 8s, 16s, 32s
},
},
);// ❌ Retrying non-retryable errors wastes resources
async function processJob(job: Job) {
try {
await callExternalAPI(job.data);
} catch (error) {
// Retrying a 400 Bad Request will never succeed
throw error; // All errors get retried
}
}
// ✅ Distinguish retryable from permanent failures
async function processJob(job: Job) {
try {
await callExternalAPI(job.data);
} catch (error) {
if (error.status === 429 || error.status >= 500) {
throw error; // Retryable — network/server issue
}
// Permanent failure — don't retry, move to dead letter
throw new UnrecoverableError(error.message);
}
}| Tipo de error | Ejemplo | Acción |
|---|---|---|
| Transitorio | Timeout, 503, conexión rechazada | Reintentar con backoff |
| Límite de tasa | 429 Too Many Requests | Reintentar con una espera mayor |
| Permanente | 400 Bad Request, datos inválidos | Cola de dead letter |
| Bug | TypeError no capturado | Cola de dead letter + alerta |
Colas de dead letter
Cuando un trabajo agota todos sus reintentos, pasa a una cola de dead letter para su investigación. Nunca descartes silenciosamente los trabajos fallidos.
// Configure dead letter queue behavior
const orderQueue = new Queue("orders", {
connection,
defaultJobOptions: {
attempts: 5,
backoff: { type: "exponential", delay: 2000 },
removeOnComplete: { age: 86400 }, // Keep completed for 24h
removeOnFail: false, // Never auto-delete failed jobs
},
});
// Monitor dead letter jobs
async function getDeadLetterJobs() {
const failed = await orderQueue.getFailed(0, 100);
return failed.map((job) => ({
id: job.id,
name: job.name,
data: job.data,
failedReason: job.failedReason,
attemptsMade: job.attemptsMade,
timestamp: job.timestamp,
}));
}Programación de trabajos
Algunos trabajos deben ejecutarse según un horario: informes diarios, sincronizaciones cada hora, limpiezas periódicas.
// Repeatable jobs with cron expressions
await reportQueue.add(
"daily-revenue-report",
{}, // Data can be empty for scheduled jobs
{
repeat: {
pattern: "0 8 * * *", // Every day at 8 AM
tz: "America/New_York",
},
},
);
await cleanupQueue.add(
"expire-old-sessions",
{},
{
repeat: {
pattern: "*/15 * * * *", // Every 15 minutes
},
},
);Idempotencia
Los trabajos pueden entregarse más de una vez (entrega at-least-once). Cada manejador de trabajos debe producir el mismo resultado tanto si se ejecuta una vez como diez.
// ❌ Not idempotent — charging the customer multiple times
async function processPayment(job: Job) {
await stripe.charges.create({
amount: job.data.amount,
customer: job.data.customerId,
});
}
// ✅ Idempotent — uses an idempotency key to prevent duplicates
async function processPayment(job: Job) {
await stripe.charges.create(
{
amount: job.data.amount,
customer: job.data.customerId,
},
{
idempotencyKey: `order-${job.data.orderId}`,
},
);
}Usa identificadores únicos (ID de pedido, ID de trabajo) como claves de idempotencia. Comprueba si el trabajo ya se hizo antes de volver a hacerlo.
Conclusiones clave
- Saca el trabajo lento y poco fiable del ciclo de solicitud y llévalo a trabajos en segundo plano
- Persiste los trabajos en un almacén duradero (Redis, PostgreSQL): el procesamiento en memoria no es fiable
- Reintenta con backoff exponencial los fallos transitorios y usa dead letter para los permanentes
- Cada manejador de trabajos debe ser idempotente: los trabajos pueden entregarse más de una vez
- Nunca descartes silenciosamente los trabajos fallidos: las colas de dead letter los conservan para su investigación
- Monitoriza la profundidad de la cola y la latencia de procesamiento: las colas que crecen señalan problemas de capacidad


