Message Queues & Pub/Sub
Decoupling producers from consumers with asynchronous messaging for durability and flexibility.
Overview
Message queues and Pub/Sub systems decouple services by allowing producers to send messages without knowing who will consume them, and consumers to receive messages without knowing who sent them. This enables asynchronous processing, load levelling, and resilience, if a consumer is down, messages accumulate and are processed when it recovers.
Origin
IBM MQ (1993) was among the first enterprise message brokers. RabbitMQ (2007, AMQP), Apache Kafka (LinkedIn, 2011), and Google Pub/Sub established modern patterns. Kafka's log-based approach (messages retained indefinitely, consumers track their position) fundamentally changed the pattern.
Examples
Sidekiq background job queue (Redis-backed)
# Producer: enqueue work without waiting for it
class OrdersController < ApplicationController
def create
order = Order.create!(order_params)
# Fire and forget, returns immediately
ProcessOrderJob.perform_later(order.id)
render json: order, status: :created
end
end
# Consumer: runs in a separate process pool
class ProcessOrderJob < ApplicationJob
queue_as :orders
retry_on ExternalServiceError, wait: :exponentially_longer, attempts: 5
def perform(order_id)
order = Order.find(order_id)
PaymentService.charge(order)
InventoryService.deduct(order.items)
OrderMailer.confirmation(order).deliver_now
order.update!(status: :confirmed)
end
endIf ProcessOrderJob fails, Sidekiq retries with exponential backoff. The HTTP response was already returned, the user is not waiting. The queue absorbs traffic spikes.
Kafka producer and consumer (conceptual)
import { Kafka } from 'kafkajs'
const kafka = new Kafka({ brokers: ['kafka:9092'] })
const producer = kafka.producer()
const consumer = kafka.consumer({ groupId: 'order-service' })
// Producer (Order Service)
await producer.send({
topic: 'order.placed',
messages: [{ key: order.id, value: JSON.stringify(order) }],
})
// Consumer (Fulfillment Service), independent, different team, different deployment
await consumer.subscribe({ topic: 'order.placed', fromBeginning: false })
await consumer.run({
eachMessage: async ({ message }) => {
const order = JSON.parse(message.value.toString())
await fulfillmentService.schedule(order)
},
})Use Cases
- 01Decoupling order placement from downstream processing (payment, fulfilment, notifications)
- 02Load levelling: absorb traffic spikes without overloading downstream services
- 03Fan-out: one event consumed by multiple independent services (billing AND shipping AND analytics)
- 04Durable retry: failed jobs are retried automatically; work is not lost if a service crashes
When Not to Use
- //Operations requiring immediate results returned to the caller, asynchrony is incompatible with synchronous request-response
- //When the operational overhead of a broker exceeds the complexity it solves (use in-process queues for simple cases)
- //When exactly-once delivery is required, most brokers guarantee at-least-once; idempotent consumers are necessary
Technical Notes
- At-least-once delivery means messages may be delivered more than once under failure. Design consumers to be idempotent: processing the same message twice must have the same effect as once
- Dead-letter queues capture messages that fail all retries, monitor them; they indicate systemic bugs
- Kafka topics are partitioned logs. Message ordering is guaranteed within a partition. Use the same partition key (e.g., customer ID) for events that must be processed in order
- Back-pressure: if consumers fall behind producers, the queue grows. Design consumers to scale horizontally and monitor queue depth as a key operational metric
More in Architecture