Every page, measured from what real people saw in the last 15 days and from a production walk-through tonight. Plain English first, the numbers and their sources after, then a ranked plan with what each step buys, what it costs, and which steps need your yes.
23 Sep 2026 · app-speed lane (Fable) for Gera · driver session 1fdb267c · Phase 11 · Page optimizations (rows p11-x-skeleton-everywhere, p11-x-caching, p11-x-index-*) · read-only measurements, no production writes
Three different things feel like "slow", and they need three different fixes.
1. The white screen. On a fresh page open, the app shows nothing for 2.5 seconds on a typical visit and 8+ seconds on a bad one — even on pages whose own data is instant. It is one structural cause: the frame around every page (side rail, top bar) waits for eight database reads before a single pixel is allowed to paint, and Next.js says in its own manual that a loading skeleton cannot show while that happens. One fix, app-wide, being built now by the prospects lane.
2. The wait for data after the skeleton. On a click inside the app the skeleton already appears in under 100 ms. What people then wait for is the page's server work: Tenants 3.5–4.5 s, Properties 4–5 s, Prospects 9–13 s, Dashboard numbers 1.4–2.5 s. Each is a per-building fan-out (one database question per building, times 157 buildings) or a whole-estate read. Fixes are per page; the biggest ones are named below.
3. "Nothing is cached." Correct, mostly. Going back to a page you opened 10 seconds ago re-runs all of its server work, because the router's client cache for dynamic pages is set to zero by default in this Next.js version. Server-side, only the dashboard and leasing numbers have a cache, it lives 30 s (the same as the poll), and it is per server instance, so a second instance recomputes. Two small changes fix most of this; one needs a guard first so a building pick can never show the previous building.
Two instruments. The beacon: every page view in production writes a row to our own propflow-traffic table with the paint times the browser reports (15 days, 9–23 Sep, 3,936 rows, of which 2,985 are non-bot product views by 30 people at 10 companies — 85% of them our own staff). The production walk-through: a headless browser signed in as the smoke user tonight, sampling the screen 60× a second on fresh opens and on click-ins.
The gap between "bytes arrived" and "something painted" is 1.7 s at the median and 8.2 s at the 95th. That is the white screen. It is not the network and not the bundle.
Next.js 16, layout.md § "Interaction with loading.js": "Because loading.js sits below layout.js in the component hierarchy, it cannot show a fallback for uncached or runtime data access in the layout itself, such as calling cookies(), headers(), or making uncached fetches. Without Cache Components: the navigation will block until the layout finishes rendering, and the loading.js fallback will not be shown." Our signed-in frame, src/app/(workspace)/layout.tsx, does exactly that: it awaits the session, the first-run check, the building list, two settings reads, the company modules, the selection, and one read per company you hold — before any child can paint. Every page's skeleton sits below it. The prescribed fix is the one the docs give: wrap the layout's runtime work in its own <Suspense> with a frame skeleton as the fallback. The prospects lane owns that change (it also found the second half: a heavy page's data work slows the layout's last reads from 100 ms to 2.7 s because they share one event loop).
This also explains a June-2026 finding that "loading.tsx never fires on navigation" and the resulting rule in page-suspense-pattern.drift.test.ts. It was the layout blocking, not React. Tonight's production walk-through shows the skeleton on a click-in at 38–87 ms for pages that have one.
Production tonight, from the dashboard, after the rail's prefetch settled. Times from the click.
| Click to | Skeleton on screen | Real rows on screen | What the wait is |
|---|---|---|---|
| Tenants | 38 ms · 87 ms | 3.8 s · 4.0 s | one "active tenants" question per building for every building in view, then identity and occupancy maps |
| Properties | 57 ms · 65 ms | 4.2 s · 5.0 s | four questions per building (units, work orders, conversations, leases) × every building |
| Conversations | no skeleton (page has none) | 176 ms · 203 ms (then its own ghost, then rows) | the island fetches after mount; the page itself has no loading file |
So on a click the app is already "responding"; what people feel is 4 seconds of skeleton. And the beacon could not see any of this until tonight: 96% of the click-in paint times it recorded (1,326 of 1,379) were copies of the fresh open's number — the vitals hook re-registers on every render and replays the buffered paint entries. First PR fixes the instrument and adds a real click-to-paint number, so the "click-in under 1 s" goal on the board can be scored.
| Page | First paint | Real content | Reading |
|---|---|---|---|
| Dashboard | 1.7 s · 1.1 s | 2.5 s · 1.4 s | frame waited on the layout; numbers come from a 30 s per-instance cache |
| Tenants | 0.84 s · 0.66 s | 4.0 s · 4.5 s | skeleton fine; server read is the wait |
| Conversations | 3.1 s · 0.55 s | 3.1 s · 0.6 s | first run hit a cold server instance; no skeleton file |
| Work orders | 0.73 s · 0.56 s | 0.73 s · 0.56 s | the index works — rows arrive with the frame |
| Prospects | 4.2 s · 2.4 s | 13.4 s · >12 s | the prospects lane's page; whole read forced by the mirror company |
| Settings | 0.37 s · 0.53 s | 1.5 s · 1.1 s | fine |
| Properties | 0.52 s · 0.36 s | 4.1 s · 4.6 s | skeleton fine; per-building fan-out is the wait |
Smoke user = platform staff with every company in view, so these are the staff path (85% of real traffic). A customer with one company reads fewer buildings; the shape is the same.
Paint times are fresh opens only (click-ins carried no real number until this week). "Users / companies" are distinct people and companies who opened the page.
| Page | Views | Fresh / click-in | Users / cos. | First paint p50 | p95 | Largest paint p50 | p95 |
|---|---|---|---|---|---|---|---|
| /agents (staff monitor) | 414 | 387 / 27 | 4 / 2 | 2.9 s | 8.1 s | 4.9 s | 13.5 s |
| /dashboard | 342 | 172 / 170 | 24 / 7 | 2.0 s | 7.6 s | 3.5 s | 14.0 s |
| /leasing/prospects | 303 | 108 / 195 | 7 / 2 | 6.8 s | 13.1 s | 7.0 s | 14.4 s |
| /leasing/prospects/[id] | 228 | 21 / 207 | 6 / 2 | 4.2 s | 10.0 s | 4.3 s | 11.0 s |
| /people/[id]/conversation | 176 | 96 / 80 | 9 / 4 | 3.5 s | 9.2 s | 4.2 s | 9.6 s |
| /conversations | 135 | 111 / 24 | 5 / 3 | 2.2 s | 8.2 s | 4.2 s | 10.9 s |
| /admin/companies/[id] | 119 | 73 / 46 | 4 / 0 | 1.7 s | 8.0 s | 3.3 s | 8.1 s |
| /admin/companies | 104 | 13 / 91 | 4 / 0 | 3.2 s | 4.1 s | 4.5 s | 7.0 s |
| /leasing (hub) | 61 | 2 / 59 | 9 / 3 | 8.4 s (n=2) | — | 1.4 s | — |
| /settings | 56 | 9 / 47 | 16 / 6 | 3.4 s | 7.5 s | 1.5 s | 7.5 s |
| /maintenance/work-orders/[id] | 50 | 49 / 1 | 2 / 0 | no paint rows recorded — cause not established; this row folds in the retired /maintenance/[displayId] path, which redirects and never paints | |||
| /properties | 23 | 3 / 20 | 8 / 3 | 3.5 s | 11.2 s | 11.5 s | — |
| /leasing/tours | 24 | 1 / 23 | 5 / 2 | 5.5 s (n=1) | — | — | — |
Traps checked: views per day run 79–375 with weekends low and 21–22 Sep high (our own testing) — no single day drives a percentile. Renamed routes were merged (/clara/conversations → /conversations).
| Endpoint | Requests | p50 | p95 | Server-hours | Who waits |
|---|---|---|---|---|---|
| /api/dashboard/stats | 1,383 | 5.7 s | 15.4 s | 2.2 | dashboard numbers on a cache miss (30 s TTL = poll interval, per instance) |
| /api/leasing/prospects | 4,058 | 3.8 s | 11.8 s | 4.3 | prospects list, every 5 s per open tab |
| /api/leasing/stats | 5,981 | 2.8 s | 12.2 s | 4.6 | dashboard, leasing hub, prospects — on a miss |
| /api/properties | 399 | 2.9 s | 6.7 s | 0.3 | properties list, every 5 s per open tab |
| /api/conversations | 22,747 | 0.77 s | 1.7 s | 4.8 | conversations list polling; reads every thread of every building in view, sorts in memory |
| /api/work-orders | ~600 | 0.1–0.9 s | 1.9 s | ≈0 | after the index (from 19 Sep) — the shape to copy |
| /api/auth/me | 39,231 | 89 ms | 435 ms | 1.0 | fine |
loading.tsx at all — Conversations (the #4 page), the Leasing and Maintenance hubs, Review, Mass sends ×3, Agents, Admin › Companies ×2, Phone numbers, Escalation holds, Collections › demand, and three dev tools — plus 16 redirect stubs that need none (Costs, Users, Settings, System settings, Renewals board, the retired maintenance and conversations sub-routes). PR #11010 ships the guard and the first four; its allowlist names the other twelve as pending. A click into those shows the previous page until the server answers.staleTimes.dynamic = 0 (Next 15+ default) → every return visit re-renders on the server. Next data cache / route config: unused; every workspace page is dynamic by construction (cookies). Server 30 s caches: dashboard stats, leasing stats (inputs + fold), tenant summaries — per Vercel instance, TTL equal to the poll interval so every second poll recomputes; leasing stats gained stale-while-revalidate on the API path only (#10843), the page's own server render still recomputes on a miss. Browser sessionStorage warm-nav cache (useScopedListCacheKey, keyed by viewer + company + buildings): properties, tenants, vendors, work orders, conversations, costs, routine, admin pages — this is why a re-visit paints the old rows instantly and then refreshes; it is client-only, so the server still pays. Nothing is cached before it is scoped, which is correct — the keys carry the caller's scope.Seconds are what a person sees; sources are the tables above. "Touches production data" = a step the driver runs by hand (green-lit 23 Sep), never a lane.
| # | Do | Buys | Costs | Owner / status | Touches production data? |
|---|---|---|---|---|---|
| 1 | Fix the instrument: vitals belong to the fresh open only; add a real click-to-paint number (navMs) to every click-in. | The board's "click-in < 1 s" becomes measurable; the 96% of copied rows stop. | 1 PR, ~150 lines. | app-speed — #10975 merged 23 Sep 06:21Z | No |
| 2 | Stream the frame: Suspense around the layout's runtime work with a frame skeleton; company names and the first-run check off the serial tail. | First paint on a fresh open from 2.5 s median → the public-page floor (~0.5 s); the 8 s tail collapses to the cold-start cost. | 2 PRs; a redirect from inside the boundary becomes a client redirect (middleware already gates auth). | prospects-8s lane — #10981 open | No |
| 3 | A loading.tsx for the 16 pages without one (the 16 redirect stubs are allowlisted); a guard test that reds when any signed-in page lacks one (with a mutation control); role="status" on every ghost so the instrument can see it. | Click-in skeleton in <100 ms on every page instead of a held old page. | 2–3 PRs, composed from the arsenal. | app-speed — #11010 open (part 1 of 3) | No |
| 4 | Time-stamp the data path: one shared checkpoint helper; Server-Timing header + one structured log line per request; checkpoints on auth → selection → each read → fold → present, starting with work orders and leasing stats, then every page. | Production shows A→B→C→D per page; the slow chunk is obvious without a lane re-measuring. | 3+ PRs; ~1 µs per mark. | app-speed — #10977 merged; #10988, #10991 approved; #10998, #11007 in review | No |
| 5 | Conversations reads its first page off the CLIST index (single company = one query; several = one per company, merged), with a fallback while the index does not exist; add-index + backfill scripts mirroring work orders. | /api/conversations from every-thread-then-sort to one bounded query; 22.7k requests / 15 d. | 3 PRs + the production steps. | app-speed — #11008 open (scripts + dry-run counts); the reader follows | Yes — driver runs: backfill --apply → dry-run reads 0 → create index → reader is live |
| 6 | Tours calendar index (TCAL): #10908/#10910/#10913 resume under the green light. | Calendar/tours read a date range instead of every tour per building. | Already written. | driver | Yes — driver runs |
| 7 | Prospects: put the mirror company's rows on the PLIST keyset (design MIRROR-PROJECTION-DESIGN.md). | ~1.5 s off every prospects render and every 5 s poll; 2.35 MB → ~0.3 MB. | Design done; backfill of ~1,050 guest-card rows. | prospects-8s lane | Yes — driver runs the backfill |
| 8 | Router cache: staleTimes.dynamic = 30 so a return visit within 30 s is instant — only after the building picker calls router.refresh() on a pick (today it writes the cookie and nothing invalidates the cached page). | Back/return visits: 3–5 s → instant. | 1 config line + 1 picker hunk (in #10976, the picker lane's PR). | app-speed, once #10976 merges | No — but the guard is a hard precondition (a stale page under a new building pick is the spill you named) |
| 9 | Server caches: the dashboard and leasing stats already serve stale-while-revalidate past their 30 s TTL (#10843, #10850); a scope-keyed 30 s cache for the tenants and properties seeds (PR #11005 — a repeat open or the properties island's 5 s poll inside the window reads nothing; a just-imported tenant list re-reads fresh by design); skip the island's duplicate first poll when the server just seeded it. | Repeat visits and polls inside 30 s cost zero partition reads; every open costs one read instead of two. | 3 small PRs, each with a before/after read count. | app-speed — #11005 open | No (keys carry the scope) |
| 10 | Properties and tenants: stop the per-building fan-out (a per-building summary row written by the same writers that change it, or the counts read from the existing indexes). | 4–5 s → <1 s on those two pages. | Design + 2–3 PRs; a backfill for the summary row. | next, after 4 shows the split | Yes if a summary row is written (driver runs) |
| 11 | Turnovers list index (building partition; the row carries no company id). | Turnovers list stops walking the base table per building. | keys + writers + backfill. | app-speed sub-agent, after 5 | Yes — driver runs |
| 12 | Cold starts: function warmth/size, and the HEAD prerender double-boot. | The 2.6 s boot on ~10% of opens. | one investigation pass. | app-speed | No |
You gave the go-ahead for schema changes (new indexes, backfills, shipping with the switch on) on 23 Sep, so nothing below waits on a second yes. Each follows the work-orders sequence the driver already ran once — for work orders it was create the index → backfill dry-run → apply → dry-run reads 0 → flip; for conversations the order is reversed (backfill first, index last) because its reader ships live with a fallback. Nothing here runs from a lane; the driver runs each step and reports the count.
| List | Index | Today | Dry-run count | Step |
|---|---|---|---|---|
| Work orders | work-order-list-index | Live 4,383 rows, backfilled 22 Sep | done | — |
| Prospects | prospect-list-index | Live, incomplete 962 rows; the mirror company is not in it | pending (mirror rows ≈1,050 guest cards) | #7 above |
| Conversations | conversation-list-index (CLIST) | Dark stamped by writers since #10588, never created on prod, read by nothing | read-only dry run on prod, 23 Sep 05:41Z: 3,833 conversation rows; 3,680 to stamp; 87 already right; 0 to repair; 63 unrouted (no company, left alone by design) + 3 with no company under a building (the org closer's job). Scripts: PR #11008. | #5 above — order is REVERSED vs work orders: run the backfill → dry-run reads 0 → then create the index (the reader ships live with a fallback, and a new index only fills from rows that already carry keys) |
| Tours / calendar | tour-calendar-index (TCAL) | 3 PRs open, resuming | in the PRs | #6 above |
| Turnovers | none | Base-table walk per building | pending | #11 above |
Sources: propflow-traffic raw partitions TRAFFIC#<date>#0..15 and STATDAY# rollups, 2026-09-09..23, read-only, identifiers hashed in memory; production walk-through prod-nav-timing.mjs (lane scratch), smoke user, two runs per page; prospects lane WATERFALL.md (23 Sep); dashboard and prospects measurement docs (22 Sep); Next.js 16.3 bundled docs layout.md, loading.md, staleTimes.md.