Mortar — performance baseline

Microbench numbers measured on 2026-05-12 against Intel Xeon @ 2.80GHz (1 single shared-CPU vCPU on the host, local Postgres on the same machine). Use as relative comparison, not as the absolute production capacity — managed RDS round-trips are ~3-10× slower depending on region + instance class.

Setup

docker compose up -d postgres
MORTAR_TEST_DB_URL="postgres://mortar:mortar@localhost:5432/mortar?sslmode=disable" \
  go test -bench=. -benchmem -benchtime=2s -run=^$ ./internal/api/...

Numbers

Benchmarkns/opµs/opimplied req/s¹allocs/op
RouteHandler_DBListTables (NoopTracker — RLS only)2,160,4632,160~463498
RouteHandler_WithEgressMeter (sync — ledger INSERT/req)2,860,4602,860~350577
RouteHandler_WithBufferedEgress (in-mem bucket roll-up)2,016,8122,017~496488
WithTenant_Roundtrip (SET LOCAL + COMMIT only)349,255349~2,86451

¹ req/s is 1e9/ns_per_op, single-threaded. Real Mortar runs GOMAXPROCS-parallel goroutines so peak throughput scales ~linearly with cores until Postgres connection pool saturates (default 10).

What this tells us

Per-request RLS overhead is ~349µs. That’s the cost of a BEGIN; SET LOCAL mortar.tenant_id; ... ; COMMIT; round-trip on local Postgres. Multi-AZ RDS turns this into ~2-5ms on a good day.

EgressMeter sync adds ~700µs. One credit_ledger INSERT per response. At ~30% relative overhead, fine for low traffic; the buffered alternative is essentially free (under-noise).

Buffered tracker is the right default at scale. At 100+ req/s the saved INSERTs translate to less Postgres write load + smaller WAL, which buys headroom for actual feature queries. Mortar keeps sync the default to make per-request usage immediately visible in /usage/me.

~500 req/s ceiling on this benchmark hardware. Production Mortar instance on a 4-core ECS should hit ~1500-2000 req/s on this path; managed RDS doubles per-request latency but doesn’t kill parallelism, so net throughput ≈ 1000 req/s/instance. Mortar scales horizontally (MORTAR_CACHE_BACKEND=redis for cross-instance rate-limit + realtime); 10 instances handle ~10k req/s under this profile.

Where the time goes

/v1/{tenant}/db/tables is essentially:

  1. RateLimit middleware (cache.Cache.IncrTTL → in-memory map for the bench; ~10µs)
  2. authMW: parse + verify HMAC-SHA256 of API key + DB lookup app_keys WHERE prefix = ? (~200µs)
  3. projectMW: parse path + identity check (~5µs)
  4. EgressMeter wrap (~1µs)
  5. userMW: noop when no end-user JWT (~1µs)
  6. creditGate: project lookup + Balance query (~400µs; 2 SELECTs vs the ledger)
  7. handler: tx → SET LOCAL → SELECT FROM app_tables WHERE tenant_id → COMMIT (~350µs)
  8. EgressMeter Record (if sync: INSERT credit_ledger ~700µs; if buffered: lock + map add ~50ns)

Total ≈ 1.5ms compute + DB; the variance vs measured ~2ms is overhead of the bench harness + GC.

How to keep this honest

When the production deploy lands:

  • Re-run against the real RDS instance + write the numbers next to these so we can see the impact of network latency.
  • Bench GET /v1/{tenant}/storage/files/... for streaming responses (different EgressMeter integration).

Write-path benches

Added 2026-05-12 (BenchmarkRouteHandler_DBInsertRow*) — answers “can we extrapolate GET list numbers to POST rows”. Short answer: no. Five reasons the shapes differ:

DimensionGET listPOST insert
RLS policyUSING (read filter)WITH CHECK (write filter)
Locknonerow lock + table-lock evaluation
WALnonefsync per commit (unless async commit)
Triggersnoneupdated_at + audit + FK
Egress trackedsmall JSON bodysmall + DB write byte cost

Run BenchmarkRouteHandler_DBInsertRow and BenchmarkRouteHandler_DBInsertRow_WithBufferedEgress against the same hardware to record the baseline. Expected: insert is 1.5-3× slower than list because of the WAL fsync + lock overhead — but the buffered-egress variant should still match the noop variant within ~5% (the optimization should hold on writes too).

When optimisations come up:

  • API-key cache. Today every authMW call hits Postgres. A per-process LRU could shave the ~200µs auth tax. Worth doing when traffic justifies it.
  • Connection pool tuning. Default 10 conns probably fine for tens of req/s; bump for thousands.
  • Prepared statement cache. Some pgx versions cache; check it’s on.