Controlling load instead of bending systems: Rate limiting with Apache Pulsar

Controlling load instead of bending systems: Rate limiting with Apache Pulsar
By Matthias Petermann / on 23.12.2025

Why is my ERP suddenly becoming a bottleneck?

In integration projects with Apache Pulsar, sooner or later an uncomfortable question arises:

Why is a single external system enough to bring an otherwise stable ERP to its knees?

The basic scenario is usually simple. An ERP system consumes a topic through which operational transactions are triggered. In normal operation, these messages come from internal workflows - controlled, predictable, well-sized.

Problems only arise when external systems use the same topic, for example for:

  • Correction postings
  • Bulk imports
  • subsequent data cleansing

The ERP itself often does not have a strong backlog or prioritization concept. The result: As soon as an external load arrives without braking, the system is overwhelmed.

🚨 Typical symptom
The ERP is not “broken”, it is simply not built for it to absorb uncontrolled load from foreign systems.

Why an ERP ticket misses the real problem

The classic solution is quickly outlined:

  • Open a ticket with the ERP manufacturer
  • Describe the load scenario
  • hope for improvement

Even if this were successful, structural questions remain unanswered:

  • Are external systems allowed to intervene unfiltered in internal process chains?
  • Can we even distinguish between internal and external use?
  • Do we have control and observation options?
⚠️ Architecture trap
More robustness in ERP improves symptoms, But it doesn’t change the lack of governance on the integration layer.

Change of perspective: Integration as an infrastructure issue

Apache Pulsar is more than a transport mechanism. Through namespaces, ACLs and Pulsar Functions, Pulsar can be used as an active data hub.

The central idea of ​​this article is:

If load is problematic, it needs to be checked before the ERP - not in the ERP.


Test setup: Make the problem reproducible

This section describes the complete test setup so that the scenario is reproducible 1:1. No assumptions are deliberately made about existing infrastructure.

ℹ️ Goal of the test setup
The test setup is not used to measure performance, but the qualitative observation of load behavior, Fairness and controllability of data flows.

1. Start Pulsar locally

A single local Pulsar broker in standalone mode is used for testing. This means that Broker, BookKeeper and ZooKeeper are bundled in one process.

podman run -it --rm \
  -p 6650:6650 \
  -p 8080:8080 \
  docker.io/apachepulsar/pulsar:4.0.8 \
  bin/pulsar standalone
  • Port 6650: Pulsar Binary Protocol (Clients)
  • Port 8080: Admin API, Functions Worker
⚠️ Standalone mode
Standalone mode is not highly available and does not form a cluster. However, this is sufficient for the behavior examined here.

2. Create ERP namespace

After starting, we switch to the Pulsar shell:

pulsar-shell

First, the namespace for the ERP is created:

admin namespaces create public/erp

3. Definition of the topic

A topic is used in the test:

  • ERP input topic (internal)
    persistent://public/erp/command

Topics do not have to be created explicitly in Pulsar - they are created implicitly the first time they are accessed.


4. Simulation of the ERP consumer

The ERP is simulated by a simple Pulsar CLI consumer that outputs all received messages to STDOUT.

export PULSAR_URL=pulsar://127.0.0.1:6650

pulsar-cli consumer \
  -t persistent://public/erp/command \
  -s erp
  • Subscription name: erp
  • Subscription type: default (Shared)

Every message received is output immediately.

ℹ️ Why CLI instead of application?
The Pulsar-CLI behaves exactly like a normal client, but dispenses with any additional logic. This makes broker behavior clearly visible.

5. Internal producer (normal operation)

The internal producer simulates regular ERP workflows with low, constant load.

seq 1 10000000 | while read i; do
  echo "erp-client-message-$i" \
    | pulsar-cli producer \
        -t persistent://public/erp/command
  sleep 5
done

Characteristics:

  • one message every 5 seconds
  • constant load
  • no burst phases

The consumer receives accordingly:

erp-client-message-1
erp-client-message-2
erp-client-message-3

6. External producer (incident)

The external producer simulates a third-party system that generates messages unhindered.

seq 1 10000000 | while read i; do
  echo "external-client-message-$i" \
    | pulsar-cli producer \
        -t persistent://public/erp/command
done

Characteristics:

  • no breaks
  • maximum sending speed
  • no consideration for the consumer

The result for the ERP consumer:

external-client-message-433
erp-client-message-9
external-client-message-434
external-client-message-435
external-client-message-436
external-client-message-437

Internal messages are almost lost in the flow of external load.

💡 observation
Pulsar guarantees order per producer, but no fairness between producers.

Dispatch rate as a control experiment

For comparison, a dispatch limit is set directly on the ERP topic:

admin topics set-dispatch-rate \
  persistent://public/erp/command \
  --dispatch-rate-period=1 \
  --msg-dispatch-rate=1

The consumer now only receives one message per second - regardless of the source.

❌ Result
The limit seems technically correct, but affects internal and external loads equally. Prioritization is completely lost. This approach is therefore considered unsuitable.

The limit is then removed again:

admin topics set-dispatch-rate \
  persistent://public/erp/command \
  --dispatch-rate-period=1 \
  --msg-dispatch-rate=-1

Architectural decision: separation of input channels

Basic idea: External producers are controlled by Pulsar, not by the ERP

External systems publish their messages in their own Pulsar topic (persistent://public/external/command) and not directly into the ERP topic. This topic is consumed exclusively by a Pulsar Function, who is the only authorized producer to write to the internal ERP topic. Rate Limiting is applied on the Dispatch of the Function and thus limits exactly the message rate, with which external events reach the ERP.


A dedicated namespace for the external system

We introduce a second namespace:

admin namespaces create public/external

This creates two explicit channels:

Origin Topic
Internal persistent://public/erp/command
External persistent://public/external/command
🤔 Governance through structure
Separation through namespaces makes usage visible, controllable and enforceable – solely through ACLs.
ℹ️ Why identical topic names?
Both topics are deliberately named command. The distinction is made exclusively via the namespace. So the semantic meaning remains identical, while the governance structure becomes explicit.

Routing with a Pulsar Function

External messages should continue to reach the ERP - but in a controlled manner. To do this, we use a pass-through pulsar function that forwards messages unchanged.

public final class Copy implements Function<byte[], byte[]> {
    @Override
    public byte[] process(byte[] input, Context context) {
        if (input == null || input.length == 0) {
            return null;
        }
        return input;
    }
}

Deployment:

admin functions create \
  --tenant public \
  --namespace external \
  --name route.copy \
  --jar /abs/path/route-copy.jar \
  --classname net.d2ux.stream.generic.route.Copy \
  --inputs persistent://public/external/command \
  --output persistent://public/erp/command
ℹ️ Important effect
The function is a consumer of the external topic. Dispatch Rate therefore works before the ERP.
ℹ️ Pulsar Functions source code

The full source code used in this article and other exemplary Pulsar Functions is located in the following repository:

https://forge.ext.d2ux.net/OpenLab/pulsar-stream-functions

The examples are kept minimal and show typical usage patterns such as routing, Filtering and controlled forwarding of messages.


Rate limiting where it makes sense

Now we set the limit exclusively on the external topic:

admin topics set-dispatch-rate \
  persistent://public/external/command \
  --dispatch-rate-period=5 \
  --msg-dispatch-rate=1

Result:

  • internal messages run unchecked
  • external load is metered
  • the ERP remains stable
external-client-message-433
erp-client-message-9
external-client-message-434
erp-client-message-10
external-client-message-435
erp-client-message-11
external-client-message-436
erp-client-message-12
external-client-message-437
✅ Architectural gain
No code changed in ERP. No manufacturer dependency. Full control at the infrastructure level.

Additional benefit: observability

Pulsar Functions provide metrics:

pulsar_function_received_total{tenant="public",namespace="external",name="route.copy"} 2919
ℹ️ visibility
We see exactly how much external load actually arrives – and when.

Conclusion

The approach shown illustrates the added value that an architectural change of perspective can offer. Instead of addressing overload situations exclusively at the application level, the integration layer itself becomes the control location.

Apache Pulsar makes it possible to explicitly structure data streams, load behavior and access paths.
By separating internal and external channels and targeted rate limiting, external influences can be controlled without changing existing core systems.

The focus of this article was deliberately on the load and runtime behavior of the described architecture. However, in productive environments, the mechanisms presented must be supplemented by appropriate security measures. In particular, this includes clearly defined ACLs at the namespace level to specify which systems are allowed to publish or consume topics.

Only the interaction of structure, governance and security turns a technical solution into a viable integration architecture.

🤔 Note
Not every overload is an application problem.
It is often an indication of a lack of control in the architecture.

Image credit: The Pulsar logo is a registered trademark of the Apache Software Foundation. Use in editorial reporting in accordance with official guidelines.