Move slow work into background jobs
Continues from the last build: Clients pay through Stripe Checkout and webhooks flip invoices to paid exactly once.
Checkout works. Clients pay, the webhook flips the invoice to paid, and the money lands.
What you'll build
Marking an invoice sent enqueues two jobs and returns instantly. A separate worker process generates the PDF with pdf-lib, uploads it to MinIO, stores the key on the invoice, and emails it through Resend with five retry attempts and exponential backoff. Unpaid invoices get reminder emails scheduled for plus 7 and plus 14 days as delayed jobs, automatically cancelled when the invoice is paid. Every job is idempotent by jobId and by its own internal state check, so BullMQ's at-least-once delivery can never double-send. Jobs that exhaust retries land in a failed queue you can inspect and requeue, not a silent black hole.
See how we teach, before you sign up
You don't just get code dumped on you. Every starter file and every solution is explained line-by-line, in plain English. Here's one real file from this project:
import IORedis from "ioredis";
const globalForRedis = globalThis as unknown as { redis?: IORedis };
export const redisConnection =
globalForRedis.redis ??
new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379", {
maxRetriesPerRequest: null,
});
if (process.env.NODE_ENV !== "production") {
globalForRedis.redis = redisConnection;
}
Reading this file
maxRetriesPerRequest: null,Required by BullMQ, otherwise ioredis gives up on the blocking commands Workers depend on.globalForRedis.redis ??Singleton pattern so Next.js dev's hot reload doesn't open a fresh connection on every file save.new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379", {Same connection string the worker process reads, they must point at the same Redis.if (process.env.NODE_ENV !== "production") {The global cache is a dev convenience only, production gets a fresh module-scoped instance per process.
Starter file for this rung.
That's 1 of 8 explained code blocks in this single project.
The build, milestone by milestone
- 1
Stand up the BullMQ worker
5 guided stepsWithout a durable, out-of-process queue, background jobs are just setTimeout calls that die when the request handler returns. BullMQ backed by Redis survives restarts and lets you scale the worker independently of the web tier.
- 2
Generate invoice PDFs in a job
5 guided stepsPDF rendering is CPU-bound and slow enough to matter once an invoice has more than a handful of line items. Doing it inline was the direct cause of the 6 second response; moving it to a worker frees the web tier's threads for the next request while the worker chews through it.
- 3
Send transactional email with retries
5 guided stepsEmail delivery depends on a third party you don't control. Resend rate limits and occasional 5xxs are normal, and the right response is not to handle it inline and hope, it's to let BullMQ's backoff absorb transient failures while a permanently broken send fails fast instead of burning five attempts for nothing.
- 4
Schedule reminder emails
5 guided stepsChasing unpaid invoices is the actual product value of reminders, but a naive nightly cron that scans every invoice is wasteful and racy. BullMQ delayed jobs let you schedule the exact reminder at enqueue time and cancel it precisely, no scanning needed.
- 5
Idempotency and the poison message
5 guided stepsBullMQ guarantees at-least-once delivery, not exactly-once. A worker crash between finishing the Resend call and BullMQ marking the job complete will redeliver that same job, and without idempotency the client gets a duplicate invoice email. The failed queue is where jobs that permanently can't succeed go so they don't loop forever.
What's inside when you start
You'll walk away with
This is portfolio-grade. Build it free.
Sign up to unlock every milestone step-by-step, the code skeletons, full reference solutions, and checkable tasks, with your progress saved as you build.
Start building