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
| Benchmark | ns/op | µs/op | implied req/s¹ | allocs/op |
|---|---|---|---|---|
RouteHandler_DBListTables (NoopTracker — RLS only) | 2,160,463 | 2,160 | ~463 | 498 |
RouteHandler_WithEgressMeter (sync — ledger INSERT/req) | 2,860,460 | 2,860 | ~350 | 577 |
RouteHandler_WithBufferedEgress (in-mem bucket roll-up) | 2,016,812 | 2,017 | ~496 | 488 |
WithTenant_Roundtrip (SET LOCAL + COMMIT only) | 349,255 | 349 | ~2,864 | 51 |
¹ 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:
- RateLimit middleware (cache.Cache.IncrTTL → in-memory map for the bench; ~10µs)
- authMW: parse + verify HMAC-SHA256 of API key + DB lookup
app_keys WHERE prefix = ?(~200µs) - projectMW: parse path + identity check (~5µs)
- EgressMeter wrap (~1µs)
- userMW: noop when no end-user JWT (~1µs)
- creditGate: project lookup + Balance query (~400µs; 2 SELECTs vs the ledger)
- handler: tx → SET LOCAL → SELECT FROM app_tables WHERE tenant_id → COMMIT (~350µs)
- 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:
| Dimension | GET list | POST insert |
|---|---|---|
| RLS policy | USING (read filter) | WITH CHECK (write filter) |
| Lock | none | row lock + table-lock evaluation |
| WAL | none | fsync per commit (unless async commit) |
| Triggers | none | updated_at + audit + FK |
| Egress tracked | small JSON body | small + 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.