How to Run Background Jobs in Node.js Without Setting Up Infrastructure
Your API endpoint needs to do three things when a user signs up: create the account, send a welcome email, and resize their profile photo. You could do all three synchronously, but now your /api/register endpoint takes 8 seconds to respond because the email service is slow and image processing blocks the event loop.
So you think: "I need a background job queue." Then you look at the options: RabbitMQ needs a server. AWS SQS needs IAM configuration. Kafka is enterprise-level overkill. Suddenly a simple "send email in background" task feels like a DevOps project.
It doesn't have to be. Let me walk you through three approaches — from zero-dependency to production-grade — and when to use each.
Why You Can't Just Use setTimeout
// ❌ This is not a background job
app.post('/api/register', async (req, res) => {
const user = await createUser(req.body);
res.json({ success: true }); // Respond immediately
// "Background" work
setTimeout(async () => {
await sendWelcomeEmail(user.email);
await resizeProfilePhoto(user.id);
}, 0);
});This looks like it works, but:
- No retry on failure. If
sendWelcomeEmailthrows, the email is permanently lost. - No persistence. If the server crashes or restarts, pending jobs vanish.
- No concurrency control. 1,000 signups = 1,000 simultaneous image resize operations hammering your CPU.
- No observability. You can't see what's pending, what failed, or what completed.
setTimeout is fire-and-forget. Real background jobs need retry logic, persistence, and monitoring.
Approach 1: In-Process Queue (Zero Dependencies)
For simple use cases — sending emails, logging analytics events, or fire-and-forget tasks — a lightweight in-process queue with retry logic is all you need.
// lib/simple-queue.ts
type Job<T> = {
id: string;
data: T;
attempts: number;
maxAttempts: number;
createdAt: Date;
};
type JobHandler<T> = (data: T) => Promise<void>;
export class SimpleQueue<T> {
private queue: Job<T>[] = [];
private processing = false;
private handler: JobHandler<T>;
private concurrency: number;
private activeCount = 0;
constructor(handler: JobHandler<T>, concurrency = 3) {
this.handler = handler;
this.concurrency = concurrency;
}
add(data: T, maxAttempts = 3): string {
const id = crypto.randomUUID();
this.queue.push({
id,
data,
attempts: 0,
maxAttempts,
createdAt: new Date(),
});
this.process();
return id;
}
private async process(): Promise<void> {
if (this.processing) return;
this.processing = true;
while (this.queue.length > 0 && this.activeCount < this.concurrency) {
const job = this.queue.shift();
if (!job) break;
this.activeCount++;
this.executeJob(job).finally(() => {
this.activeCount--;
if (this.queue.length > 0) this.process();
});
}
this.processing = false;
}
private async executeJob(job: Job<T>): Promise<void> {
job.attempts++;
try {
await this.handler(job.data);
console.log(`[Queue] Job ${job.id} completed`);
} catch (error) {
console.error(`[Queue] Job ${job.id} failed (attempt ${job.attempts}/${job.maxAttempts})`);
if (job.attempts < job.maxAttempts) {
// Exponential backoff: 1s, 2s, 4s
const delay = Math.pow(2, job.attempts - 1) * 1000;
setTimeout(() => {
this.queue.push(job);
this.process();
}, delay);
} else {
console.error(`[Queue] Job ${job.id} permanently failed`, error);
// Optionally: save to a dead letter store
}
}
}
get pendingCount(): number {
return this.queue.length;
}
get activeJobs(): number {
return this.activeCount;
}
}Using It
// queues/email-queue.ts
import { SimpleQueue } from '@/lib/simple-queue';
import { sendEmail } from '@/lib/email';
type EmailJob = {
to: string;
subject: string;
html: string;
};
export const emailQueue = new SimpleQueue<EmailJob>(async (job) => {
await sendEmail(job.to, job.subject, job.html);
}, 5); // Process 5 emails at a time
// In your API route:
app.post('/api/register', async (req, res) => {
const user = await createUser(req.body);
// Queue email — returns immediately
emailQueue.add({
to: user.email,
subject: 'Welcome!',
html: renderWelcomeEmail(user.name),
});
res.json({ success: true });
});When this is enough:
- Tasks that can be lost on server restart (emails can be re-triggered)
- Low-volume processing (< 100 jobs/minute)
- Single server deployment
When you need more: Multiple servers, tasks that must survive restarts, or heavy processing.
Approach 2: BullMQ + Redis (Production-Grade)
BullMQ is the go-to solution for Node.js background jobs. It uses Redis for persistence, supports retries, priorities, scheduling, and rate limiting — and it's surprisingly easy to set up.
npm install bullmqDefine the Queue and Worker
// queues/image-queue.ts
import { Queue, Worker } from 'bullmq';
const connection = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
};
// Queue: where you ADD jobs
export const imageQueue = new Queue('image-processing', { connection });
// Worker: where jobs are PROCESSED
const worker = new Worker('image-processing', async (job) => {
const { userId, imagePath } = job.data;
console.log(`Processing image for user ${userId}`);
// Step 1: Download original
const original = await downloadImage(imagePath);
// Step 2: Generate sizes
await Promise.all([
generateThumbnail(original, userId, 150), // 150px thumb
generateMedium(original, userId, 600), // 600px medium
generateLarge(original, userId, 1200), // 1200px large
]);
// Step 3: Update user record
await updateUserProfileImage(userId, {
thumbnail: `/images/${userId}/thumb.webp`,
medium: `/images/${userId}/medium.webp`,
large: `/images/${userId}/large.webp`,
});
// Progress tracking
await job.updateProgress(100);
}, {
connection,
concurrency: 3, // Process 3 images simultaneously
limiter: {
max: 10, // Max 10 jobs
duration: 60_000, // Per minute (rate limiting)
},
});
worker.on('completed', (job) => {
console.log(`Image job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`Image job ${job?.id} failed:`, err.message);
});Adding Jobs
// In your API route
app.post('/api/profile/photo', async (req, res) => {
const { userId, imagePath } = req.body;
await imageQueue.add('resize', {
userId,
imagePath,
}, {
attempts: 3, // Retry up to 3 times
backoff: { type: 'exponential', delay: 2000 }, // 2s, 4s, 8s
removeOnComplete: { count: 100 }, // Keep last 100 completed
removeOnFail: { count: 500 }, // Keep last 500 failed
priority: 1, // Higher priority
});
res.json({ message: 'Photo processing started' });
});Scheduled / Delayed Jobs
// Send follow-up email 24 hours after signup
await emailQueue.add('follow-up', {
userId: user.id,
template: 'day-one-tips',
}, {
delay: 24 * 60 * 60 * 1000, // 24 hours
});
// Run a cleanup job every hour
await maintenanceQueue.add('cleanup', {}, {
repeat: {
every: 60 * 60 * 1000, // Every hour
},
});Monitoring with Bull Board
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
const serverAdapter = new ExpressAdapter();
createBullBoard({
queues: [
new BullMQAdapter(emailQueue),
new BullMQAdapter(imageQueue),
],
serverAdapter,
});
app.use('/admin/queues', serverAdapter.getRouter());Now you have a dashboard at /admin/queues showing all pending, active, completed, and failed jobs.
Approach 3: Serverless-Friendly (Vercel, Cloudflare, AWS Lambda)
On serverless platforms, you can't run persistent workers. The function executes and dies. You have two options:
Option A: waitUntil (Vercel / Cloudflare)
// app/api/register/route.ts (Next.js)
import { after } from 'next/server';
export async function POST(request: Request) {
const user = await createUser(await request.json());
// This runs AFTER the response is sent
after(async () => {
await sendWelcomeEmail(user.email);
await trackAnalyticsEvent('user_signup', user.id);
});
return Response.json({ success: true });
}after() (Next.js 15+) lets you run code after the response is sent. The function invocation stays alive until the work completes. It's not a queue — there's no retry — but it's perfect for non-critical tasks.
Option B: External Queue Service
For reliable serverless background jobs, use a hosted queue:
- Upstash QStash — HTTP-based message queue built for serverless
- Inngest — Event-driven background functions
- Trigger.dev — Background jobs with built-in retry and scheduling
// Using QStash
import { Client } from '@upstash/qstash';
const qstash = new Client({ token: process.env.QSTASH_TOKEN! });
// In your API route — queue the job
await qstash.publishJSON({
url: 'https://yourapp.com/api/jobs/send-email',
body: { userId: user.id, template: 'welcome' },
retries: 3,
delay: 0,
});
// The job endpoint processes it
// app/api/jobs/send-email/route.ts
export async function POST(request: Request) {
const { userId, template } = await request.json();
await sendTemplatedEmail(userId, template);
return Response.json({ success: true });
}QStash calls your endpoint via HTTP, with built-in retries and dead letter queues. Your serverless function doesn't stay alive — QStash triggers a new invocation for each job.
Which Approach to Choose
| Scenario | Approach | Why |
|----------|----------|-----|
| Single server, low volume | In-process queue | Zero dependencies, simple |
| Multiple servers, high volume | BullMQ + Redis | Persistent, scalable, monitored |
| Serverless (Vercel/Cloudflare) | after() or QStash | No persistent process needed |
| Critical jobs (payments) | BullMQ or external service | Must survive crashes |
| Quick fire-and-forget (analytics) | after() or in-process | Doesn't need persistence |
Key Takeaways
setTimeoutis not a background job. No retry, no persistence, no monitoring.- In-process queues work for single-server, non-critical tasks.
- BullMQ is the standard for Node.js apps that need reliability and observability.
- Serverless needs external triggers. Use
after(), QStash, or Inngest. - Always add retry logic. External services fail. Your jobs should retry automatically.
- Monitor your queues. Failed jobs that nobody notices are worse than no jobs at all.
Background jobs are one of those things every backend developer needs but nobody teaches in tutorials. Start simple, add complexity as your scale demands it, and always make sure you can see what's happening in your queues.
Find me at: itszain.dev