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.
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();- Docker with Compose
- An Ecotone Enterprise licence key for the
non-blocking confirm,batchingandbatching + non-blockingscenarios. 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.
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 transportsrun.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, sqsPrefer 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 kafkaEcotone 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.
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 headroom —
shared_buffers=1GB,wal_buffers=64MB,max_wal_size=8GB, so inserts never stall on a checkpoint.synchronous_commitstays 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).
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.
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.
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.
benchmark.php— the timed scenarios, ~100 lines of actual logicoutbox-benchmark.php— the outbox relay scenariossrc/OutboxRelay.php— the outbox bootstrap, in both shapessrc/Providers.php— one Ecotone bootstrap per provider; the only difference between baseline and fast scenarios is one call:
$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.