Publish-Subscribe Pattern: Build Async Pipelines That Don't Fall Over at 3 AM
Publish-subscribe pattern decouples producers from consumers.
20+ years shipping large-scale distributed systems. Lessons pulled from things that broke in production.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
Pub/sub decouples message producers from consumers via a message broker. Publishers emit events to topics; subscribers consume from those topics. This enables async, scalable, and fault-tolerant communication between services.
Think of a radio station. The station (publisher) broadcasts music on a frequency (topic). Anyone with a radio tuned to that frequency (subscriber) hears the music. The station doesn't know who's listening, and listeners don't affect the broadcast. If you miss a song, you can't replay it unless the station recorded it (persistent subscription).
I've seen a well-intentioned pub/sub system bring down a payments service at 3 AM because the subscriber couldn't keep up and the broker's memory filled up. The team had to restart the broker and replay hours of lost messages. That's the kind of failure that makes you appreciate the pattern's sharp edges.
Pub/sub solves a fundamental problem: how do you let multiple services react to events without coupling them? Before pub/sub, teams used direct HTTP calls or shared databases. Both create tight coupling and single points of failure. A payment service calling a notification service directly means if notifications are down, payments fail. That's unacceptable.
By the end of this article, you'll know exactly when to use pub/sub, how to implement it without the rookie mistakes, and how to debug the three most common production failures. You'll also know when a simple queue or direct call is the smarter choice.
Why Decoupling Matters More Than You Think
Direct coupling between services creates fragility. If service A calls service B directly, and B is slow or down, A fails too. Pub/sub breaks that chain. The publisher doesn't care if subscribers exist or are healthy. It just fires a message and moves on. This lets you scale subscribers independently, add new consumers without touching publishers, and survive subscriber failures without data loss.
But decoupling comes with a cost: you lose visibility. A direct call returns a response; pub/sub is fire-and-forget. You need monitoring, retries, and dead-letter queues to handle failures. Many teams jump into pub/sub without these, then wonder why messages vanish.
Choosing Your Broker: Kafka vs Redis vs RabbitMQ
Your broker choice defines your failure modes. Kafka is built for high-throughput, persistent, replayable streams. Redis pub/sub is fast but ephemeral — messages are lost if no subscriber is listening. RabbitMQ sits in between: persistent queues, flexible routing, but lower throughput than Kafka.
Here's the rule: Use Kafka when you need message replay and ordering guarantees (e.g., event sourcing, audit logs). Use Redis pub/sub for transient notifications where loss is acceptable (e.g., live dashboards). Use RabbitMQ for reliable task distribution with complex routing (e.g., work queues with multiple bindings).
Don't use Redis pub/sub for anything that must survive a restart. Don't use Kafka for low-latency real-time chat — the overhead is too high.
Handling Backpressure: Don't Let the Broker Eat Your Memory
The most common pub/sub failure is the subscriber falling behind. Messages pile up on the broker, memory grows, and eventually the broker OOMs or starts dropping messages. This is backpressure, and you must handle it.
Solutions: 1) Use a bounded buffer on the subscriber side — never let the client library buffer unlimited messages. 2) Implement a circuit breaker: if the subscriber's processing queue exceeds a threshold, pause consumption and alert. 3) Use a dead-letter queue for messages that can't be processed after retries.
In Kafka, you can increase partitions and add more consumers to scale horizontally. In Redis, you're limited — consider switching to Redis Streams which support consumer groups and backpressure.
Idempotency: Because At-Least-Once Means Duplicates
Most pub/sub systems guarantee at-least-once delivery. That means your subscriber will see the same message more than once — during retries, rebalances, or broker failovers. If you're not idempotent, you'll double-charge customers, send duplicate emails, or create duplicate database records.
Solution: Every message carries a unique idempotency key (e.g., UUID). The subscriber checks a dedup cache (Redis with TTL) before processing. If the key exists, skip. If not, process and store the key with a TTL longer than the maximum possible duplicate window.
Never rely on database unique constraints alone — they cause constraint violation errors that need handling.
Dead-Letter Queues: Where Messages Go to Die (or Be Resurrected)
Not all messages can be processed. Maybe the downstream service is down, the data is corrupt, or a bug in your subscriber. If you keep retrying forever, you'll clog the queue and block other messages. Enter the dead-letter queue (DLQ): a separate queue for messages that failed after a maximum number of retries.
Configure your broker to send messages to a DLQ after N retries. Then have a separate process that periodically replays DLQ messages (after fixing the issue) or alerts a human. Never let messages vanish silently.
In Kafka, you can use a DLQ topic and a separate consumer. In RabbitMQ, use dead letter exchanges. In Redis Streams, you'll need to implement it manually.
When Not to Use Pub/Sub: The Overkill Trap
Pub/sub is not always the answer. If you have a single consumer and need a response, use a direct call or a request-reply pattern. If you need exactly-once processing and can't tolerate duplicates, consider a transactional outbox pattern with a database instead.
I've seen teams use Kafka to send a notification email — that's a sledgehammer for a nail. A simple queue (RabbitMQ) or even an HTTP call with retries would be simpler and cheaper.
Also avoid pub/sub for low-latency (<10ms) interactions. The broker hop adds latency. For real-time gaming or trading, consider direct WebSocket connections or UDP multicast.
Monitoring and Debugging: The Blind Spot
Pub/sub systems are notoriously hard to debug because messages flow asynchronously. You need end-to-end tracing. Attach a unique trace ID to every message at the publisher, and propagate it through all subscribers. Use distributed tracing tools (Jaeger, Zipkin) to visualize the flow.
Also log every message arrival and processing outcome. Without logs, you're blind. I've spent hours chasing a missing message only to find it was published to the wrong topic due to a typo.
Set up metrics: publish rate, consume rate, lag, error rate, DLQ depth. Alert on anomalies.
The 4GB Container That Kept Dying
- Always bound every buffer in your pipeline — broker, client, and application.
- Unbounded buffers are landmines.
kafka-consumer-groups --describe --group <group> to see lag per partition.kafka-consumer-groups --bootstrap-server localhost:9092 --group my-group --describecurl http://consumer-host:8080/healthkafka-topics --alter --topic orders --partitions 10 and add more consumers.| File | Command / Code | Purpose |
|---|---|---|
| BasicPubSub.systemdesign | function publishOrderPlaced(orderId, userId, items) { | Why Decoupling Matters More Than You Think |
| BrokerComparison.systemdesign | producer.send(new ProducerRecord<>("orders", orderId, orderJson)); | Choosing Your Broker |
| BackpressureHandler.systemdesign | const MAX_BUFFER_SIZE = 1000; | Handling Backpressure |
| IdempotentSubscriber.systemdesign | const dedupCache = new RedisClient(); | Idempotency |
| DLQSetup.systemdesign | channel.exchangeDeclare("orders.dlx", "direct", true); | Dead-Letter Queues |
| WhenToAvoidPubSub.systemdesign | async function registerUser(userData) { | When Not to Use Pub/Sub |
| TracingSetup.systemdesign | const traceId = uuidv4(); | Monitoring and Debugging |
Key takeaways
Interview Questions on This Topic
How does Kafka handle backpressure when a consumer is slower than the producer?
Frequently Asked Questions
20+ years shipping large-scale distributed systems. Lessons pulled from things that broke in production.
That's Async & Data Processing. Mark it forged?
3 min read · try the examples if you haven't