Benchmark
Historical measurements
These measurements predate outbox 0.3. They do not measure the new lease heartbeats, fenced transitions, stored retry schedules, or bounded bulk inserts. Benchmark the installed release against your own PostgreSQL workload before using these values for capacity planning.
Measures the overhead added by OutboxEmitter.emit() and the end-to-end latency from emit to handler invocation.
What We Measure
| Benchmark | Description |
|---|---|
| A) INSERT — no outbox (baseline) | Raw $transaction with a single INSERT INTO outbox_events |
| B) emit() — single event | OutboxEmitter.emit() in a transaction (includes toPayload() + JSON.stringify + INSERT) |
| C) emitMany() — 10 events | OutboxEmitter.emitMany() with 10 events in a single transaction |
| D) Poll-to-dispatch — single event | End-to-end: PENDING → poller fetches → handler called |
| E) Poller throughput — 100 events | Time for one poll() cycle to process a full batch of 100 events |
Test Setup
- NestJS: Test app with
@nestjs/testing - PostgreSQL: Docker
postgres, localhost - Iterations: 200 per scenario (configurable)
- Warmup: 20 iterations (discarded)
- Poller:
polling.enabled: false, manually called viapoller.poll()
Running Locally
# Start PostgreSQL
docker compose up -d
# Run with defaults (200 iterations, 20 warmup)
DATABASE_URL=postgresql://test:test@localhost:5433/outbox_test \
npx ts-node bench/outbox.bench.ts
# Custom iterations
DATABASE_URL=... npx ts-node bench/outbox.bench.ts --iterations 500 --warmup 50Results
Measured on macOS, Node.js 20, PostgreSQL 16 (Docker), localhost. Your results will vary.
| Benchmark | Avg | P50 | P95 | P99 |
|---|---|---|---|---|
| A) INSERT — no outbox (baseline) | 0.85ms | 0.78ms | 1.21ms | 1.52ms |
| B) emit() — single event in transaction | 0.91ms | 0.84ms | 1.28ms | 1.61ms |
| C) emitMany() — 10 events in transaction | 3.15ms | 2.98ms | 4.12ms | 5.03ms |
| D) Poll-to-dispatch — single event latency | 1.42ms | 1.31ms | 2.05ms | 2.68ms |
| E) Poller throughput — 100 events batch | 38.5ms | 36.2ms | 48.1ms | 55.3ms |
Interpretation
Emit overhead is negligible. A single emit() (B) adds ~0.06ms over the baseline INSERT (A) — the cost of toPayload() serialization and JSON.stringify. This is the overhead the business code pays per event.
emitMany() scales linearly. 10 events (C) take ~3.15ms, or ~0.31ms per event. That historical run used a separate INSERT per event; 0.3 prevalidates and chunks bulk inserts, so this cost model is not current.
Poll-to-dispatch latency is dominated by the UPDATE query. The poller's UPDATE ... RETURNING query with FOR UPDATE SKIP LOCKED accounts for most of the ~1.42ms in (D). Handler invocation itself is sub-microsecond in this benchmark.
Throughput scales well. Processing 100 events in a single poll cycle (E) takes ~38.5ms, yielding ~2,600 events/sec. Real throughput depends on handler complexity and database latency.
| Metric | Value |
|---|---|
| Emit overhead per event (B - A) | ~0.06ms |
| emitMany per-event cost (C / 10) | ~0.31ms |
| Poll-to-dispatch single event | ~1.42ms |
| Batch throughput (100 events) | ~2,600 events/sec |
Methodology
performance.now()for sub-millisecond timing- Unique event IDs prevent key collisions across runs
- Table truncated between scenarios
- Poller called manually (
poll()) to isolate measurement from scheduling jitter - Each scenario runs sequentially (no concurrent interference)