13 — Performance: frontend and SQL
Commandment III in pure form: verify, don’t declare — applied to speed. “Feels faster” doesn’t exist. Before/after numbers exist.
Performance isn’t a hunch, it’s a measurement. The most common optimization mistake: guessing what’s slow and “fixing” it without proof it was the problem. Measure first, fix what the metric points to, and prove with a number that you improved it. Performance is also SEO (Core Web Vitals — Google’s measures: how fast a page appears, how stably it lays out and how fast it reacts. They affect your Google ranking and whether the user stays. Measurable, so you can improve them. → 10) and cost (a faster query = a cheaper server → 12).
Measure first
- Lighthouse / Core Web Vitals: LCP (largest contentful paint), CLS (layout shift), INP (interaction), TBT. These are hard numbers, not impressions.
- Before/after, not “feels faster.” E.g. home-page optimization: preload hero,
GPU-promoted animations,
content-visibility: autobelow the fold; you report LCP before and after, not “probably lighter.” → 03 - SQL — The query language for a database — the way you “ask” the database for data or change it. The universal standard for talking to a database. A badly written query can bog down the whole app.:
EXPLAIN QUERY PLANtells you whether a query uses an Index (database) — A lookup in the database that makes searching instant instead of scanning everything in order. The first move for slow queries — like an index at the back of a book instead of reading 400 pages. or scans the whole table.
Frontend
- Code-splitting + lazy-load below-fold — don’t load what the user can’t see.
- Images: WebP (e.g. convert everything to WebP q=95), responsive
srcset, preload hero (LCP), the rest lazy. - Fonts: self-host (e.g. Playfair/Outfit, as on jakub.solutions),
font-display: swap— text visible before the font arrives. content-visibility: autoon sections below the fold — the browser skips rendering what’s offscreen.- GPU-promoted animations (
translateZ(0)/transform) instead of layout-triggering properties. - Static Cache — A temporarily remembered result, so the same thing isn’t computed again on every request. Speeds the app up, but can be a trap: a stale cache shows old data.:
immutable+ longmax-agein prod (e.g. 7 days immutable in prod). - Cache-busting per deploy: since CSS/JS are
immutable, append?v=<git-short-hash>to every link — each deploy changes the URL → fresh fetch. Without it the user sees the old app (broken layout) until a hard refresh (→ 05). - Streaming — Sending a large response in chunks, as it goes, instead of loading everything into memory at once. The app doesn’t choke on huge data — the user sees a result sooner. / SSE for chat — the LLM response token-by-token (e.g. via SSE), the user sees the first words right away instead of a blank screen until generation finishes. → 08
- Server-side pagination — never ship the whole catalog to the browser (e.g. server-side pagination for a few thousand items).
Mobile: the viewport is a moving target
On a phone the viewport is not a fixed rectangle: the URL bar grows and shrinks as you scroll, and the on-screen keyboard eats the bottom half. Treat it as static and you get elements hidden behind the keyboard, or a tall empty band where the URL bar used to be. Measure the real viewport — don’t assume it.
- Use the dynamic unit.
100dvh(dynamic viewport height) for a full-screen sheet — not100vh(which is the largest viewport and runs under the URL bar). Declareheight: 100vhfirst as the fallback, thenheight: 100dvhto override — property order matters, an old engine keeps the last rule it understands. - Pin keyboard-sticky elements to
visualViewport, not the layout viewport. A chat input or a sticky CTA onposition: fixed; bottom: 0is measured against the layout viewport and slides under the keyboard. The only reliable anchor is thevisualViewportAPI — An agreed way for two programs to talk to each other — one asks, the other answers in a set format. Through an API your app connects to outside services (payments, maps, AI). Treat an API key like a password. (.height/.offsetTop, re-read on itsresize/scroll) — it papers over the different iOS vs Android keyboard behaviour. Snap to the new size; don’t animate the keyboard open (atransition/transformon the keyboard event produces a visible jump). - A
flex-basisset for a row becomes a HEIGHT in a column.flex: 1 1 220pxis a width hint in a row; the same rule under aflex-direction: columnbreakpoint makes a 220px-tall empty box. Reset the basis (flex: 0 0 auto) at the breakpoint where the main axis flips. - The emulator lies; the DOM and the device don’t. A desktop browser’s device-mode renders
dvh/fixed elements differently from a real phone — don’t “fix” a layout off a screenshot. MeasuregetBoundingClientRect()/getComputedStyle()on the real elements, and keep a device-profile test project (e.g. a separate Playwright project pinned to a phone) that asserts geometry every run: no horizontal overflow, tabs on one line, the key element above the fold. Verify, don’t declare (→ 03).
SQL
- Indexes on columns in
WHEREandJOIN— a hot query without an index is a full table scan. - Partial indexes — e.g.
uniq_prices_active … WHERE expired_at IS NULL(an index only on active prices — smaller, faster, enforces uniqueness, → 11). EXPLAIN QUERY PLANbefore and after adding an index — proof the plan changed.- Avoid N+1 — A performance bug: the app queries the database 200 times instead of once, asking separately for each item. The most common cause of a “laggy” list. Like 200 phone calls instead of one request for the whole list. — no query in a loop per row; batch/join in one shot.
SELECTonly the columns you need — notSELECT *when you need three fields.- Server-side pagination + cached aggregates (e.g.
site_statsinstead ofCOUNT(*)over the whole database on every visit to the home page). - WAL — Write-Ahead Log: a database mode where changes go to a journal first, then to the data. Gives safety (recovery after a crash) and better read/write concurrency. (SQLite — The simplest database — the whole thing lives in one file, with no separate server. A great default to start: zero configuration, easy backup (you copy the file). As you grow, you migrate up.) — readers don’t block the writer; the default mode in the reference project.
- Careful with
LIKE '%foo'— a leading wildcard kills the index (full scan); consider FTS if it’s a hot search path.
Anti-patterns
- 🚫 Optimization without measurement — “I improved it” without knowing whether it was slow (→ 03).
- 🚫 No index on a hot query — the most common cause of a slow catalog page.
- 🚫 N+1 in a loop — 200 queries where one join would do.
- 🚫
SELECT *— you transfer and deserialize columns you don’t use. - 🚫 Client-side pagination of huge sets — shipping a few thousand records to show 20.
- 🚫 Blocking the UI waiting for the full LLM response instead of streaming (→ 12).
- 🚫 No cache on expensive aggregates —
COUNT(*)over the whole database on every request. - 🚫
100vhfor a full-screen mobile sheet — the bottom hides under the URL bar/keyboard (usedvh+visualViewport). - 🚫
position: fixed; bottom: 0for a keyboard-sticky input — it slides under the keyboard (anchor tovisualViewport). - 🚫 A flex-basis carried from a row into a column breakpoint — a tall empty box (reset the basis where the axis flips).
- 🚫 “Fixing” mobile from a desktop-emulator screenshot — you fix an artifact; measure real DOM geometry / test on a device profile.
For new projects
Add to Day 0 (→ 07): a Lighthouse baseline right after the
first working page (you have a reference point), indexes on filter columns from the first
Migration (database) — A controlled change to the database layout — adding a column, a table or moving data — step by step. Like a renovation to plan: rebuilding data in a set order so nothing “collapses”., EXPLAIN QUERY PLAN as a habit on every hot query. The overriding rule:
no optimization without a before-and-after number — because without proof an “optimization” can
be a regression in disguise (→ 03). Speed is both SEO
(→ 10) and infrastructure cost (→ 12)
— one investment, three returns.