Tandem

Performance

Measured performance

On a 2 vCPU / 8 GB cloud VM shared with PostgreSQL, Kafka and the load driver, Tandem delivers COMMIT → ack at a median of 13 ms and a p99 of 87 ms while carrying 600 events a second, and sustains 1200 to 1400 events/s.

Zero lost events and zero ordering violations in every scenario, at every rate — including the rates the host could not keep up with.

p50 13 ms

COMMIT → ack at 600 events/s, mean of three runs. p95 46 ms, p99 87 ms; the p99.9 is the host’s own tail and ranged 146 to 363 ms.

1200–1400 events/s

Highest rate delivered with the backlog flat, across seven searches in one hour on two cores shared with the database and the broker.

0 lost, 0 reordered

Across all seven scenarios — saturation past the ceiling, a relay killed mid-flight, an undeliverable event, three instances with one killed outright.

Latency

Delivery time at 600 events/s

The clock starts when the producing transaction commits and stops when Kafka acknowledges the publish, both read on the same machine — which is why the database and the broker share this host.

COMMIT to ack latency at 600 events per second: p50 12.6, p95 45.9, p99 87.0 and p99.9 231.6 milliseconds, with the spread between three runs shown as a whisker.
600 events/s is around half of this host’s ceiling, so the relay is not idling at these figures. A row written into a bucket that has been quiet longer waits for the poll interval instead, up to 100 ms.

Throughput

Past the ceiling nothing is lost

Up to the ceiling the relay delivers one event for every event offered and the backlog stays flat — a handful of rows pending at any moment, whatever the rate. Past it nothing fails and nothing is dropped: the excess accumulates in the outbox and drains once the offered rate falls back.

Delivered throughput against offered rate. Delivery keeps pace one for one up to 1250 events per second; past that the relay stays at its ceiling and the excess accumulates as backlog.
Every rate up to 1250 events/s held its backlog flat in this run; 1300 and above did not. A second run of the same shape bracketed its ceiling at 1200.

What sets the limit

On this host the ceiling is the cores, not the disk

What runs out at the ceiling is CPU: it reaches 94% of both cores while disk utilisation sits under 1% and the relay waits on I/O for a tenth of a percent of its time. The bottleneck here is computation — encoding, publishing and polling — not the rate at which the database writes to disk.

CPU and disk utilisation by offered rate. CPU rises with the rate to 94 percent where the ceiling sits and 94 percent at the rate that failed, while disk utilisation stays below 10 percent throughout.
At the rates the relay could not hold, CPU is exhausted just the same — 94% of both cores — while the disk stays close to idle.

Scenarios

Correctness under stress

A pass means zero ordering violations and zero lost events — never a throughput threshold. One row is marked HOST: the scenario hit a limit of this two-core VM, not of Tandem.

ScenarioWhat it doesResultObserved
Sustained throughput Finds the highest rate the relay holds PASS 1250 events/s, ceiling bracketed below 1300/s
Latency at normal load Holds 600 events/s and times every event PASS p50 12.8 ms, p99 90.9 ms
Hot partition Sends 80% of traffic to one aggregate PASS 985 rows queued behind the hot bucket; the rest kept flowing
Saturation Drives deliberately past the ceiling, then stops HOST Backlog drained, but two cores need longer than the 9-minute window; nothing lost or reordered meanwhile
Worker failover Kills the relay mid-flight and restarts it PASS 0 duplicates against a bound of 800
Poison message Injects an event that can never be delivered PASS Its aggregate blocked; every other aggregate unaffected
Multi-instance coordination Runs three relays and kills one outright PASS Survivors re-took its 84 buckets, 256 of 256 covered, no duplicates

Endurance

Six hours of production-shaped load

Every scenario above measures the first few minutes of a relay's life. The ways an outbox actually fails in production are slow ones — a leak, index bloat on a table churning through millions of rows, autovacuum falling behind, a lease renewal path that misbehaves only after many cycles. None of them shows up in four minutes.

So Tandem is also run long. This one run predates the adaptive idle backoff, so its latency figures below carry the fixed 100 ms poll interval the numbers further up no longer use; what it is evidence for — drift, or its absence, over six hours — is unaffected.

A fixed 400 events/s held for six hours under two-instance lease coordination, with retention set so the relay deletes delivered rows throughout — the churn a real deployment lives in, not a table that only grows. 8.6 million events, reported in eighteen twenty-minute windows so the last can be compared against the first.

No drift

Throughput 395.7 → 399.6 events/s against 400 offered; p50 flat at 58–60 ms and p99 within 156–173 ms, with no slope in either.

256/256 buckets

Ownership complete and disjoint in every one of the eighteen windows, across roughly 720 lease renewal cycles.

0 lost, 0 reordered

Zero duplicates too, over the whole run. The outbox reached a steady size instead of growing, and autovacuum kept pace with the churn.

This one ran on a developer machine rather than the VM described below, so read it for the absence of a slope rather than for a rate — the whole value of an endurance run is in what does not change. Windows, host samples and the run's own summary are in docs/benchmark-results/2026-08-30-endurance.

Recovery

Catching up after the relay has been down

What an outage leaves behind is a backlog: the rows sit in the outbox and wait, and the only question is how long they take to drain. The claim reads pending rows in id order through a partial index of their own (schema v5), so catching up costs what the backlog costs rather than what the table holds.

405 s for a 30-minute outage

At 400 events/s that is ~713 000 undelivered rows. A 15-minute outage, ~358 000 rows, clears in 146 s.

Flat in the backlog

On the 2 vCPU host, 239 871 rows cleared at 3 656 rows/s and 479 328 at 3 440 — the same rate at twice the backlog.

+0.6% WAL per insert

What the index costs the write path, measured in WAL bytes. Zero ordering violations, zero lost and zero duplicates at every backlog.

The recovery times come from a developer machine; the reference-host pair confirms the same behaviour on native Docker. Runs, plans and samples are in docs/benchmark-results/2026-08-31-outage-recovery.

Test environment

What produced these numbers

Host AWS m7i-flex.large — 2 vCPU, 8 GB, Ubuntu 24.04, native Docker, eu-central-1
Topology PostgreSQL, Kafka, the relay and the load driver on one machine, so COMMIT → ack is measured against a single clock
Relay 8 workers, batches of 100, 256 buckets
Events 1 KB payloads
Producer acks=all, enable.idempotence=true — the production-mandated settings, never a faster configuration for the sake of a number

Treat these as a floor. Latency is stable on this host; sustained throughput is not: this is a burstable instance, whose capacity moves with recent CPU use, and its ceiling has ranged from 725 to 1450 events/s across sessions. Those two cores also carry PostgreSQL, Kafka and the load driver alongside the relay, so a host with cores of its own should do better on both counts.

Every figure is backed by its raw run — logs, resource samples and the script that redraws these charts from them — in docs/benchmark-results. The methodology is in HLD-load-testing.md, the search algorithm in LLD-benchmark.md §7.