import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis(process.env.REDIS_URL!);
export const emailQueue = new Queue('email', { connection });
export const emailWorker = new Worker(
'email',
async (job) => {
// sendEmail(job.data)
return { sent: true };
},
{
connection,
concurrency: 5
}
);
emailWorker.on('failed', async (job, err) => {
if (!job) return;
if ((job.attemptsMade ?? 0) >= (job.opts.attempts ?? 1)) {
// dead-letter pattern: re-enqueue into a separate queue for investigation
await new Queue('email-dlq', { connection }).add('dead', { original: job.data, error: err.message });
}
});
Background jobs will fail in production, so I like having a predictable story for retries and poison messages. BullMQ is a solid middle ground: Redis-backed, straightforward, and good enough for most apps. I set explicit attempts and backoff, and when a job keeps failing I push it to a DLQ (dead-letter queue) for human inspection instead of retrying forever. For side-effecting work (emails, payments), I include an idempotency key in the job payload and design the handler to be safe on re-run. The core insight is that retries must be safe by design—don’t rely on ‘it probably won’t run twice’, because eventually it will.