Background Job Processing Patterns: A Practical Guide for 2026
Master background job processing for scalable applications. Learn queue patterns, retry strategies, dead letter queues, and best practices for reliable async workflows.
// table of contents (31 sections)
Every production application eventually hits the same wall: operations that take too long. Sending emails, processing images, generating reports, syncing with third-party APIs — these operations block your main thread and frustrate users.
Background job processing solves this. You offload work to a queue, return immediately to the user, and process asynchronously. Simple concept, but the implementation details separate reliable systems from broken ones.
Let’s dive into the patterns that make background jobs work at scale.
Why Background Jobs Matter
The User Experience Problem
Users expect responses in under 200ms. Anything longer feels slow. But many operations can’t complete that fast:
| Operation | Typical Duration |
|---|---|
| Send email with attachment | 500ms - 2s |
| Process uploaded image | 1s - 10s |
| Generate PDF report | 2s - 30s |
| Sync with external API | 1s - 60s |
| Video transcoding | 1min - 1hour |
Running these synchronously means:
- Users stare at loading spinners
- HTTP connections timeout
- Server resources are blocked
- Poor user experience
The Architecture Solution
Background jobs decouple request from processing:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │────▶│ API │────▶│ Queue │────▶│ Worker │
│ │◀────│ (fast) │ │ │ │ (slow) │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│ │
└──────────────── Notification ─────────────────────┘
The API responds instantly. The user gets on with their life. The work happens in the background.
Core Patterns
Pattern 1: Fire-and-Forget
The simplest pattern. Submit a job, don’t wait for results.
// API endpoint
app.post('/send-welcome-email', async (req, res) => {
const { email, name } = req.body;
// Enqueue job
await queue.add('send-email', {
to: email,
template: 'welcome',
data: { name }
});
// Return immediately
res.json({ message: 'Email queued' });
});
// Worker process
worker.process('send-email', async (job) => {
const { to, template, data } = job.data;
await emailService.send(to, template, data);
});
Use for:
- Sending emails/SMS
- Logging and analytics
- Cache invalidation
- Webhook notifications
Pattern 2: Deferred Results
User needs the result, but can wait. Poll for completion.
// API endpoint
app.post('/generate-report', async (req, res) => {
const jobId = uuid();
await queue.add('generate-report', {
jobId,
userId: req.user.id,
filters: req.body.filters
});
res.json({
jobId,
statusUrl: `/jobs/${jobId}/status`
});
});
// Status endpoint
app.get('/jobs/:jobId/status', async (req, res) => {
const status = await redis.hgetall(`job:${req.params.jobId}`);
res.json(status);
});
// Worker updates status
worker.process('generate-report', async (job) => {
const { jobId } = job.data;
await redis.hmset(`job:${jobId}`, { status: 'processing' });
const report = await generateReport(job.data);
const url = await uploadToS3(report);
await redis.hmset(`job:${jobId}`, {
status: 'completed',
url,
completedAt: new Date().toISOString()
});
});
Use for:
- Report generation
- File exports
- Batch processing
- Image/video processing
Pattern 3: Scheduled Jobs
Run jobs at specific times or intervals.
// Schedule job for future execution
await queue.add('send-reminder', {
userId: '123',
message: 'Your trial expires tomorrow'
}, {
delay: 24 * 60 * 60 * 1000 // 24 hours
});
// Recurring job (cron pattern)
await queue.add('cleanup-temp-files', {}, {
repeat: {
pattern: '0 2 * * *' // Daily at 2 AM
}
});
// Worker handles both immediate and scheduled
worker.process('send-reminder', async (job) => {
await notificationService.send(job.data);
});
Use for:
- Email reminders
- Subscription renewals
- Daily reports
- Cleanup tasks
Pattern 4: Job Chaining (Workflows)
Chain jobs together for complex workflows.
// Define workflow
const workflow = {
steps: [
{ name: 'validate-order', queue: 'orders' },
{ name: 'charge-payment', queue: 'payments' },
{ name: 'fulfill-order', queue: 'fulfillment' },
{ name: 'send-confirmation', queue: 'notifications' }
]
};
// Orchestrator
worker.process('validate-order', async (job) => {
await validateOrder(job.data);
// Trigger next step
await queue.add('charge-payment', {
...job.data,
step: 2
});
});
// With error handling per step
worker.process('charge-payment', async (job) => {
try {
await chargePayment(job.data);
await queue.add('fulfill-order', job.data);
} catch (error) {
await queue.add('order-failed', {
...job.data,
error: error.message
});
}
});
Use for:
- Order processing
- Onboarding workflows
- Data pipelines
- Multi-step integrations
Reliability Patterns
Retry Strategy
Jobs fail. Networks flake. External APIs go down. A good retry strategy is essential.
const retryConfig = {
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000 // Start at 1s, double each retry
}
};
await queue.add('fetch-user-data', { userId: '123' }, retryConfig);
// Worker with retry awareness
worker.process('fetch-user-data', async (job) => {
console.log(`Attempt ${job.attemptsMade + 1}/${job.opts.attempts}`);
const response = await fetchExternalAPI(job.data.userId);
return response;
});
Exponential backoff formula:
delay = baseDelay * 2^(attempt - 1)
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 second |
| 3 | 2 seconds |
| 4 | 4 seconds |
| 5 | 8 seconds |
Dead Letter Queue (DLQ)
When all retries fail, move to a dead letter queue for investigation.
// Configure DLQ
const queue = new Queue('main-queue', {
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 }
}
});
const dlq = new Queue('dead-letter-queue');
// Worker with DLQ handling
worker.on('failed', async (job, error) => {
if (job.attemptsMade >= job.opts.attempts) {
await dlq.add('failed-job', {
originalJob: job.data,
error: error.message,
failedAt: new Date().toISOString(),
stack: error.stack
});
}
});
// DLQ processor for manual intervention
dlqWorker.process('failed-job', async (job) => {
// Log for investigation
await logFailedJob(job.data);
// Alert team
await sendAlert(`Job failed: ${job.data.originalJob.type}`);
});
Idempotency
Jobs might be processed multiple times. Design for it.
worker.process('process-payment', async (job) => {
const { transactionId, amount } = job.data;
// Check if already processed
const existing = await db.query(
'SELECT status FROM transactions WHERE id = $1',
[transactionId]
);
if (existing?.status === 'completed') {
console.log('Transaction already processed, skipping');
return { status: 'skipped', reason: 'already_completed' };
}
// Process with idempotency key
await paymentProvider.charge({
amount,
idempotencyKey: transactionId
});
await db.query(
'UPDATE transactions SET status = $1 WHERE id = $2',
['completed', transactionId]
);
});
Timeout Handling
Don’t let jobs run forever.
await queue.add('slow-operation', data, {
timeout: 30000 // 30 seconds
});
worker.process('slow-operation', async (job) => {
// Use AbortController for fetch/operations
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 28000);
try {
const result = await fetchExternalAPI(data, {
signal: controller.signal
});
return result;
} finally {
clearTimeout(timeoutId);
}
});
Queue Technology Options
Redis-Based (BullMQ, Sidekiq, Celery)
Pros:
- Fast in-memory operations
- Rich feature set (delayed jobs, priorities, rate limiting)
- Active ecosystem
- Easy to scale horizontally
Cons:
- Redis persistence concerns
- Memory limits
- Single point of failure (without Redis Cluster)
// BullMQ example
import { Queue, Worker } from 'bullmq';
import Redis from 'ioredis';
const connection = new Redis({ host: 'localhost', port: 6379 });
const queue = new Queue('email-queue', { connection });
const worker = new Worker('email-queue', async job => {
await sendEmail(job.data);
}, {
connection,
concurrency: 10 // Process 10 jobs simultaneously
});
Database-Based (PostgreSQL, SQLAlchemy)
Pros:
- ACID guarantees
- No additional infrastructure
- Easy to query job status
- Transactional with your data
Cons:
- Slower than Redis
- Polling instead of pushing
- Database can become bottleneck
-- PostgreSQL queue table
CREATE TABLE job_queue (
id SERIAL PRIMARY KEY,
queue_name VARCHAR(255),
payload JSONB,
status VARCHAR(50) DEFAULT 'pending',
attempts INT DEFAULT 0,
max_attempts INT DEFAULT 3,
created_at TIMESTAMP DEFAULT NOW(),
scheduled_at TIMESTAMP DEFAULT NOW(),
locked_by VARCHAR(255),
locked_at TIMESTAMP
);
-- Lock and fetch job (atomic)
UPDATE job_queue
SET status = 'processing',
locked_by = 'worker-1',
locked_at = NOW()
WHERE id = (
SELECT id FROM job_queue
WHERE queue_name = 'email'
AND status = 'pending'
AND scheduled_at <= NOW()
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING *;
Cloud Services (AWS SQS, Google Cloud Tasks)
Pros:
- Fully managed
- Infinite scaling
- Built-in DLQ
- No infrastructure
Cons:
- Vendor lock-in
- Higher cost at scale
- Limited features compared to BullMQ
// AWS SQS example
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
const sqs = new SQSClient({ region: 'us-east-1' });
// Enqueue
await sqs.send(new SendMessageCommand({
QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123456789/my-queue',
MessageBody: JSON.stringify({
type: 'send-email',
data: { to: 'user@example.com' }
}),
DelaySeconds: 10, // Optional delay
MessageAttributes: {
Priority: { StringValue: 'high', DataType: 'String' }
}
}));
Best Practices
1. Keep Jobs Small and Focused
// BAD: One massive job
await queue.add('process-user', { userId: '123' });
// Does: validate, charge, email, update, notify...
// GOOD: Small, focused jobs
await queue.add('validate-user', { userId: '123' });
// Next job triggered after success
2. Include All Context in Job Data
// BAD: Worker queries for additional data
await queue.add('send-email', { userId: '123' });
// Worker: const user = await db.findUser(userId);
// GOOD: Include necessary data
await queue.add('send-email', {
userId: '123',
email: user.email,
name: user.name,
preferences: user.preferences
});
3. Monitor Everything
// Track metrics
metrics.increment('job.enqueued', { queue: 'email' });
metrics.timing('job.duration', duration, { queue: 'email', type: job.name });
// Set up alerts
if (queueDepth > 1000) {
await sendAlert('Queue depth critical');
}
4. Graceful Shutdown
let isShuttingDown = false;
process.on('SIGTERM', async () => {
isShuttingDown = true;
// Stop accepting new jobs
await worker.pause();
// Wait for current jobs to finish
await worker.whenCurrentJobsFinished();
// Close connections
await queue.close();
await redis.quit();
process.exit(0);
});
worker.process('my-job', async (job) => {
if (isShuttingDown) {
// Re-queue job for another worker
throw new Error('Worker shutting down');
}
// Process job
});
5. Use Job Priorities
await queue.add('send-password-reset', data, {
priority: 1 // High priority
});
await queue.add('send-newsletter', data, {
priority: 10 // Low priority
});
Common Pitfalls
Pitfall 1: Memory Leaks in Long-Running Workers
Workers run for days. Memory leaks compound.
// BAD: Closures capture large objects
const bigData = await loadData();
worker.process('job', async (job) => {
// bigData stays in memory forever
return processWith(bigData, job);
});
// GOOD: Load per job or use WeakRef
worker.process('job', async (job) => {
const data = await loadDataForJob(job);
return processWith(data);
});
Pitfall 2: Unhandled Promise Rejections
// BAD: Missing error handling
worker.process('job', async (job) => {
await doSomething(); // Might throw
await doSomethingElse();
});
// GOOD: Comprehensive error handling
worker.process('job', async (job) => {
try {
await doSomething();
await doSomethingElse();
} catch (error) {
logger.error('Job failed', {
jobId: job.id,
error: error.message,
stack: error.stack
});
throw error; // Re-throw to trigger retry
}
});
Pitfall 3: Queue as Database
// BAD: Using queue as data store
await queue.add('save-user', userData);
// No transaction, no querying, no relations
// GOOD: Save to database, queue notification
await db.saveUser(userData);
await queue.add('send-welcome-email', { email: userData.email });
Architecture at Scale
Here’s how a production system looks:
┌─────────────────────────────────────────────────────────────────────┐
│ Background Job Architecture │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ Producer │───┐ │
│ │ (API) │ │ │
│ └──────────────┘ │ │
│ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Redis │────▶│ Scheduler │ (delayed jobs) │
│ │ Queue │ └─────────────┘ │
│ └──────┬──────┘ │
│ │ │
│ ┌──────────┼──────────┬───────────┐ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Worker │ │ Worker │ │ Worker │ │ Worker │ │
│ │ (email) │ │ (report)│ │ (image) │ │ (API) │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │ │
│ └──────────┴──────────┴───────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ DLQ │ │ Metrics │ │
│ │ (failures) │ │ Dashboard │ │
│ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
When to Use Background Jobs
| Scenario | Use Background Jobs? |
|---|---|
| Sending emails | Yes |
| Processing uploads | Yes |
| Third-party API calls | Yes |
| Report generation | Yes |
| Simple CRUD operations | No |
| Real-time features | No (use WebSockets) |
| Quick database queries | No |
Conclusion
Background job processing is fundamental to building responsive, scalable applications. The key principles:
- Decouple: Separate request from processing
- Retry: Expect failures, handle gracefully
- Monitor: Know what’s happening in your queues
- Idempotency: Design for duplicate processing
- Scale: Workers are cheap, user experience is expensive
Start with a simple Redis-based queue like BullMQ. Add complexity (multiple queues, priorities, DLQ) as your needs grow. The patterns in this guide will help you avoid the pitfalls that break production systems.
Async is the future. Your users — and your servers — will thank you.
Related Reading
You might also like
Workflow Orchestration 2026: Temporal vs Inngest vs Trigger.dev
Compare leading workflow orchestration platforms for modern applications. Learn when to use Temporal, Inngest, or Trigger.dev for background jobs, event-driven architectures, and durable workflows.
API Gateway Patterns: The Front Door to Your Microservices
Master API Gateway patterns for microservices architecture. Learn request routing, authentication, rate limiting, and service mesh integration with TypeScript examples.
Database Connection Pooling: Patterns for High-Performance Applications
Master database connection pooling for scalable applications. Compare pgBouncer, HikariCP, and connection pool patterns with practical examples and performance benchmarks.
More Posts
API Gateway Patterns: The Front Door to Your Microservices
Web Components 2026: Building Framework-Agnostic UI Libraries
Building Autonomous AI Workflows with LangGraph: A Practical Guide
Building Type-Safe APIs with tRPC in 2026: Full-Stack TypeScript Without Schemas
Database Connection Pooling: Patterns for High-Performance Applications
Prompt Caching: Reduce LLM Costs by 90% with Smart Context Management
Enjoyed This Post?
Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.
