$ cat ~/field-notes/background-jobs-queues-explained.md
Background Jobs and Queues: Stop Blocking Your API
My API endpoint was timing out. Users uploaded images, and the endpoint resized them, generated thumbnails, updated the database, sent webhooks, and returned a response. The whole process took 8 seconds. Users saw spinning loaders. Requests timed out. Heroku killed long-running processes.
I moved everything to background jobs. API response dropped to 40ms. Images processed asynchronously. Users got instant feedback. No more timeouts.
Background jobs are essential for any non-trivial API. Here's how to implement them and when to use them.
The Problem: Blocking Operations
Without background jobs, slow operations block API responses:
// BAD: Everything in the request handler
app.post('/api/posts', upload.single('image'), async (req, res) => {
try {
// 1. Resize image (3 seconds)
const resized = await sharp(req.file.buffer)
.resize(1200, 1200)
.jpeg({ quality: 90 })
.toBuffer();
// 2. Upload to S3 (2 seconds)
await s3.upload({
Key: `posts/${filename}`,
Body: resized
}).promise();
// 3. Generate thumbnails (2 seconds)
const thumb = await sharp(req.file.buffer)
.resize(300, 300)
.jpeg({ quality: 80 })
.toBuffer();
await s3.upload({
Key: `thumbs/${filename}`,
Body: thumb
}).promise();
// 4. Save to database (200ms)
const post = await db.query('INSERT INTO posts SET ?', {
user_id: req.user.id,
image_url: imageUrl,
thumb_url: thumbUrl
});
// 5. Send notifications (500ms)
await sendNotifications(post.id);
// 6. Update search index (1 second)
await searchIndex.add(post);
// Total time: ~8 seconds
res.json(post);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
Problems:
- User waits 8 seconds for response
- Request can timeout (30s limit on most platforms)
- Can't retry failures easily
- Single point of failure
- Wastes API server resources
The Solution: Background Jobs
Move slow operations to background workers:
const Queue = require('bull');
const imageQueue = new Queue('image-processing', {
redis: { host: 'localhost', port: 6379 }
});
// GOOD: Fast API response, process in background
app.post('/api/posts', upload.single('image'), async (req, res) => {
// 1. Save to database immediately (200ms)
const post = await db.query('INSERT INTO posts SET ?', {
user_id: req.user.id,
status: 'processing'
});
// 2. Queue background job
await imageQueue.add('process-image', {
postId: post.id,
imageBuffer: req.file.buffer.toString('base64'),
userId: req.user.id
});
// 3. Return immediately
res.json({
id: post.id,
status: 'processing',
message: 'Image is being processed'
});
});
// Worker processes jobs in background
imageQueue.process('process-image', 5, async (job) => {
const { postId, imageBuffer, userId } = job.data;
const buffer = Buffer.from(imageBuffer, 'base64');
// All the slow stuff happens here
const resized = await sharp(buffer).resize(1200, 1200).jpeg().toBuffer();
const imageUrl = await uploadToS3(resized, 'posts');
const thumb = await sharp(buffer).resize(300, 300).jpeg().toBuffer();
const thumbUrl = await uploadToS3(thumb, 'thumbs');
await db.query('UPDATE posts SET ? WHERE id = ?', [{
image_url: imageUrl,
thumb_url: thumbUrl,
status: 'published'
}, postId]);
await sendNotifications(postId);
await searchIndex.add({ id: postId, userId });
return { success: true, postId };
});
Benefits:
- API responds in 200ms instead of 8s
- Jobs processed asynchronously
- Automatic retries on failure
- Can scale workers independently
- Better resource utilization
Bull: Redis-Backed Job Queue
Bull is the most popular Node.js queue library:
npm install bull ioredis
Basic Setup
const Queue = require('bull');
// Create queue
const emailQueue = new Queue('email', {
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD
}
});
// Add job to queue
await emailQueue.add('welcome-email', {
userId: 123,
email: 'user@example.com',
name: 'John'
});
// Process jobs
emailQueue.process('welcome-email', async (job) => {
const { userId, email, name } = job.data;
await sendEmail({
to: email,
subject: 'Welcome!',
template: 'welcome',
data: { name }
});
// Update user record
await db.query('UPDATE users SET welcome_sent = true WHERE id = ?', [userId]);
return { success: true, userId };
});
Job Options
// Delayed job
await emailQueue.add('reminder', { userId: 123 }, {
delay: 3600000 // 1 hour from now
});
// Priority (lower number = higher priority)
await emailQueue.add('urgent', { data }, { priority: 1 });
await emailQueue.add('normal', { data }, { priority: 5 });
// Retry configuration
await emailQueue.add('api-call', { url }, {
attempts: 5,
backoff: {
type: 'exponential',
delay: 2000
}
});
// Remove job after completion
await emailQueue.add('temp-job', { data }, {
removeOnComplete: true,
removeOnFail: false
});
// Job timeout
await emailQueue.add('long-task', { data }, {
timeout: 30000 // Fail if takes > 30 seconds
});
Worker Configuration
// Process multiple jobs concurrently
emailQueue.process('send-email', 10, async (job) => {
// Processes up to 10 jobs simultaneously
await sendEmail(job.data);
});
// Multiple job types in one queue
emailQueue.process('welcome-email', async (job) => {
await sendWelcomeEmail(job.data);
});
emailQueue.process('reset-password', async (job) => {
await sendPasswordResetEmail(job.data);
});
emailQueue.process('notification', async (job) => {
await sendNotificationEmail(job.data);
});
// Separate worker file
// worker.js
const emailQueue = new Queue('email', { redis: redisConfig });
emailQueue.process(async (job) => {
console.log(`Processing job ${job.id}`);
switch (job.name) {
case 'welcome-email':
return await sendWelcomeEmail(job.data);
case 'notification':
return await sendNotification(job.data);
default:
throw new Error(`Unknown job type: ${job.name}`);
}
});
console.log('Worker started');
Run workers separately:
# API server
node app.js
# Workers (separate processes)
node worker.js
node worker.js
node worker.js
Job Lifecycle and Events
const queue = new Queue('tasks');
// Job events
queue.on('completed', (job, result) => {
console.log(`Job ${job.id} completed with result:`, result);
});
queue.on('failed', (job, error) => {
console.error(`Job ${job.id} failed:`, error.message);
// Alert, log, or take action
});
queue.on('progress', (job, progress) => {
console.log(`Job ${job.id} progress: ${progress}%`);
});
queue.on('stalled', (job) => {
console.warn(`Job ${job.id} stalled`);
});
// Queue events
queue.on('error', (error) => {
console.error('Queue error:', error);
});
queue.on('waiting', (jobId) => {
console.log(`Job ${jobId} is waiting`);
});
queue.on('active', (job) => {
console.log(`Job ${job.id} started`);
});
// Job progress reporting
queue.process(async (job) => {
const items = job.data.items;
for (let i = 0; i < items.length; i++) {
await processItem(items[i]);
// Report progress
await job.progress((i + 1) / items.length * 100);
}
return { processed: items.length };
});
Common Job Patterns
1. Email Jobs
const emailQueue = new Queue('email');
// Queue email
async function queueEmail(to, template, data) {
await emailQueue.add('send', {
to,
template,
data,
queuedAt: Date.now()
}, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000
}
});
}
// Worker
emailQueue.process('send', async (job) => {
const { to, template, data } = job.data;
const html = await renderTemplate(template, data);
await sendEmail({
to,
subject: getSubject(template),
html
});
return { sent: true, to };
});
// Usage
await queueEmail('user@example.com', 'welcome', { name: 'John' });
2. Image Processing
const imageQueue = new Queue('images');
imageQueue.process('resize', 5, async (job) => {
const { imageUrl, sizes } = job.data;
const image = await downloadImage(imageUrl);
const results = {};
for (const [name, { width, height }] of Object.entries(sizes)) {
const resized = await sharp(image)
.resize(width, height, { fit: 'cover' })
.jpeg({ quality: 90 })
.toBuffer();
const url = await uploadToS3(resized, `${name}/${filename}`);
results[name] = url;
await job.progress(Object.keys(results).length / Object.keys(sizes).length * 100);
}
return results;
});
// Usage
const job = await imageQueue.add('resize', {
imageUrl: 'https://example.com/image.jpg',
sizes: {
thumbnail: { width: 150, height: 150 },
medium: { width: 600, height: 600 },
large: { width: 1200, height: 1200 }
}
});
3. Report Generation
const reportQueue = new Queue('reports');
reportQueue.process('generate', async (job) => {
const { userId, reportType, params } = job.data;
await job.progress(10);
// Fetch data (slow)
const data = await fetchReportData(reportType, params);
await job.progress(40);
// Generate report (slow)
const pdf = await generatePDF(data);
await job.progress(70);
// Upload to S3
const url = await uploadToS3(pdf, `reports/${userId}/${Date.now()}.pdf`);
await job.progress(90);
// Notify user
await emailQueue.add('send', {
to: params.email,
template: 'report-ready',
data: { url }
});
await job.progress(100);
return { url, size: pdf.length };
});
// API endpoint
app.post('/api/reports', async (req, res) => {
const job = await reportQueue.add('generate', {
userId: req.user.id,
reportType: req.body.type,
params: req.body.params
});
res.json({
jobId: job.id,
status: 'queued',
message: 'Report is being generated. You will receive an email when ready.'
});
});
// Check job status
app.get('/api/reports/:jobId', async (req, res) => {
const job = await reportQueue.getJob(req.params.jobId);
if (!job) {
return res.status(404).json({ error: 'Job not found' });
}
const state = await job.getState();
const progress = job.progress();
res.json({
id: job.id,
state,
progress,
result: state === 'completed' ? job.returnvalue : null
});
});
4. Webhook Delivery
const webhookQueue = new Queue('webhooks');
webhookQueue.process('deliver', async (job) => {
const { url, payload, headers } = job.data;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers
},
body: JSON.stringify(payload),
timeout: 10000
});
if (!response.ok) {
throw new Error(`Webhook failed: ${response.status}`);
}
return {
status: response.status,
deliveredAt: Date.now()
};
});
// Queue webhook with retries
async function sendWebhook(url, payload) {
await webhookQueue.add('deliver', {
url,
payload,
headers: {
'X-Webhook-Signature': signPayload(payload)
}
}, {
attempts: 5,
backoff: {
type: 'exponential',
delay: 1000
}
});
}
5. Data Sync Jobs
const syncQueue = new Queue('sync');
// Recurring sync job
syncQueue.add('sync-users', {}, {
repeat: {
cron: '0 * * * *' // Every hour
}
});
syncQueue.process('sync-users', async (job) => {
const users = await fetchUsersFromExternalAPI();
for (const user of users) {
await db.query(
'INSERT INTO users SET ? ON DUPLICATE KEY UPDATE ?',
[user, user]
);
}
return { synced: users.length };
});
Bull Dashboard
Monitor jobs with Bull Board:
npm install @bull-board/express
const { createBullBoard } = require('@bull-board/api');
const { BullAdapter } = require('@bull-board/api/bullAdapter');
const { ExpressAdapter } = require('@bull-board/express');
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: [
new BullAdapter(emailQueue),
new BullAdapter(imageQueue),
new BullAdapter(reportQueue)
],
serverAdapter
});
app.use('/admin/queues', serverAdapter.getRouter());
// Visit http://localhost:3000/admin/queues
Dashboard shows:
- Active, waiting, completed, failed jobs
- Job details and logs
- Retry failed jobs
- Clean old jobs
- Pause/resume queues
Scheduled Jobs (Cron)
// Run every day at 2 AM
await cleanupQueue.add('cleanup-old-data', {}, {
repeat: {
cron: '0 2 * * *'
}
});
// Run every 15 minutes
await backupQueue.add('backup', {}, {
repeat: {
every: 15 * 60 * 1000
}
});
// Run on specific days
await reportQueue.add('weekly-report', {}, {
repeat: {
cron: '0 9 * * 1' // Monday at 9 AM
}
});
Job Dependencies
// Process jobs in sequence
const job1 = await queue.add('step-1', { data: 'foo' });
const job2 = await queue.add('step-2', { data: 'bar' }, {
parent: job1.id
});
const job3 = await queue.add('step-3', { data: 'baz' }, {
parent: job2.id
});
// job2 waits for job1, job3 waits for job2
Error Handling
queue.process(async (job) => {
try {
const result = await doWork(job.data);
return result;
} catch (error) {
// Log error
console.error(`Job ${job.id} failed:`, error);
// Retry specific errors
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
throw error; // Bull will retry
}
// Don't retry other errors
await job.moveToFailed({
message: error.message
}, true);
// Notify on permanent failure
if (job.attemptsMade >= job.opts.attempts) {
await notifyError(job, error);
}
}
});
// Global error handler
queue.on('failed', async (job, error) => {
if (job.attemptsMade >= job.opts.attempts) {
// All retries exhausted
await logPermanentFailure(job, error);
await alertTeam(job, error);
}
});
Graceful Shutdown
async function gracefulShutdown() {
console.log('Shutting down gracefully...');
// Stop accepting new jobs
await queue.pause();
// Wait for active jobs to finish (with timeout)
await queue.close(30000);
console.log('All jobs completed. Exiting.');
process.exit(0);
}
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);
Serverless Alternative: AWS Lambda
For simple jobs without needing Bull:
// API queues Lambda invocation
const AWS = require('aws-sdk');
const lambda = new AWS.Lambda();
app.post('/api/process', async (req, res) => {
// Invoke Lambda asynchronously
await lambda.invoke({
FunctionName: 'image-processor',
InvocationType: 'Event', // Async
Payload: JSON.stringify({
imageUrl: req.body.imageUrl,
userId: req.user.id
})
}).promise();
res.json({
status: 'processing',
message: 'Image processing started'
});
});
// Lambda function (image-processor)
exports.handler = async (event) => {
const { imageUrl, userId } = event;
const image = await downloadImage(imageUrl);
const resized = await sharp(image).resize(1200, 1200).jpeg().toBuffer();
const url = await uploadToS3(resized);
await updateDatabase(userId, url);
await sendNotification(userId, url);
return { success: true };
};
When to Use What
Use Bull + Redis when:
- Need reliable job processing
- Want retry logic and error handling
- Need job prioritization
- Running on persistent servers
- Want dashboard and monitoring
Use Lambda/serverless when:
- Simple one-off tasks
- Don't want to manage workers
- Irregular workload (sporadic jobs)
- Want auto-scaling
- Already on AWS/serverless architecture
Use cron/scheduled tasks when:
- Regular scheduled jobs only
- Don't need queueing
- Low volume
- Simple scripts
Production Setup
// config/queue.js
const Queue = require('bull');
const Redis = require('ioredis');
const redisConfig = {
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
password: process.env.REDIS_PASSWORD,
maxRetriesPerRequest: null, // Bull requirement
enableReadyCheck: false
};
// Create queue with proper config
function createQueue(name, options = {}) {
return new Queue(name, {
redis: redisConfig,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000
},
removeOnComplete: 100, // Keep last 100 completed jobs
removeOnFail: 500, // Keep last 500 failed jobs
...options
}
});
}
module.exports = {
emailQueue: createQueue('email'),
imageQueue: createQueue('images', { timeout: 60000 }),
reportQueue: createQueue('reports', { timeout: 300000 })
};
// worker.js
const { emailQueue, imageQueue, reportQueue } = require('./config/queue');
// Process jobs
emailQueue.process(5, require('./workers/email'));
imageQueue.process(3, require('./workers/image'));
reportQueue.process(1, require('./workers/report'));
// Monitor
emailQueue.on('completed', (job) => {
console.log(`Email job ${job.id} completed`);
});
emailQueue.on('failed', (job, error) => {
console.error(`Email job ${job.id} failed:`, error.message);
});
console.log('Worker started');
// Deployment with PM2
// ecosystem.config.js
module.exports = {
apps: [
{
name: 'api',
script: 'app.js',
instances: 2,
exec_mode: 'cluster'
},
{
name: 'worker',
script: 'worker.js',
instances: 4
}
]
};
The Bottom Line
Background jobs are essential for production APIs:
Never block API responses with slow operations. Queue them.
Use Bull + Redis for reliable job processing with retries and monitoring.
Process jobs in separate workers not in your API servers.
Monitor job queues - failed jobs mean something is broken.
Handle failures gracefully with retries and alerts.
Moving slow operations to background jobs dropped my API response time from 8 seconds to 40ms. Users got instant feedback. No more timeouts. No more angry users.
Implement background jobs before you need them. Your users will thank you.