Make lists fast: search, pagination and the N+1 hunt
Continues from the last build: Uploads go straight to object storage with presigned URLs and private serving.
You seed the database with 50,000 invoices to demo the dashboard to a prospect and it takes 4 seconds to load.
What you'll build
The dashboard invoice list renders in under 300ms at p95 with 50,000 rows, backed by a single joined query instead of N+1 lookups. Pagination uses a stable (created_at, id) cursor that never skips or repeats a row. Search is server-side, ranked, and covers client name, invoice number and notes through a GIN-indexed tsvector column. You can explain, with an EXPLAIN ANALYZE plan in hand, exactly which index served which query and what it would cost to add one more.
See how we teach, before you sign up
You don't just get code dumped on you. Every starter file and every solution is explained line-by-line, in plain English. Here's one real file from this project:
import { db } from "../lib/db";
import { invoices, clients } from "../db/schema";
const CLIENT_COUNT = 300;
const INVOICE_COUNT = 50_000;
const BATCH_SIZE = 1000;
function randomNotes(): string {
const phrases = ["late fee applied", "paid via bank transfer", "net 30 terms", "rush job", "quarterly retainer"];
return phrases[Math.floor(Math.random() * phrases.length)];
}
async function seed() {
console.log(`Seeding ${CLIENT_COUNT} clients...`);
const clientRows = Array.from({ length: CLIENT_COUNT }, (_, i) => ({
orgId: "org_demo",
name: `Client ${i}`,
}));
const insertedClients = await db.insert(clients).values(clientRows).returning({ id: clients.id });
console.log(`Seeding ${INVOICE_COUNT} invoices in batches of ${BATCH_SIZE}...`);
for (let offset = 0; offset < INVOICE_COUNT; offset += BATCH_SIZE) {
const batch = Array.from({ length: BATCH_SIZE }, (_, i) => {
const n = offset + i;
return {
orgId: "org_demo",
clientId: insertedClients[n % insertedClients.length].id,
number: `INV-${1000 + n}`,
amountCents: 5000 + (n % 50) * 100,
currency: "usd",
notes: randomNotes(),
};
});
await db.insert(invoices).values(batch);
if (offset % 10000 === 0) console.log(` inserted ${offset}`);
}
console.log("Seed complete.");
process.exit(0);
}
seed();
Reading this file
const BATCH_SIZE = 1000Batching inserts is the difference between a seed that finishes in seconds and one that takes minutes; one INSERT per row would issue 50,000 round trips.await db.insert(invoices).values(batch)A single multi-row INSERT per batch, not a loop of single-row inserts inside the loop.amountCents: 5000 + (n % 50) * 100Money stays integer cents even in seed data, matching the platform-wide money rule.clientId: insertedClients[n % insertedClients.length].idCycling through client ids spreads 50,000 invoices across 300 clients instead of every invoice sharing one client, which matters for realistic index selectivity.
Starter file for this rung.
That's 1 of 9 explained code blocks in this single project.
The build, milestone by milestone
- 1
Measure first, not vibes
6 guided stepsEvery fix in this rung is judged against a baseline. If you optimize before you measure you will fix the wrong thing, or fix a thing that was never slow, and you will not be able to prove the fix worked.
- 2
Hunt the N+1s
5 guided stepsN+1 queries are the single most common reason a list page that works fine with 20 rows falls over with 5,000. The fix is almost always cheaper than the bug: one join instead of hundreds of round trips.
- 3
Cursor pagination that does not lie
6 guided stepsOFFSET pagination has a second lie beyond speed: if a new invoice is inserted while a user is paging, OFFSET can skip or repeat rows because the row numbers shift under it. A cursor tied to actual column values does not have that problem.
- 4
Postgres full-text search across client, number and notes
6 guided stepsA generated column keeps the search index automatically in sync with the source columns on every insert or update, so you never run a separate reindex job. GIN is the index type built for this: it indexes every lexeme in the vector, not the whole row.
- 5
Index deliberately, not everywhere
6 guided stepsEvery index speeds up reads but slows down every write to that table and takes disk space, because Postgres has to update the index on every INSERT and UPDATE. Indexing everything is not free; the discipline is to add only the indexes a real query plan demanded.
What's inside when you start
You'll walk away with
This is portfolio-grade. Build it free.
Sign up to unlock every milestone step-by-step, the code skeletons, full reference solutions, and checkable tasks, with your progress saved as you build.
Start building