Goodbye Exchanges: How Pulsar Replaces Fanout, Routing, and Headers

TL;DR:
Pulsar does away with RabbitMQ’s separate Exchange object – but it still lets you implement all the same messaging patterns (fanout broadcasts, selective routing, and even content-based routing) using topics, subscriptions, and a bit of application logic. In this post, we explain how to achieve RabbitMQ’s exchange types in Pulsar’s world. Fanout exchange? Just use one topic with multiple subscriptions (each subscription will get a copy of every message). Direct or topic exchanges (routing keys)? Use separate topics or metadata keys to route messages to where they need to go. Headers exchange (content-based routing)? Pulsar doesn’t route on message properties by itself, but we’ll show how you can use Pulsar Functions or client-side filtering to accomplish the same goal. By the end, you’ll see that although Pulsar’s model is simpler (just producers and topics), it’s flexible enough to replace the complex exchange bindings of RabbitMQ.
Recap: What Exchanges Do (RabbitMQ Refresher)
In RabbitMQ, an exchange is the routing intermediary that takes messages from producers and decides which queue(s) to send them to based on some rules. RabbitMQ has several built-in exchange types:
- Direct exchange: routes messages to queues whose binding key exactly matches the message’s routing key. E.g., send with routing key
"us-west"
, goes to the queue bound with"us-west"
. - Fanout exchange: routes messages to all bound queues, ignoring any routing key (broadcast).
- Topic exchange: routes messages based on wildcard pattern matching of the routing key against the queue binding patterns (e.g.,
"orders.*"
might catch"orders.new"
). - Headers exchange: routes based on message header values instead of a routing key (matching on a set of header key-value pairs).
These allow RabbitMQ to do complex in-broker routing logic. JMS, on the other hand, doesn’t have an explicit exchange concept; JMS Topics broadcast to all subscribers by default, and JMS Queue is point-to-point. Some JMS brokers offer filtering via message selectors, which let a consumer ask for only messages with certain properties, effectively offloading filtering logic to the broker.
Now, Apache Pulsar doesn’t use exchanges at all – producers send messages directly to a topic. So how can we replicate what exchanges do? The key is to remember that Pulsar topics are cheap and flexible, and consumers have the power to choose what they subscribe to (including using wildcard topic names). Also, Pulsar messages can carry a key and properties which applications can leverage for routing decisions.
Let’s go through each pattern:
Fanout (Broadcast) – One Message to All Subscribers
RabbitMQ fanout exchange: Producer sends to an exchange of type “fanout”, which delivers the message to every queue bound to that exchange. Every consumer on those queues gets a copy of the message.
Pulsar approach: Use a single topic and give each subscribing group its own subscription name. As we saw in the first post, if two different subscriptions exist on the same topic, each subscription will receive every message. This naturally implements fanout. The producer just publishes to the topic normally – no special routing logic needed. Pulsar will ensure that Subscription A, Subscription B, etc., each get the message.
Example: Imagine you have an event that multiple services need to know about (like a “user.signup” event that both an email service and an analytics service should process). In RabbitMQ you might use a fanout exchange “user-events” bound to two queues (“emailQ” and “analyticsQ”). In Pulsar, you simply define a topic, say user-events
, and have the email service subscribe with subscription name “email-service-sub” and the analytics service with “analytics-service-sub”. When a new user event is published to user-events
topic, both subscriptions will get it (each service’s consumer gets its own copy). Under the hood, Pulsar retained the message until both subscriptions acknowledged it.
This pattern is straightforward: one topic, multiple subscriptions = broadcast. No exchange object or binding configuration required. When a new service needs the data, you just give it a new subscription name on that topic and it will start receiving all new messages from that point forward.
One thing to note: By default, if a new subscription is created, it begins at the latest message (i.e., it won’t see old messages sent before it existed). You can override this by specifying subscription options (like starting at earliest or a specific timestamp). But the typical behavior in pub-sub is that new subscribers only get new messages from the time they subscribe.
Direct Routing – Pointing Messages to Specific Consumers or Queues
RabbitMQ direct exchange: You have multiple routing keys and want messages to go only to the queue that is bound for that key. For example, in a stock trading system, you might tag price updates with a stock symbol and deliver each update only to the queue (service) handling that symbol.
Pulsar approach: In Pulsar, producers choose the topic to send to. So the simplest way to do what a direct exchange does is to use separate topics in the first place. For instance, instead of one exchange “prices” with routing keys for each symbol, you might have topics named prices.AAPL, prices.GOOG
, etc. The producer for Apple stock updates simply sends to prices.AAPL
topic, the Google producer to prices.GOOG
, and so on. Consumers subscribe to the topic(s) they care about.
This might seem like moving complexity to the producer (since it must decide the topic), but remember in RabbitMQ the producer had to know the routing key and exchange anyway – not much different. In Pulsar, “topic” effectively replaces “exchange+routingKey” combination.
But what if you truly want to send to one topic and have the broker decide which subscriber should get it based on some key? Pulsar doesn’t have an exchange to do that routing decision for multiple subscriptions – typically you’d just use separate topics. However, Pulsar does allow consumers to use a topics pattern to subscribe to multiple topics in one go. This is analogous to RabbitMQ’s topic exchange wildcards but done on the consumer side. Let’s cover that next.
Topic Pattern (Wildcard) – Similar to Topic Exchanges
RabbitMQ topic exchange: Allows wildcard matching of routing keys. For example, route messages with routing key “error.crITICAL” to queues bound with pattern “error.*” or “#.CRITICAL”.
Pulsar approach: Instead of having one topic and multiple wildcard bindings, Pulsar encourages using the topic naming to categorize messages, and then consumers can subscribe using a regex pattern that matches multiple topics. Pulsar clients support subscribing to a regex pattern which will include all topics that match (and even auto-subscribe to new ones that match in the future).
For example, you could name topics by region: logs.us-west, logs.us-east, logs.eu
, etc. A consumer can subscribe with a pattern logs.*
to get all regions, or maybe logs.us-*
to get just U.S. logs. This is powerful because it pushes the categorization to the topic namespace rather than a separate exchange layer.
How to use: In the Java client, you might do:

This one consumer will receive messages from all topics whose name starts with logs
. (in that namespace). If new topics like logs.apac
are created later and match the regex, the client will automatically pick them up.
This covers many “dynamic routing” cases. If you’re using JMS, think of it like having multiple Topics and using a wildcard to subscribe to many at once (JMS itself doesn’t have regex topic subscribe, but some brokers do). In RabbitMQ terms, we’ve sort of inlined the topic exchange’s logic into the topic naming scheme and consumer’s pattern.
Why use multiple topics instead of one with selective routing? Two main reasons:
- Isolation & scaling: In Pulsar (and Kafka as well), topics are the unit of parallelism and storage. Keeping disparate streams separate as different topics can be beneficial for performance and clarity. If you had one mega-topic with many different types of messages and you rely on filtering, you might be doing extra work reading messages that you then ignore. Multiple topics let you only consume what you need and allow the broker to manage them independently.
- Simplicity of broker design: By not having complex server-side routing rules, Pulsar stays simpler and focuses on throughput and storage. The trade-off is that the application (or at least the naming convention) makes routing decisions. It might feel like a step backward if you enjoyed RabbitMQ’s built-in routing logic, but it actually aligns with how modern log-based systems (Kafka, Pulsar, etc.) operate – they favor partitioning streams by topic and key rather than inside-broker filtering.
Headers or Content-Based Routing – Achieving it in Pulsar
RabbitMQ headers exchange: You can route messages based on arbitrary header fields (like "department: finance"
or "priority: high"
), by matching those headers in the exchange routing logic. JMS’s equivalent is message selectors, where a consumer asks the broker, “Only give me messages where department='finance'
,” and the broker filters them.
Pulsar approach: Pulsar brokers do not examine message properties or content for routing. All consumers of a topic see all messages (unless it’s a shared subscription where broker is just load-balancing them out). So to do content-based filtering/routing, you have two main options:
- Consumer-side filtering: A consumer can subscribe to the topic and then in your consumer code, check message properties or content and decide to process or skip. Unwanted messages can simply be acknowledged (to discard them) or not acknowledged (which would eventually dead-letter them if using DLQ, as we’ll cover in Post 5). This is akin to JMS selectors, except Pulsar doesn’t have a built-in selector syntax – you implement the “if” logic yourself in code.
- Pulsar Functions (server-side): Pulsar provides Pulsar Functions, a lightweight server-side compute framework, which can be used to do content-based routing on the broker side. Essentially, you write a small function (in Java or Python, etc.) that triggers on each message of an input topic and then publishes it to one or more output topics based on content. This is exactly how you’d implement a headers exchange in Pulsar terms: one input topic, and the function will examine message properties or payload and then forward it to specific topic(s). We’ll cover Pulsar Functions in detail in Post 8, but to illustrate, consider this example:
Suppose you want to route incoming support tickets to different topics by urgency: normal vs. urgent. In RabbitMQ, you might attach a header "priority":"urgent"
and have a headers exchange send urgent ones to an urgentTickets
queue. In Pulsar, you could simply have an input topic tickets
and two output topics tickets.normal
and tickets.urgent
. Then deploy a Pulsar Function that does:

This function subscribes to tickets
(input) and republishes to the appropriate topic. Consumers can then just subscribe to tickets.urgent
or tickets.normal.
Yes, this means an extra step (the function) – but this is effectively how you’d implement content routing without burdening the core broker with property filtering logic. Pulsar Functions are integrated and can run on the Pulsar cluster, making this fairly seamless (we’ll see more later).
If writing a Pulsar Function is overkill for your scenario, you can also design producers to send messages to different topics directly if they know where things should go. Often, adding a bit of routing logic in producers or a dedicated router service keeps Pulsar’s usage clear: each topic has a defined purpose or category of message.
Real-world tip: Many Pulsar deployments use a combination of naming conventions and simple processing functions to replace what was done with RabbitMQ’s exchanges. For example, StreamNative’s documentation suggests using separate topics or Pulsar Functions for scenarios that Rabbit would solve with a headers exchange or complex bindings. While Pulsar doesn’t natively match on message content, it’s built to work with these extension points (functions or connectors) to cover that gap.
No Exchanges, But Not Less Powerful
At first, coming from RabbitMQ, it may feel like Pulsar lacks a feature because there’s no direct analog of an exchange. However, you’ll find that:
- Fanout is trivial in Pulsar: just multiple subscriptions. No need to declare an exchange and bind queues – any consumer with a new subscription name automatically creates a “tap” on the topic and gets everything.
- Selective routing can be achieved by topic design and consumer patterns, which, while requiring more upfront design, results in a more type-safe or schema-like separation of streams (instead of a single exchange carrying many message types).
- Content-based routing isn’t built into the broker core, but Pulsar Functions provide that capability in a decoupled way, and consumers can always self-filter if needed.
Another advantage of not having exchanges is simplification of the system’s moving parts. You don’t have to manage exchange durability, binding lifecycles, etc. The trade-off is that you, the developer, decide topic organization that suits your routing needs. Pulsar’s philosophy is to keep the messaging model simpler (just pub-sub streams) and push specialized routing logic to the edges or to its lightweight compute layer.
To make it concrete, let’s compare side-by-side a scenario in RabbitMQ vs Pulsar:
Scenario: A producer emits events of types A, B, C into RabbitMQ. Consumers for A, B, C should get only their respective events.
- RabbitMQ solution: Producer sends all events to an exchange “events_exch” with routing key “A”, “B”, or “C” per event type. Three queues (QueueA, QueueB, QueueC) are bound to the exchange with binding keys “A”, “B”, “C” respectively. Each consumer group listens to one queue. RabbitMQ exchange ensures A events go only to QueueA, etc.
- Pulsar solution: Create three topics:
events-A
,events-B
,events-C
. Producer sends each event to the topic corresponding to its type (this logic can be in producer code or perhaps a Pulsar Function that reads a combined topic and splits them – but simpler is producer knows where to send). Consumers just subscribe to the one topic they need (with their subscription name). No other filtering needed – they only get the type they subscribed to. If having separate topics for each type seems heavy, one could also send all events to a singleevents
topic, and have consumers filter messages of type A vs B vs C by examining a property. But splitting by topic usually scales better and is clearer.
The Pulsar approach treats topic names as the routing key namespace. And because topics are not expensive (Pulsar can handle many topics – hundreds or thousands easily), this is usually fine. In RabbitMQ, having tons of exchanges or queues can become hard to manage; in Pulsar, splitting streams by topic is normal practice.
Headers to Properties Mapping
For JMS users, Pulsar messages support properties (key-value pairs) on messages that are analogous to JMS message properties or RabbitMQ headers. You set them in the producer and retrieve on consumer. Pulsar doesn’t do anything with these properties by itself (no broker-side filter), but they are very useful in Pulsar Functions or consumer logic. So you could carry a header like department:finance
in a Pulsar message property and either have a specific topic for finance messages or a function that looks at that property to route the message accordingly.
One more related Pulsar feature: Message Key hashing and Key_Shared subscription. This is more about ordering and load-balancing than routing to different topics, but worth a mention. Pulsar allows a producer to tag a message with a key
, and if the topic is partitioned, that key will consistently hash to the same partition. Consumers with Key_Shared subscription ensure all messages with the same key go to the same consumer. This is not exactly like routing keys to different queues – it’s more for ordering guarantees across consumers – but it highlights that Pulsar does pay attention to the message key for partition distribution. We’ll talk about this in Post 7 (Ordering Guarantees). Just note that “routing key” in Rabbit isn’t the same as Pulsar’s “message key”: Rabbit’s routing key chooses which queue, Pulsar’s key chooses which partition/consumer, not a different topic.
Key Takeaways
- No Exchange Object in Pulsar: Producers send directly to topics. This simplifies the topology – you don’t configure fanout or direct exchanges – but you use topics and subscriptions creatively to get the same results.
- Fanout = multiple subscriptions: To broadcast a message, have multiple subscriptions on a topic. Pulsar will deliver each message to each subscription’s backlog. This covers RabbitMQ’s fanout exchange and JMS topic use cases easily.
- Direct/Topic routing = use topic names and patterns: Instead of one exchange with many routing keys, you might create multiple Pulsar topics (perhaps sharing a naming convention). Consumers can use regex subscription patterns to subscribe to multiple topics if needed (like topic wildcards). Essentially, designing a good topic naming scheme replaces a lot of what exchange bindings do.
- No built-in content-based routing: Pulsar brokers don’t filter by headers/properties like RabbitMQ’s headers exchange or JMS selectors. To implement that, you can use Pulsar Functions (to route messages based on content to different topics) or simply subscribe to the whole topic and filter in your code. Pulsar Functions provide an in-cluster way to emulate content-based routing logic.
- Simplicity and flexibility: Pulsar’s approach might require a bit more thinking about topic taxonomy upfront, but it also means the messaging layer is straightforward and high-performance. You won’t accidentally create an exchange binding loop or have to debug complex routing rules – the routing is mostly by topic design or small functions that you control.
In the next post, we’ll dive into message delivery guarantees in Pulsar: how it achieves at-least-once delivery, how acknowledgments work, and what it means to get “effectively-once” or “exactly-once” processing. If you’re curious how Pulsar handles reliability compared to JMS acknowledgments or RabbitMQ’s acknowledges and redeliveries, read on!
Newsletter
Our strategies and tactics delivered right to your inbox