ZVN

Creating a Queue and Worker with BullMQ

Some work shouldn't block a request. When a user invites someone to your app, they shouldn't wait for an email to send before getting a response. In this post, we'll use BullMQ to queue invite emails and process them in a background worker.

Why a Queue?

A queue splits the work into two parts: the API quickly adds a job and responds, while a separate worker picks up jobs and does the slow part. This gives us:

  • Fast responses—email sending happens in the background
  • Automatic retries when the email provider is down
  • Jobs survive restarts since they live in Redis

Creating the Queue

BullMQ is backed by Redis, so all we need is a connection and a queue name:

import { Queue } from 'bullmq';
const connection = {
host: 'localhost',
port: 6379
};
export const emailQueue = new Queue('invite-emails', { connection });

Adding Jobs

When one user invites another, we add a job with everything the worker will need:

interface InviteEmailJob {
to: string;
invitedBy: string;
inviteLink: string;
}
async function inviteUser(email: string, invitedBy: string) {
const inviteLink = await createInviteLink(email);
await emailQueue.add('send-invite', {
to: email,
invitedBy,
inviteLink
} satisfies InviteEmailJob);
// Respond to the inviter immediately —
// the email sends in the background
}

The Worker

The worker runs as its own process. It listens on the same queue name and processes each job as it arrives:

import { Worker } from 'bullmq';
const worker = new Worker<InviteEmailJob>(
'invite-emails',
async job => {
const { to, invitedBy, inviteLink } = job.data;
await sendEmail({
to,
subject: `${invitedBy} invited you!`,
body: `Join here: ${inviteLink}`
});
},
{ connection }
);
worker.on('completed', job => {
console.log(`Invite sent to ${job.data.to}`);
});
worker.on('failed', (job, err) => {
console.error(`Invite to ${job?.data.to} failed:`, err.message);
});

Handling Failures

Email providers fail. Instead of writing retry logic ourselves, we tell BullMQ how to retry when adding the job:

await emailQueue.add('send-invite', jobData, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000 // 5s, 10s, 20s
}
});

If all attempts fail, the job lands in the failed set where you can inspect or retry it later.

Conclusion

With a Queue to accept jobs and a Worker to process them, invite emails send reliably without slowing down your API. The same pattern covers most background work—image processing, webhooks, notifications—just add another queue.

Don't forget Redis needs to be running for any of this to work. 😉

© Copyright Eli Zevin