Skip to content
· 11 min read · 0 views

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:

OperationTypical Duration
Send email with attachment500ms - 2s
Process uploaded image1s - 10s
Generate PDF report2s - 30s
Sync with external API1s - 60s
Video transcoding1min - 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)
AttemptDelay
1Immediate
21 second
32 seconds
44 seconds
58 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

ScenarioUse Background Jobs?
Sending emailsYes
Processing uploadsYes
Third-party API callsYes
Report generationYes
Simple CRUD operationsNo
Real-time featuresNo (use WebSockets)
Quick database queriesNo

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.


You might also like

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.

Discussion