Skip to content

Repository files navigation

Ecotone publishing throughput demo

How many messages per second can a single PHP process publish — with every delivery confirmed by the broker?

This demo times three ways of publishing the same 10,000 messages with Ecotone against Kafka, RabbitMQ (on both Enqueue AMQP transports), SQS (LocalStack), Redis and Postgres:

Scenario What happens
per-message $publisher->send() in a loop. One broker round-trip per message, each awaited before the next starts.
non-blocking confirm $publisher->publishDeferred() in a loop, every Future resolved at the end. Messages travel while the loop keeps running; confirmations are collected once.
batching + non-blocking One BatchMessage carrying all 10,000 entries, published as a provider-native batch, confirmations awaited once.

Every scenario awaits every delivery confirmation before the clock stops. Nothing here is fire-and-forget: a failed delivery surfaces as PublishingFailedException, pointing at the exact message that failed.

The two mechanisms

Ecotone's High Throughput Publishing is one switch that turns on every mechanism the provider can actually deliver:

Provider Batching Non blocking confirmation
Kafka producer lingering delivery reports awaited at scope end
RabbitMQ one publisher-confirms round trip (plus a single socket write on amqp-lib only — see below) confirms awaited at scope end
SQS batch send requests responses awaited at scope end
Postgres multi row insert not offered — the insert blocks
Redis single scripted round trip not offered — the round trip blocks

Redis and Postgres confirm the write in the reply to the write itself, so there is nothing to defer — their configuration takes no parameters, publishDeferred() throws, and they run a batching scenario instead. The signature tells you what the provider can do:

// Kafka, RabbitMQ, SQS — both mechanisms, each independently opt-out-able
KafkaPublisherConfiguration::createWithDefaults(topicName: $topic)
    ->withHighThroughputPublishing(
        batchPublishing: true,
        nonBlockingConfirmation: true,
        confirmationTimeoutInMilliseconds: 5000,
    );

// Redis, Postgres — batching is all that is on offer
DbalMessagePublisherConfiguration::create(MessagePublisher::class, $queue)
    ->withHighThroughputPublishing();

Requirements

  • Docker with Compose
  • An Ecotone Enterprise licence key for the non-blocking confirm, batching and batching + non-blocking scenarios. Without one, the demo still runs and measures the synchronous baseline. You can request a trial licence at ecotone.tech/pricing#trial to run the full comparison yourself.

Running

cp .env.example .env         # paste your licence key into ECOTONE_LICENCE_KEY
docker compose up -d
docker compose exec app composer install
./run.sh                     # all providers
./run.sh kafka               # or: sqs, redis, postgres, rabbitmq-ext, rabbitmq-lib
./run.sh rabbitmq            # shorthand for both AMQP transports

run.sh runs the benchmark with CLI opcache and the tracing JIT switched on, and with XDEBUG_MODE=off so the JIT is actually able to engage — a loaded Xdebug overrides zend_execute_ex(), which makes PHP refuse to enable JIT entirely. That one variable is worth about 3x on the PHP-heavy paths, so use run.sh. A plain docker compose exec app php benchmark.php runs without CLI opcache and without JIT, and will report substantially lower numbers than the tables below.

There is a second benchmark for the other half of an outbox setup — draining rows that are already committed in the database and relaying them to the target:

./run.sh outbox              # all targets
./run.sh outbox kafka        # or: memory, postgres, rabbitmq, redis, sqs

Prefer single-provider runs when you want peak numbers: in a full sequential run the later providers score 20–35% below what they reach in isolation.

Each run publishes 10,000 messages per scenario. Override it with BENCH_MESSAGES:

BENCH_MESSAGES=1000 ./run.sh kafka

RabbitMQ is measured twice

Ecotone writes an AMQP batch to the socket in a single go — batch_basic_publish() followed by publish_batch() — only when the connection comes from enqueue/amqp-lib. On enqueue/amqp-ext the writes stay per message; batching still collapses the run into a single pass through the messaging pipeline and a single publisher-confirms wait, which is where most of its batched gain comes from. Non-blocking confirmation works the same on both transports.

Because the batch path genuinely differs, the two transports are separate rows in the results rather than a configuration detail: rabbitmq-ext and rabbitmq-lib.

The counterintuitive part is that amqp-ext wins anyway, by roughly 1.8× on the batched path, despite writing each message individually: php-amqplib encodes the protocol in pure PHP, and that overhead costs more than the single socket write saves. Every scenario is faster on amqp-ext in absolute terms, which is why it stays the recommended transport.

amqp-lib does not even win on multipliers: 3.7× batched and 1.6× on non-blocking confirm, against 4.9× and 1.5× for ext in the table below. It also starts from a slower per-message baseline (9,776 against 13,754 msg/sec), so it is behind in every scenario in absolute terms. Judge the transports on msg/sec, not on their speed-ups.

Infrastructure tuning

The compose file is tuned for throughput without weakening any confirmation:

  • Broker storage on tmpfs (Kafka log segments, RabbitMQ data, Postgres data directory) — removes laptop-disk fsync jitter. Delete the tmpfs: lines to measure against real disks.
  • Postgres WAL headroomshared_buffers=1GB, wal_buffers=64MB, max_wal_size=8GB, so inserts never stall on a checkpoint. synchronous_commit stays on: the INSERT confirmation keeps meaning "durably committed".
  • RabbitMQ without the management plugin — its stats collection taxes the hot path.
  • Kafka with a fixed 1G heap — no JVM resizing mid-run.
  • LocalStack with logging off the hot path (LS_LOG=warning).

Example results

Recorded on a laptop — 24 cores, RAM-backed broker storage, CLI opcache and the tracing JIT active. 10,000 messages per run, each scenario the median of 5 iterations. Each provider's row-set is its best isolated single-provider run out of four passes on a warm stack — one real run per provider, so the multipliers stay internally consistent. Recorded 2026-08-10 (raw output lands in results/, which is gitignored — re-run to regenerate it).

| Provider            | Scenario                |      msg/sec |   Speed-up |
|---------------------|-------------------------|--------------|------------|
| Kafka               | per-message             |        9,053 |        — |
| Kafka               | non-blocking confirm    |       23,421 |       2.6x |
| Kafka               | batching + non-blocking |      157,266 |      17.4x |
| RabbitMQ (amqp-ext) | per-message             |       13,754 |        — |
| RabbitMQ (amqp-ext) | non-blocking confirm    |       20,261 |       1.5x |
| RabbitMQ (amqp-ext) | batching + non-blocking |       67,143 |       4.9x |
| RabbitMQ (amqp-lib) | per-message             |        9,776 |        — |
| RabbitMQ (amqp-lib) | non-blocking confirm    |       15,252 |       1.6x |
| RabbitMQ (amqp-lib) | batching + non-blocking |       36,294 |       3.7x |
| SQS (LocalStack)    | per-message             |          660 |        — |
| SQS (LocalStack)    | non-blocking confirm    |        1,143 |       1.7x |
| SQS (LocalStack)    | batching + non-blocking |        7,549 |      11.4x |
| Redis               | per-message             |       32,184 |        — |
| Redis               | batching                |      115,638 |       3.6x |
| Postgres            | per-message             |       16,421 |        — |
| Postgres            | batching                |       53,711 |       3.3x |

Read the batched numbers, not the multipliers — and read them as a band, not a point. Min–max across every isolated 10,000-message run recorded in one session:

Provider Batched band Best
Kafka 98,962–157,266 157,266 msg/sec — 10,000 confirmed in 64ms
Redis 111,374–115,638 115,638 msg/sec — 10,000 confirmed in 86ms
RabbitMQ (amqp-ext) 62,854–67,143 67,143 msg/sec — 10,000 confirmed in 149ms
Postgres 48,887–53,711 53,711 msg/sec
RabbitMQ (amqp-lib) 31,222–36,294 36,294 msg/sec
SQS (LocalStack) 7,190–7,549 7,549 msg/sec

Most of the batched bands are tight — Redis 4%, SQS 5%, amqp-ext 6%, Postgres 9%. Kafka's is the widest at 37%; its best isolated pass reached 157,266 msg/sec, and a second pass landed within 0.05% of it. Freshly started containers score lower for a run or two, and a full sequential ./run.sh puts every provider near the bottom of its band, so warm the stack and use single-provider runs when you want peaks.

The synchronous baselines are the noisy ones — they swing several-fold between runs, because they are pure round-trip latency and inherit every wobble of broker state; the batched path amortizes it away. Read the non-blocking confirm column as a statement about where each transport spends its time: Kafka gains the most (2.6x), because producing without flushing lets it keep working while delivery reports drain behind it, while the AMQP transports and SQS gain 1.5x to 1.7x — each message is still its own write there, so only the confirmation wait is coalesced, never the write itself.

Two more honest caveats:

  • SQS here is LocalStack on localhost. Against real AWS the per-message baseline pays a full HTTPS request per message, so the relative win grows — but do not read the absolute msg/sec as AWS numbers.
  • Tuning the infrastructure shrinks the multiplier while leaving the batched throughput largely untouched — batching buys you the most exactly where the infrastructure is least forgiving. The specific default-config Postgres figures that used to sit here were measured before the JIT problem above was found, so they have been removed rather than restated; re-measure them if you want them.

Draining the outbox

benchmark.php measures the write into the broker. outbox-benchmark.php measures what happens after it, when the messages are already committed in the database and something has to relay them onward.

Scenario What happens
message by message The outbox is a plain Combined Message Channel: consumed one message per poll cycle, each one deserialized and republished on its own. This is what runs without a licence.
batched (100 per cycle) OutboxForwardingMessageChannel: a publishing endpoint claims up to 100 rows straight from the database (FOR UPDATE SKIP LOCKED on PostgreSQL), groups them by target and hands over whole batches. Rows travel in wire format — nothing is deserialized on the way through.
single batch The same, with the batch size raised to the whole backlog: one claim, one publish, one transaction.

The relay is the whole definition — one Dbal outbox, one target, and High Throughput Publishing on the target so a claimed batch becomes one native broker batch:

#[ServiceContext]
public function channels(): array
{
    return [
        OutboxForwardingMessageChannel::create('orders', 'outbox', 'orderProcessing')
            ->withMaxForwardingBatchSize(100),
        KafkaMessageChannelBuilder::create('orderProcessing')
            ->withHighThroughputPublishing(),
    ];
}

Delivery stays at-least-once in every scenario: a failed delivery is released for redelivery rather than duplicating what already went out, and a connection failure rolls the whole cycle back for a clean retry.

Example results — 10,000 messages waiting in the outbox

Same laptop and same tuned stack as above. The batched scenarios are the median of 3 runs; message by message is a single run, because it takes nearly seven minutes:

| Target                              | Scenario                |      msg/sec | Total time |
|-------------------------------------|-------------------------|--------------|------------|
| In-memory target (relay cost only)  | message by message      |          509 |     19.64s |
| In-memory target (relay cost only)  | batched (100 per cycle) |       27,223 |      0.37s |
| In-memory target (relay cost only)  | single batch            |       47,171 |      0.21s |
| Postgres target                     | batched (100 per cycle) |       24,164 |      0.41s |
| Postgres target                     | single batch            |       33,427 |      0.30s |
| RabbitMQ target                     | batched (100 per cycle) |       25,418 |      0.39s |
| RabbitMQ target                     | single batch            |       37,808 |      0.26s |
| Kafka target                        | batched (100 per cycle) |       31,461 |      0.32s |
| Kafka target                        | single batch            |       50,052 |      0.20s |
| Redis target                        | batched (100 per cycle) |       34,093 |      0.29s |
| Redis target                        | single batch            |       43,063 |      0.23s |
| SQS target (LocalStack)             | batched (100 per cycle) |        5,293 |      1.89s |
| SQS target (LocalStack)             | single batch            |        6,794 |      1.47s |

Three things this run says:

  • The relay was the bottleneck, not the broker. Message by message, 10,000 rows take 19.6 seconds to leave the outbox — about 2ms each, spent on poll cycles and per-message transactions rather than on the target. Every broker receives the same 10,000 rows in under a third of a second once the relay claims them in one batch.
  • Which target you relay into barely matters. The single-batch column spans 0.20s (Kafka) to 0.30s (Postgres) for everything except SQS, whose API caps a batch request at 10 entries, so 10,000 messages are still 1,000 HTTP round trips. Kafka even edges the in-memory target, which is the clearest way to say the broker is not what you are paying for.
  • Batch size is the smaller knob. One hundred cycles of 100 rows cost ~0.37s; a single claim of 10,000 costs ~0.21s — about 1.7x, at the price of holding the whole batch in memory inside one transaction. Getting off the per-message path is what matters; the exact batch size much less so.

The baseline is measured against the in-memory target on purpose: it isolates the relay's own cost instead of crediting it to whichever broker sits behind it. Set BENCH_OUTBOX_BASELINE=1 to measure it against every selected target too.

What to look at

$publisherConfiguration->withHighThroughputPublishing();

The same call configures a message channel, which is what most applications actually wire up:

KafkaMessageChannelBuilder::create($channelName)
    ->withHighThroughputPublishing();

Handler code does not change. Messages published from a command handler are gathered, sent as one batch, and all confirmations are awaited before the transaction commits — a failed delivery still fails the business operation or is routed to the error channel, per failed message, not per batch.

About

Demo of publishing using non-blocking, batched approach

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages