Settle builds every option with IBM Bob, then measures them 2026-09-27 03:57 UTC · base d049ae6
The question

How do we make the top customers endpoint fast?

3 of 3 options built by IBM Bob in parallel, each in its own branch, then measured with the same 15 second load test at concurrency 8.

Verdict

Materialized view meets every constraint with the smallest change.

Materialized view: p95 15.2 ms, 56 lines of code changed, 0 new dependencies. Cache fails staleness (60.2 s, needs ≤ 15 s); Index and rewrite fails p95 latency (198.1 ms, needs ≤ 50 ms).

Decision map

Each dot is one option Bob built. Drag the two lines to set your limits. Anything in the shaded corner is good enough, and the smallest change in there wins.

1 ms10 ms100 ms1 sfresh15 s30 s45 s60 s75 s p95 latency, log scale → ↑ staleness ≤ 50 ms ≤ 15 s Cache 2 ms · 60.2 s stale Materialized view · pick 15 ms · 5.2 s stale Index and rewrite 198 ms · fresh Today (no change) 695 ms · fresh
MeasuredToday (no change)CachePickMaterialized viewIndex and rewrite
p95 latencyneeds ≤ 50 ms, median of 3 runs 695 ms (659 ms–726 ms)2 ms (2 ms–2 ms)✓15 ms (14 ms–16 ms)✓198 ms (168 ms–204 ms)✕
Requestsfailed requests disqualify 636 · 0 failed289,325 · 0 failed✓33,613 · 0 failed✓2,299 · 0 failed✓
Median latency 572 ms1 ms10 ms155 ms
Throughput 14.1 req/s6391.7 req/s757.5 req/s50.5 req/s
Stalenessneeds ≤ 15 s instant60.2 s✕5.2 s✓instant✓
Code changedapplication code, tests excluded +0 −0+48 −1+47 −9+32 −10
Tests added +0 lines+99 lines+89 lines+69 lines
Files touched 0142
New dependencies nonenonenonenone
Tests pass · 3pass · 7✓pass · 7✓pass · 6✓
Existing testsmust be left untouched —untouched✓untouched✓untouched✓
Built by Bob in —2m 7s2m 20s2m 23s

✓ meets the constraint · ✕ misses it · first column is today's code, for reference

Trust this comparison

Base commitd049ae6053bf, every option branched from it
Load test15 s at concurrency 8, 3 runs per option, median reported, options measured one at a time
Disqualifiersmeasurement errors, no successful requests, more than 1% failed requests, a missing freshness result, or changed existing tests
Cache✓ 0 of 289325 failed · ✓ tests untouched · p95 per run 2 / 2 / 2 ms
Materialized view✓ 0 of 33613 failed · ✓ tests untouched · p95 per run 16 / 14 / 15 ms
Index and rewrite✓ 0 of 2299 failed · ✓ tests untouched · p95 per run 204 / 198 / 168 ms
Today's code636 requests, 0 failed
Machine11th Gen Intel(R) Core(TM) i5-1145G7 @ 2.60GHz · 8 cores · 16 GB · win32 10.0.26200 · Node v24.15.0

The options

Cache

Cache the endpoint's response in memory with a 60 second time to live.

Branch settle/2026-09-27T03-47-21/cache

  • demo/orders-api/src/server.ts
  • demo/orders-api/test/cache.test.ts test
The code Bob wrote
src/server.ts⋯ } +// ---------------------------------------------------------------------------+// In-memory response cache for GET /stats/top-customers+// TTL is 60 seconds by default; tests may override via the exported constant.+// ---------------------------------------------------------------------------++export const CACHE_TTL_MS = 60_000;++interface CacheEntry {+  value: TopCustomer[];+  expiresAt: number;+}++// One cache map per module load. Each buildServer() call shares this cache so+// that concurrent requests collapse onto the same in-flight query (see the+// inflight map below) and hot restarts pick up immediately.+const cache = new Map<number, CacheEntry>();+const inflight = new Map<number, Promise<TopCustomer[]>>();++/** Exposed so tests can invalidate entries without waiting for real TTL. */+export function bustCache(): void {+  cache.clear();+}++async function cachedTopCustomers(db: PGlite, limit: number): Promise<TopCustomer[]> {+  const now = Date.now();+  const entry = cache.get(limit);+  if (entry && now < entry.expiresAt) {+    return entry.value;+  }++  // Collapse concurrent requests for the same limit into one DB query.+  let pending = inflight.get(limit);+  if (!pending) {+    pending = topCustomers(db, limit).then((rows) => {+      cache.set(limit, { value: rows, expiresAt: Date.now() + CACHE_TTL_MS });+      inflight.delete(limit);+      return rows;+    }).catch((err) => {+      inflight.delete(limit);+      throw err;+    });+    inflight.set(limit, pending);+  }++  return pending;+}+ function send(res: ServerResponse, status: number, body: unknown): void {   res.writeHead(status, { "content-type": "application/json" });⋯       if (req.method === "GET" && url.pathname === "/stats/top-customers") {         const limit = Math.min(Math.max(Number(url.searchParams.get("limit") ?? 10), 1), 100);-        return send(res, 200, await topCustomers(db, limit));+        return send(res, 200, await cachedTopCustomers(db, limit));       }

Materialized view Pick

Precompute the ranking in a Postgres materialized view and refresh it in the background every 5 seconds.

Branch settle/2026-09-27T03-47-21/matview

  • demo/orders-api/src/db.ts
  • demo/orders-api/src/index.ts
  • demo/orders-api/src/matview.ts
  • demo/orders-api/src/server.ts
  • demo/orders-api/test/matview.test.ts test
The code Bob wrote
src/db.ts⋯     ANALYZE;   `);++  // Materialized view: precomputed top-customers ranking (no time filter —+  // the view itself stores the rolling-30-day aggregation so every refresh+  // re-evaluates the window cheaply against a fresh snapshot).+  await db.exec(`+    CREATE MATERIALIZED VIEW mv_top_customers AS+    SELECT c.id AS customer_id,+           c.name,+           sum(o.amount_cents)::int AS revenue_cents,+           count(*)::int           AS orders+    FROM orders o+    JOIN customers c ON c.id = o.customer_id+    WHERE o.status = 'paid'+      AND o.created_at > now() - interval '30 days'+    GROUP BY c.id, c.name+    ORDER BY revenue_cents DESC;++    CREATE UNIQUE INDEX ON mv_top_customers (customer_id);+  `);+   return db; }src/index.ts⋯ import { openDb } from "./db.ts"; import { buildServer } from "./server.ts";+import { startRefresh } from "./matview.ts";  const port = Number(process.env.PORT ?? 3000); const started = Date.now(); const db = await openDb();+startRefresh(db); buildServer(db).listen(port, () => {   console.log(`orders-api listening on ${port} (seeded in ${Date.now() - started} ms)`);src/matview.ts⋯+import type { PGlite } from "@electric-sql/pglite";++const REFRESH_INTERVAL_MS = 5_000;++/**+ * Starts a background interval that refreshes mv_top_customers every 5 s.+ * Returns a stop function that clears the interval (call it in tests/shutdown).+ *+ * CONCURRENTLY is used so reads are never blocked during refresh.+ * It requires the unique index on customer_id that openDb() creates.+ */+export function startRefresh(db: PGlite): () => void {+  const timer = setInterval(() => {+    db.exec("REFRESH MATERIALIZED VIEW CONCURRENTLY mv_top_customers").catch(+      (err) => console.error("[matview] refresh error:", err),+    );+  }, REFRESH_INTERVAL_MS);++  // Let Node exit even if the interval is still pending (belt-and-suspenders).+  if (typeof timer.unref === "function") timer.unref();++  return () => clearInterval(timer);+}src/server.ts⋯ export async function topCustomers(db: PGlite, limit: number): Promise<TopCustomer[]> {   const { rows } = await db.query<TopCustomer>(-    `SELECT c.id AS customer_id,-            c.name,-            sum(o.amount_cents)::int AS revenue_cents,-            count(*)::int AS orders-     FROM orders o-     JOIN customers c ON c.id = o.customer_id-     WHERE o.status = 'paid'-       AND o.created_at > now() - interval '30 days'-     GROUP BY c.id, c.name+    `SELECT customer_id, name, revenue_cents, orders+     FROM mv_top_customers      ORDER BY revenue_cents DESC      LIMIT $1`,

Index and rewrite

Add the right index or indexes and rewrite the query so Postgres does less work. No caching and no precomputed tables.

Branch settle/2026-09-27T03-47-21/index

  • demo/orders-api/src/db.ts
  • demo/orders-api/src/server.ts
  • demo/orders-api/test/index.test.ts test
The code Bob wrote
src/db.ts⋯     FROM generate_series(1, ${ORDERS}); +    -- Partial covering index for the top-customers query.+    -- The WHERE clause limits the index to 'paid' rows only (~75% of the+    -- table, but the only rows the query ever touches).+    -- Including customer_id and amount_cents means Postgres can satisfy the+    -- entire GROUP BY / SUM from the index without visiting the heap at all+    -- (index-only scan).  created_at is the leading column so the 30-day+    -- range filter is a cheap B-tree range scan rather than a full index scan.+    CREATE INDEX orders_paid_created_covering_idx+      ON orders (created_at, customer_id, amount_cents)+      WHERE status = 'paid';+     ANALYZE;   `);src/server.ts⋯ // endpoint the Settle demo debates how to speed up. export async function topCustomers(db: PGlite, limit: number): Promise<TopCustomer[]> {+  // Aggregate on orders first using only the columns covered by the partial+  // index (status = 'paid' is the index predicate; created_at, customer_id,+  // amount_cents are index columns), then join to customers for the name.+  // This lets Postgres use an index-only scan for the heavy aggregation step+  // and avoids dragging the customers table into the hash/sort before LIMIT.   const { rows } = await db.query<TopCustomer>(-    `SELECT c.id AS customer_id,+    `SELECT agg.customer_id,             c.name,-            sum(o.amount_cents)::int AS revenue_cents,-            count(*)::int AS orders-     FROM orders o-     JOIN customers c ON c.id = o.customer_id-     WHERE o.status = 'paid'-       AND o.created_at > now() - interval '30 days'-     GROUP BY c.id, c.name-     ORDER BY revenue_cents DESC-     LIMIT $1`,+            agg.revenue_cents,+            agg.orders+     FROM (+       SELECT customer_id,+              sum(amount_cents)::int AS revenue_cents,+              count(*)::int          AS orders+       FROM orders+       WHERE status = 'paid'+         AND created_at > now() - interval '30 days'+       GROUP BY customer_id+       ORDER BY revenue_cents DESC+       LIMIT $1+     ) agg+     JOIN customers c ON c.id = agg.customer_id+     ORDER BY agg.revenue_cents DESC`,     [limit],   );

Design doc appendix

## Appendix: measured comparison

**Question:** How do we make the top customers endpoint fast?

Each option was built by IBM Bob in its own branch from `d049ae6`, then measured with the same load test (15 s at concurrency 8) and the same staleness probe.

| | Today (no change) | Cache | Materialized view | Index and rewrite |
|---|---|---|---|---|
| p95 latency | 695 ms (659 ms–726 ms) | 2 ms (2 ms–2 ms) | 15 ms (14 ms–16 ms) | 198 ms (168 ms–204 ms) |
| Requests | 636 · 0 failed | 289,325 · 0 failed | 33,613 · 0 failed | 2,299 · 0 failed |
| Median latency | 572 ms | 1 ms | 10 ms | 155 ms |
| Throughput | 14.1 req/s | 6391.7 req/s | 757.5 req/s | 50.5 req/s |
| Staleness | instant | 60.2 s | 5.2 s | instant |
| Code changed | +0 −0 | +48 −1 | +47 −9 | +32 −10 |
| Tests added | +0 lines | +99 lines | +89 lines | +69 lines |
| Files touched | 0 | 1 | 4 | 2 |
| New dependencies | none | none | none | none |
| Tests | pass · 3 | pass · 7 | pass · 7 | pass · 6 |
| Existing tests | — | untouched | untouched | untouched |
| Built by Bob in | — | 2m 7s | 2m 20s | 2m 23s |

**Constraints:** `max_p95_ms: 50`, `max_staleness_seconds: 15`, `tests_must_pass: true`

**Verdict:** Materialized view meets every constraint with the smallest change. Materialized view: p95 15.2 ms, 56 lines of code changed, 0 new dependencies. Cache fails staleness (60.2 s, needs ≤ 15 s); Index and rewrite fails p95 latency (198.1 ms, needs ≤ 50 ms).

**Branches:**
- Cache: `settle/2026-09-27T03-47-21/cache`
- Materialized view: `settle/2026-09-27T03-47-21/matview`
- Index and rewrite: `settle/2026-09-27T03-47-21/index`

_Generated by Settle on 2026-09-27._