Skip to content

Performance lens in review

Ask what each change costs at production's size; post only what grows, with the count that proves it.

Predict first

Before you read: predict

MR !3391 adds the proctor console's room panel: a count of each alert kind in the room, and one tile per candidate showing the last event that candidate's laptop reported. Its test, a room of 3 candidates, passes. The migration is unchanged:

-- migrations/001_console.sql (excerpt)
CREATE INDEX events_candidate_at ON events (candidate_id, at);
CREATE INDEX events_room_kind ON events (room_id, kind);

On exam day a proctor watches 40 candidates, and the exam runs for 90 minutes. Each proctor's console calls roomPanel every 5 s.

Which review comment names a cost that grows on exam day?

Code for this question
// src/console/room-panel.js (new)const ALERT_KINDS = ['second face', 'tab switch', 'audio']; export async function roomPanel(db, roomId) {  const counts = {};  for (const kind of ALERT_KINDS) {    const { rows } = await db.query('SELECT count(*)::int AS n FROM events WHERE room_id = $1 AND kind = $2', [roomId, kind]);    counts[kind] = rows[0].n;  }  const { rows: candidates } = await db.query('SELECT id, name FROM candidates WHERE room_id = $1 ORDER BY id', [roomId]);  const tiles = [];  for (const c of candidates) {    const { rows } = await db.query('SELECT kind FROM events WHERE candidate_id = $1 ORDER BY at DESC LIMIT 1', [c.id]);    tiles.push({ id: c.id, name: c.name, last: rows[0]?.kind ?? null });  }  return { counts, tiles };}
Pick one

The 30-second version

Read each changed query, loop, job and render with one question: what does it cost at production's size? A cost that grows with rows, users or events is a finding; one bounded by a fixed list is not. Prove it with a count at two sizes, judge plans on production-sized data, and post the number with the fix.

Ask what each change costs at production's size; post only what grows, with the count that proves it.

How it works

Step 1 of 12

Ask the scale question. For each changed query, loop, job and render, ask what it costs at production's size: its rows, users, events per second.
In the kitchen
  • the plan was rehearsed on three rows; the estate has three thousand → the MR's test ran on a seed (steps 1, 6)

Analogy: A vineyard manager walking the harvest plan before picking the whole estate

In the vineyard In review
the plan was rehearsed on three rows; the estate has three thousand the MR's test ran on a seed (steps 1, 6)
a picker carrying each bunch to the press alone makes one trip per bunch a statement per row (step 4)
three trips to fetch the three varieties' crates stay three a loop over a fixed list (steps 2–3)
the tally board lists every crate ever picked a read with no bound (step 5)
asked for the ten oldest barrels, the cellar hand checks every barrel's date, having no register kept in age order ORDER BY without a matching index (step 7)
the buyer waits at the cellar door while every bottle of his order is labelled work the reply does not show (step 8)
she writes "three thousand trips, not three" in the margin, with the fix the number in the comment (step 12)

In real terms: a change is cheap or costly only at a size; the review asks for that size and counts what grows.

Breaks down when: a vineyard's rows are walked once a season. A request path runs thousands of times a minute, so a small cost per request is multiplied by traffic as well as by data (steps 10, 12).

Contrasting case: a one-off admin script over today's 40 rows needs none of this; the lens pays on paths that run per request or per render.

Examples, run for real

4.1 ExamHall EXH-3090: a candidate's detail panel at two sizes

EXH-3090: MR !3390 added a candidate's detail panel to the proctor console: a count per alert kind and the five most recent events. Its test seeded one candidate with five events. Counted for that seed and for a candidate who has sent 500 events by the end of the exam:

4.1 as merged · statements per call: 4 (5 events) vs 4 (500 events) · rows the event read returns: 5 vs 500
4.1 bounded   · statements per call: 4 (5 events) vs 4 (500 events) · rows the event read returns: 5 vs 5

The statements stayed at four at both sizes: three come from the alert-kind loop, whose bound is the fixed list (step 3). The event read had no bound, so it returned every event to keep five (step 5); with ORDER BY at DESC LIMIT 5 it returns five at both sizes.

4.2 Proof domain, payments ledger: work before the reply

MR !3399 makes a refund email every member of the merchant's team before replying. Counted for the test's merchant and the largest one:

4.2 as merged · mailer calls before the reply: 2 (team of 2: 2 emails) vs 40 (team of 40: 40 emails)
4.2 one job   · mailer calls before the reply: 1 (team of 2: 1 job queued) vs 1 (team of 40: 1 job queued)

The reply carries only the refunded entry's id; the emails grow with the team. With one queued job, the reply waits for one call at any team size (step 8).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 8

MR !3393 adds a merchant's account list with each account's balance. Its own test, a merchant with 3 accounts, passes:

The reviewer asks for one more test.

Which one is red on this MR and green once the balances come from one grouped query?

Code for this question
// src/accounts/account-list.js (new)export async function accountList(db, merchantId) {  const { rows: accounts } = await db.query('SELECT id, currency FROM accounts WHERE merchant_id = $1 ORDER BY id', [merchantId]);  for (const a of accounts) {    const { rows } = await db.query('SELECT coalesce(sum(amount_cents), 0)::int AS cents FROM entries WHERE account_id = $1', [a.id]);    a.balanceCents = rows[0].cents;  }  return accounts;}
Pick one

Question 2 of 8

MR !3394 adds the payouts team's queue of pending payouts and an index for it:

-- migrations/014_pending_payouts.sql (new)
CREATE INDEX payouts_pending ON payouts (status, created_at);

The author writes: "On the dev seed (120 payouts, 3 pending), EXPLAIN shows Seq Scan on payouts with or without the new index, so I'll drop the migration." Production's payouts table holds 200,000 rows, 200 of them pending.

Which reply holds?

Code for this question
// src/payouts/pending.js (new)export async function pendingPayouts(db) {  const { rows } = await db.query("SELECT id, merchant_id, amount_cents FROM payouts WHERE status = 'pending' ORDER BY created_at LIMIT 100");  return rows;}
Pick one

Question 3 of 8

Ticket: the finance dashboard's "Close month" button times out at production's 12,000 accounts. The dashboard shows the reply's totalCents, and every merchant still gets a statement. The handler today (deps holds the statement renderer, the mailer and the job queue):

Which change should the review ask for?

Code for this question
// src/month-end/close-month.js (unchanged)export async function closeMonth(db, deps, month) {  const { rows: accounts } = await db.query('SELECT id, merchant_id FROM accounts ORDER BY id');  let totalCents = 0;  for (const a of accounts) {    const { rows } = await db.query('SELECT coalesce(sum(amount_cents), 0)::int AS cents FROM entries WHERE account_id = $1 AND month = $2', [a.id, month]);    totalCents += rows[0].cents;    const pdf = deps.renderStatement(a.id, month, rows[0].cents);    await deps.mailer.send(a.merchant_id, pdf);  }  return { month, accounts: accounts.length, totalCents };}
Pick one

Question 4 of 8

MR !3396 adds the month-end reconciliation, which the unchanged month-end run calls. The MR's test, one merchant with 2 payouts, passes:

In production a month has 400 merchants with 12 payouts each.

How many statements does one month-end run send?

Code for this question
// src/month-end/run-month-end.js (unchanged)export async function runMonthEnd(db, reconcileMerchant, month) {  const { rows: merchants } = await db.query('SELECT id FROM merchants ORDER BY id');  const report = {};  for (const m of merchants) report[m.id] = await reconcileMerchant(db, m.id, month);  return report;}// src/month-end/reconcile.js (new)export async function reconcileMerchant(db, merchantId, month) {  const { rows: payouts } = await db.query('SELECT id, amount_cents FROM payouts WHERE merchant_id = $1 AND month = $2', [merchantId, month]);  const mismatched = [];  for (const p of payouts) {    const { rows } = await db.query('SELECT coalesce(sum(amount_cents), 0)::int AS cents FROM entries WHERE payout_id = $1', [p.id]);    if (rows[0].cents !== p.amount_cents) mismatched.push(p.id);  }  return mismatched;}
Pick one

Question 5 of 8

EXH-3091: an MR adds the proctor console's room log. Each console polls it every 5 s with the last event id it has. A room seats 40 candidates; through a 3-hour exam their laptops send events and proctors add notes. The MR's test, a room of 3 with 6 events and 2 notes, passes:

-- migrations/001_console.sql (excerpt)
CREATE INDEX events_room_id ON events (room_id, id);

Which review comment names a reply that grows as the exam runs?

Code for this question
// src/console/room-log.js (new)export async function roomLog(db, roomId, afterId) {  const { rows: seats } = await db.query('SELECT id, name FROM candidates WHERE room_id = $1 ORDER BY id', [roomId]);  const { rows: events } = await db.query('SELECT id, candidate_id, kind FROM events WHERE room_id = $1 AND id > $2 ORDER BY id LIMIT 100', [roomId, afterId]);  const { rows: notes } = await db.query('SELECT id, candidate_id, body FROM notes WHERE room_id = $1 ORDER BY id', [roomId]);  return { seats, events, notes, after: events.at(-1)?.id ?? afterId };}
Pick one

Question 6 of 8

The interviewer asks about MR !3391, the room panel of the opening probe. The platform's consoles watch 40 candidates each and refresh every 5 s. In at most four lines, write your review comment: the statements per refresh, the statements per second across the platform, the fix, and the test that proves it.

How will this perform with 50,000 candidates?

Question 7 of 8

MR !3397 adds a candidate's event timeline to the proctor console. On exam morning the live feed sends each console one message a second, each holding 3 alerts, and calls the page's callback once per alert. 1,250 consoles are open, each with one candidate selected:

How many events requests reach the API from the timelines, from mount to the end of the first minute?

Code for this question
// src/console/CandidateTimeline.jsx (new)import { useEffect, useState } from 'react'; export function CandidateTimeline({ api, candidateId, filter }) {  const [events, setEvents] = useState([]);  useEffect(() => {    let live = true;    api.events(candidateId, filter.kinds).then((rows) => { if (live) setEvents(rows); });    return () => { live = false; };  }, [api, candidateId, filter]);  return (    <ul>      {events.map((e) => <li key={e.id}>{e.kind}</li>)}    </ul>  );}// src/console/ConsolePage.jsx (new)import { useEffect, useState } from 'react';import { CandidateTimeline } from './CandidateTimeline.jsx'; export function ConsolePage({ api, feed, selected }) {  const [alerts, setAlerts] = useState([]);  useEffect(() => feed.subscribe((a) => setAlerts((all) => [...all, a])), [feed]);  return (    <main>      <p>{alerts.length} alerts</p>      <CandidateTimeline api={api} candidateId={selected} filter={{ kinds: ['second face', 'tab switch'] }} />    </main>  );}
Pick one

Question 8 of 8

MR !3398 adds exchange rates and payee names to a merchant's statement. A statement holds a month of entries, in at most three currencies:

Which review comment names a cost that grows with the statement?

Code for this question
// src/statements/enrich.js (new)export function makeEnricher(db, rates = new Map()) {  async function rateFor(currency) {    if (!rates.has(currency)) {      const { rows } = await db.query('SELECT rate FROM fx_rates WHERE currency = $1', [currency]);      rates.set(currency, Number(rows[0].rate));    }    return rates.get(currency);  }  return async function enrich(entries) {    for (const e of entries) {      e.rate = await rateFor(e.currency);      const { rows } = await db.query('SELECT name FROM payees WHERE id = $1', [e.payee_id]);      e.payee = rows[0].name;    }    return entries;  };}
Pick one

Go deeper

Pro tip
  1. Read production's row count before you size a load test. SELECT reltuples::bigint FROM pg_class WHERE relname = 'entries' returns the planner's estimate without counting the table, so the load test's seed can match it. When: you write a load test for an endpoint that reads a growing table (step 1). Verify (RAN, tips.mjs):
    tip SELECT reltuples::bigint FROM pg_class WHERE relname = 'entries' -> 120,000 (a 120,000-row table after ANALYZE; no count(*) run)
    Status: RAN.
Source

Every cost this lesson grades is executed; the sources support the steps and key nothing. Valid as of 2026-10-06.

  • Prisma documentation, Query optimization (the curriculum row's first source), https://www.prisma.io/docs/orm/prisma-client/queries/query-optimization-performance (prisma-query-optimization.txt). Quoted: "The n+1 problem occurs when looping through query results and performing one additional query per result". Supports step 4; the fixtures use plain SQL, and no Prisma API keys anything.
  • PostgreSQL 18 documentation, Chapter 11. Indexes (the row's second source), https://www.postgresql.org/docs/current/indexes.html (postgresql-indexes.txt). Quoted: "But indexes also add overhead to the database system as a whole, so they should be used sensibly." Supports step 6: an index earns its place where a query needs it.
  • PostgreSQL 18 documentation, 11.1. Introduction (the chapter's first section), https://www.postgresql.org/docs/current/indexes-intro.html (postgresql-indexes-intro.txt). Quoted: "it will use the index in queries when it thinks doing so would be more efficient than a sequential table scan" (step 6) and "With no advance preparation, the system would have to scan the entire test1 table, row by row, to find all matching entries." (step 7). The plans graded here come from PGlite's PostgreSQL 18.3, the version these pages describe.
  • react.dev, You Might Not Need an Effect (the row's third source), https://react.dev/learn/you-might-not-need-an-effect (react-you-might-not-need-an-effect.txt). Quoted: "Many apps use Effects to kick off data fetching." Background for step 10; when an effect reruns belongs to react-effects-synchronization.

Checked against the tools and docs as of 2026-10-06.