Kafka Is Not A Job Queue

Kafka to Redis migration cover
Photo by Pawel Czerwinski on Unsplash

Project Overview

A core campaign management system was utilizing AWS MSK (Managed Streaming for Apache Kafka) at a cost of ~$1,000/month. The system was suffering from queue blocking and disproportionate infrastructure overhead. The root cause was an architectural mismatch: we were using an event streaming platform to do a job queue’s work.

The Architectural Mismatch

The backend consisted of an API that accepted campaign launch requests and a worker unit that executed outbound tasks (email, SMS, voicemail) with specific step delays, retries and status checks.

Kafka sat between them. While Kafka provides a strong boundary between request paths and background processors, it is fundamentally designed for ordered event streams, log retention and partition-based consumption.

Our campaign workers, however, required job queue semantics: independent tasks with distinct retry counts, delays, priorities and concurrency limits. Because the setup used a single topic and consumer group, unrelated campaign tasks were forced into a strictly ordered line. A slow job (like a rate-limited voicemail batch) would block faster jobs (like email sends) competing for the same consumption path, severely degrading the system.

The Migration: BullMQ and Redis

To align the architecture with business needs, Kafka was replaced with BullMQ running on Redis. Instead of a single shared stream, the system uses dedicated queues per task type, allowing independent concurrency limits based on the cost, external rate limits and speed of the work.

export const WORKER_CONCURRENCY = {
  [JobType.CAMPAIGN]: 100,
  [JobType.CAMPAIGN_STEP]: 40,
  [JobType.EMAIL]: 30,
  [JobType.SMS]: 50,
  [JobType.VOICEMAIL]: 1,
  [JobType.WATCH]: 100
};

Simplified Handoff

The API no longer publishes to a Kafka topic; it adds a job directly to the Redis queue with an idempotency key to protect against duplicate processing.

const queue = QueueManager.getInstance().getQueue<CampaignJob>('CAMPAIGN');
await queue.add('launch', { campaignId, userId }, { jobId: idempotencyKey });

Results & Business Impact

Moving from a managed streaming platform to an in-memory job queue successfully eliminated the queue-blocking issues. Moreover, it drastically reduced our infrastructure footprint.

ItemMonthly Cost
Before: AWS MSK~$1,000
After: Redis~$180
Saved~$820/month (82% Reduction)

Takeaway

Treating a job queue as an event stream introduces unnecessary complexity. Kafka’s partition and broker model is designed for high-throughput, ordered event streams shared across multiple independent consumers. For independent, delayed and rate-limited background tasks, a dedicated job queue provides better control primitives and significantly lower operational overhead.