Skip to content

Benchmark

Measures the overhead added by OutboxEmitter.emit() and the end-to-end latency from emit to handler invocation.

What We Measure

BenchmarkDescription
A) INSERT — no outbox (baseline)Raw $transaction with a single INSERT INTO outbox_events
B) emit() — single eventOutboxEmitter.emit() in a transaction (includes toPayload() + JSON.stringify + INSERT)
C) emitMany() — 10 eventsOutboxEmitter.emitMany() with 10 events in a single transaction
D) Poll-to-dispatch — single eventEnd-to-end: PENDING → poller fetches → handler called
E) Poller throughput — 100 eventsTime 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 via poller.poll()

Running Locally

bash
# 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 50

Results

Measured on macOS, Node.js 20, PostgreSQL 16 (Docker), localhost. Your results will vary.

BenchmarkAvgP50P95P99
A) INSERT — no outbox (baseline)0.85ms0.78ms1.21ms1.52ms
B) emit() — single event in transaction0.91ms0.84ms1.28ms1.61ms
C) emitMany() — 10 events in transaction3.15ms2.98ms4.12ms5.03ms
D) Poll-to-dispatch — single event latency1.42ms1.31ms2.05ms2.68ms
E) Poller throughput — 100 events batch38.5ms36.2ms48.1ms55.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. Each event is a separate INSERT within the same transaction.

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.

MetricValue
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)

Released under the MIT License.