Hard lesson 02

Message Brokers vs Event Buses

Publishers that never name their subscribers. Wildcard channels. A clean Observer relationship across eleven services. Everything about the shape is right. Yet if a subscriber is restarting when you publish, that event never happened. Decoupling and durability are independent properties, and the industry habit of calling both of them "event-driven" is what makes this expensive to discover in production.

Level Intermediate Stack Java 17 · Spring · Redis · AMQP Verified against production code

The claim under test

An estate publishes domain events onto Redis channels. Services subscribe by pattern. No service imports another service's classes. The architecture document says event-driven, and by one reasonable definition it is.

textthe asynchronous topology, in full
11 services · 0 brokers

channels     mission.*.areas          area create / update / delete
             mission.*.entities       entity position updates
             mission.*.geofencing     geofence breach notifications

publish      redisTemplate.convertAndSend(channel, payload)
subscribe    implements MessageListener   (Spring Data Redis)

guarantees   persistence  ✗      replay  ✗      consumer groups  ✗
             broker ack   ✗      DLQ     ✗      backpressure     ✗

The decoupling is real. The publisher genuinely does not know who is listening, which is the property most teams are actually buying when they reach for a bus. What it does not buy, and what the word "event-driven" quietly implies, is that the event will arrive. Fowler opened his 2017 write-up of an event-driven summit with the reason this argument keeps recurring:

The biggest outcome of the summit was recognizing that when people talk about "events", they actually mean some quite different things.

The pattern above is his event notification: "a system sends event messages to notify other systems of a change in its domain", and, the part that matters here, "the source system doesn't really care much about the response". That is a coherent, useful pattern. It is only a problem when the receiver treats the notification as the only copy of the fact.

What Redis pub/sub actually guarantees

Redis is unusually honest about this, and the sentence to internalise is theirs, not a critic's. It is in the delivery-semantics section of the pub/sub documentation:

Redis' Pub/Sub exhibits at-most-once message delivery semantics. […] If the subscriber is unable to handle the message (for example, due to an error or a network disconnect) the message is forever lost.

Read that mechanically rather than as a caveat. PUBLISH is a routing operation over sockets that are open at that instant. There is no store to write to, so there is nothing for a late subscriber to read; the documentation notes that "Pub/Sub has no relation to the key space": the channel is not a key, it is a fan-out table. Redis returns the number of clients it wrote to, which is a fan-out count and not a delivery receipt: it does not tell you whether any of those clients finished processing, and it cannot tell you about the subscriber that reconnects a second later.

The one guarantee that is offered is ordering: "Subscribers receive the messages in the order that the messages are published". That is worth naming, because ordering without delivery is a weaker property than it sounds. You get the surviving messages in order, with unobservable gaps where the rest used to be.

Decoupling and durability are orthogonal

A publisher that does not name its subscribers is decoupled. A message that survives a subscriber being down is durable. Nothing about the first produces the second, and every fire-and-forget bus scores perfectly on the first. This is why a clean Observer diagram is not evidence of a delivery guarantee, and why code review, which sees the diagram, keeps missing this.

That gives you the decision, and it is not "Redis or Kafka". It is: is this channel carrying a notification, or is it carrying the only copy of a fact?

Notification: the truth is elsewhere

The receiver reacts by reading: it fetches the current area from the owning service or the database. A dropped notification costs you latency, not data. The next notification, or a periodic reconcile, repairs the gap.

Transport of record: the message itself is the fact

The receiver's state is derived only from the messages it saw. A drop is permanent divergence with no signal. Enterprise Integration Patterns names the remedy:

"Use Guaranteed Delivery to make messages persistent so that they are not lost even if the messaging system crashes."

Delivery semantics, precisely

Three terms get used loosely and one of them is routinely oversold. Kafka's design documentation states them in a single line each, and the wording is worth taking literally. Note that the second says never lost, not delivered once.

  1. At-most-once

    "Messages may be lost but are never redelivered." The sender does not wait, the receiver does not confirm, and nothing retries. Redis pub/sub sits here permanently, not by configuration, but because there is no store that could serve a retry.

    Acceptable when the next message supersedes this one. Unacceptable the moment a receiver accumulates state from the stream.

  2. At-least-once

    "Messages are never lost but may be redelivered." The broker holds the message until a consumer acknowledges it; an unacknowledged message is redelivered after a timeout, a nack, or a consumer crash. This is what a durable queue or a log with committed offsets gives you, and it is the strongest guarantee a broker can offer on its own.

    The cost is duplicates, and they are not rare. Every consumer restart mid-batch produces them. That cost is transferable: make the consumer idempotent and duplicates stop mattering.

  3. Exactly-once, and why it is not a delivery guarantee

    Kafka's own definition is "Each message is processed once and only once", and note processed, not delivered. Delivery cannot be exactly-once across a network partition: the sender either retries an unacknowledged send (risking a duplicate) or does not (risking a loss), and it cannot distinguish "lost request" from "lost response".

    What real systems ship is at-least-once delivery plus idempotent consumption, usually called effectively-once. Kafka's idempotent producer removes duplicates on the produce path, and transactions give atomic read-process-write when the source and the sink are both Kafka. The instant your consumer writes to a relational database or calls a third-party API, you are back to at-least-once and the deduplication is yours to build.

Many systems claim to provide "exactly-once" delivery semantics, but it is important to read the fine print, because sometimes these claims are misleading …

Kafka also names its own default plainly: it "guarantees at-least-once delivery by default, and allows the user to implement at-most-once delivery by disabling retries on the producer and committing offsets in the consumer prior to processing a batch of messages". Committing before you process is the one-line difference between the two guarantees, and it is a configuration mistake people make by accident.

Four buses, eight properties

"Use Kafka" is not the answer to most of these problems, and it is worth seeing why: Redis Streams closes almost the whole gap without adding a second piece of infrastructure. Read the amber cells carefully, because they are where the marketing and the mechanism disagree.

PropertyRedis pub/subRedis StreamsRabbitMQKafka
Delivery ceiling

Delivery ceiling

The strongest delivery guarantee the transport can offer on its own, before the application adds retries or deduplication of its own.

At-most-once At-least-once At-least-once At-least-once
Persistence

Persistence

Whether a message is written to a store that outlives the instant it was sent, so it still exists if no one was listening.

None: written to connected sockets and discarded Append-only log held as a key, covered by RDB/AOF Durable queue + persistent message delivery mode Segmented log on disk, replicated across brokers
Replay

Replay

Whether a consumer can go back and read messages it already handled, or ones it missed while it was down.

None: no history exists XRANGE / read from any entry ID Acknowledged messages are gone Seek to any offset inside the retention window
Consumer groups

Consumer groups

Whether several instances of one service can share the work, each taking a portion of the messages, instead of every instance receiving all of them.

Every subscriber gets every message; no partitioning XGROUP, per-consumer pending entries list Competing consumers share one queue; a second logical group needs a second queue group.id, partitions assigned per member
Acknowledgement

Acknowledgement

Whether a consumer can tell the transport that one specific message was handled, so it is not delivered again.

None: the reply is a socket count XACK; unacked entries stay in the PEL basicAck / basicNack, per delivery A committed offset: a position, not a per-message ack
Dead letter

Dead letter

Where a message goes when the system decides it cannot or should not be delivered, instead of being retried forever or dropped silently.

Nothing exists to route Hand-rolled: read the delivery count from XPENDING, move to a second stream Broker-native x-dead-letter-exchange Client-side only (Spring's DeadLetterPublishingRecoverer); the broker has no DLQ concept
Ordering

Ordering

The scope inside which messages are guaranteed to arrive in the order they were sent. Almost always per channel or per partition, rarely across the whole system.

As published, per channel, with silent gaps where messages were dropped Per stream; entry IDs are monotonic Per queue, and broken by redelivery and by competing consumers Per partition, never per topic
Backpressure

Backpressure

What happens when a consumer is slower than the producer: whether the transport slows down, buffers, or discards the excess.

None: a subscriber that cannot keep up is disconnected by the server Consumer pulls, but the stream grows until it is trimmed Prefetch bounds in-flight deliveries per consumer Consumer pulls; lag is observable and bounded by retention
The dead-letter asymmetry, which most comparisons get backwards

RabbitMQ dead-letters in the broker: you declare an exchange on the queue and a rejected message is routed without the consumer's cooperation, so it works even when the consumer process is the thing that is broken. Kafka has no such concept: a "dead letter topic" is your client producing to another topic, which means a client that cannot start, cannot deserialize, or cannot reach the cluster also cannot dead-letter. Same words, materially different failure envelope.

The failure mode, drawn

The whole lesson is one comparison: what the system does with a message whose consumer is not there. On the left there is nowhere to put it. On the right there is.

Redis pub/sub · at most once Publisher RedisTemplate only mission.*.areas in memory · nothing stored Subscriber A connected, receives Subscriber B offline at publish time the message is forever lost no buffer · no retry · no replay · no signal Durable broker · at least once Publisher same code, new bus durable log · offsets retained 0 1 2 3 4 survives a consumer restart redeliver ack Consumer group was offline at publish time redelivered from offset 3 at-least-once · the consumer must dedupe
Left: the channel is a fan-out table over open sockets. Subscriber B was restarting, so there was no socket to write to and no store to fall back on. The event is gone, and neither side gets an error. Right: the entry stays in the log until it is acknowledged, so the consumer's absence costs latency instead of data. What it buys in exchange is duplicates: offset 3 may be delivered twice, which is why the consumer has to be idempotent.

What this looked like in production

Verified against a real 11-service estate

An eleven-service distributed estate used Redis pub/sub as its only asynchronous bus, with no Kafka, no RabbitMQ and no JMS anywhere in the estate. Channels followed a wildcard convention (mission.*.areas, mission.*.entities, mission.*.geofencing). Publishers held only a RedisTemplate and never referenced a subscriber, so the decoupling was a genuine Observer rather than decoupling by naming convention. Subscribers implemented Spring Data Redis MessageListener.

The choice was deliberate, and the context is worth stating. The estate was a proof of concept, designed to sit behind an API gateway once one existed, and the team was programming optimistically: every service was assumed to be running and reachable. Redis pub/sub was the stopgap that moved service-to-service traffic off synchronous HTTP and onto a publish and subscribe shape quickly, so that the business logic would already be written against a decoupled bus by the time a more robust one arrived. Nothing here crossed a client boundary; the channels carried traffic between services only.

That is a defensible trade for a proof of concept, and it is still an insufficient implementation. It holds only while every assumption around it holds. The moment one subscriber is restarting, the event is gone, and no part of the system reports it.

The team hit the durability wall and engineered around it above the bus, inside the gateway service:

  • a PendingEventStore, keyed mission:pending:{userId}:{eventId};
  • an unacked set per user, mission:unacked:{userId};
  • a dead-letter key, mission:dlq;
  • an EventRetryScheduler on a @Scheduled fixed delay;
  • a STOMP /ack endpoint the client called on receipt;
  • a session listener that flushed pending events on reconnect.

That is the right diagnosis and a real at-least-once layer: pending store, redelivery, acknowledgement and dead-lettering, assembled from primitives rather than bought. The boundary worth naming is where it stops.

It covered the server→client WebSocket hop only. The upstream service→Redis→gateway hop remained at-most-once: if the gateway was the process that restarted, the event was never in the PendingEventStore to be retried. End-to-end delivery was therefore still best-effort, and the guaranteed half was the half the gateway owned.

That unguaranteed first hop has a name and a standard remedy: it is the dual-write problem, and the fix is to make the publish part of the same transaction as the write. The Outbox Pattern is the page that closes exactly this gap. Read it next if this section describes your system.

Two smaller edges follow from the same shape. The retry scheduler was a single-node @Scheduled loop, so a second gateway replica would need leader election or a distributed lock before it could run safely. Otherwise every replica retries every pending event. And there was no schema registry or envelope versioning, so a payload change was a coordinated deploy rather than a rolling one.

The honest summary is a team that correctly identified a delivery-guarantee gap and closed the half it controlled. Naming the other half is not a criticism of that work; it is the next ticket.

gateway/…/PendingEventStore.java gateway/…/EventRetryScheduler.java mission:pending:{userId}:{eventId} mission:unacked:{userId} mission:dlq

Doing it properly

Three tabs, in the order the problem is usually met. The first is the fire-and-forget publisher: correct code, with one property worth reading carefully. The second moves the same event onto a durable queue with manual acknowledgement and a broker-routed dead letter. The third is the one that actually matters: at-least-once is only an improvement if applying an event twice is indistinguishable from applying it once.

javaevents/AreaEventPublisher.java
@Slf4j
@Service
@RequiredArgsConstructor
public class AreaEventPublisher {

    /** The only collaborator. Nothing in this class knows a subscriber exists. */
    private final RedisTemplate<String, Object> redisTemplate;

    public void publishAreaChanged(String missionId, AreaChanged event) {
        String channel = "mission." + missionId + ".areas";

        // convertAndSend returns the number of client sockets Redis wrote to.
        // That is a fan-out count, not a delivery receipt: it says nothing about
        // whether any consumer finished handling the event, and the subscriber
        // that reconnects one second from now is not counted and never will be.
        Long receivers = redisTemplate.convertAndSend(channel, event);

        log.debug("published {} to {}, {} socket(s)", event.eventId(), channel, receivers);
    }
}
Two details that decide whether this works

The id must come from the producer. If the consumer derives an id from broker metadata, a redelivery produces a different id and deduplication silently stops working. Stamp a UUID when the event is created, once, and carry it in the envelope.

The dedupe table needs a retention policy. processed_events grows at the rate of your event volume forever. Partition or purge it on a window comfortably longer than your maximum redelivery delay. Retention shorter than the redelivery window reintroduces exactly the duplicate you built the table to stop.

Dead-lettering deserves the same precision. It is not error handling; it is the decision to stop retrying and preserve the evidence, which is the pattern Enterprise Integration Patterns describes as what happens "when a messaging system determines that it cannot or should not deliver a message". A dead-letter queue with no alert on its depth is a silent data-loss channel with extra steps.

When Redis pub/sub is the right call

None of the above makes Redis pub/sub a bad tool. It is an excellent one, and the operational argument is strong: it is already running for caching and sessions, it adds no broker to provision or patch, and it delivers with in-memory latency because there is no disk in the path. That last property is not a shortcut around durability. It is the same decision, viewed from the other side.

When a channel crosses from the top group into the bottom group, the first move is usually not a new broker. Redis already ships the durable primitive:

A Redis stream is a data structure that acts like an append-only log but also implements several operations to overcome some of the limits of a typical append-only log.

Streams give you consumer groups, per-consumer pending entries, XACK, replay from any entry ID, and, as the pub/sub documentation itself points out, persistence, "both at-most-once as well as at-least-once delivery semantics". Migrating a channel from PUBLISH to XADD is a change of two call sites and a consumer loop. Adopting Kafka is a change of platform, staffing and on-call. Those are not comparable decisions, and reaching for the second when the first would do is how estates acquire infrastructure nobody can operate.

Canonical sources