query cost

why

an unbounded read is silent until tables grow: at 100 rows an N+1 loop takes 50ms, at 50k rows it takes 25 seconds and the screen dies. naming cost at design time — indexes touched, rows scanned, round trips — is the only moment the fix is cheap; after ship the defect is found by a 3 a.m. alert instead of by reading the query.

rule

every durable read names its cost before it ships: which indexes it hits, how many rows it can touch, how many round trips the answer needs. a read whose cost grows with table size — not page size — is a defect, caught at design time by reading the query, not at incident time by an alert.

cost model

read shapeacceptable cost
indexed point lookupO(1) round trip, one row.
cursor page over indexed orderO(page size) rows, one round trip.
aggregate projectionbounded by the aggregate's declared window, computed from durable facts.
per-item hydration inside a looprejected — batch into one read or denormalize the fact.

the N+1 shape is the canonical violation: a list read followed by a per-row lookup for a related fact. the fix is contract-first — the page response embeds the related projection, or the relation is denormalized into the row at write time per database design. "fast enough on 100 rows" is not a defense; the table grows.

write-side and read-side responsibilities

concernowner
indexes matching the stable orders and filtersschema migration, per schema migrations.
bounded response shapethe API contract, per interface-shaped api.
batching related readsthe domain service composing the projection.
count accuracydeclared: exact count is a cost decision, not a default — approximate counts are named in the contract when chosen.

do not

  • ship a read that filters an unindexed column over a growing table.
  • hydrate related facts per-row in a loop when a batch read or embedded projection exists.
  • hide an expensive read behind a small-looking endpoint; the cost belongs in the slice.
  • add an index without naming the query it serves; orphan indexes have their own cost.

verification

for each list operation in the contract: read its generated SQL or query plan, name the index used, record the plan in the slice. insert an order-of-magnitude more rows in a non-production environment and confirm page latency stays flat in page size. count round trips per screen load; every screen is a bounded, named number of queries.

good and bad

bad

const rows = await listOrders(cursor); // 1 query
for (const row of rows) {
row.customer = await getCustomer(row.id); // N more queries
}

cost grows with page size; latency climbs silently as data accumulates until the screen times out under normal traffic. the fix under load is an emergency rewrite instead of the one-line batch a design review would have caught.

good

const rows = await listOrders(cursor); // 1 query
const customers = await getCustomers(rows.map(id)); // 1 batched query

one page read plus one batched hydration — bounded round trips regardless of table size. latency stays flat, capacity planning is arithmetic over named queries, and design review rejects an expensive read by reading the contract instead of discovering it in an incident.

search pages

go to any page