Why the app feels slow, and what fixes it

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

The short version

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.

What "slow" actually is, measured

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.

Fresh opens (hard loads): first byte at 47 ms, first paint at 2.5 s

Workspace pages (1,127 fresh opens)

first paint median 2.5 s · 75th pct 4.3 s · 95th pct 8.4 s
largest paint median 3.7 s · 95th pct 12.1 s
first byte median 47 ms
customers only (170 opens): median 1.9 s, 95th pct 6.4 s
our staff (957 opens): median 2.8 s, 95th pct 8.4 s

Public pages, same app shell

/login first paint 0.47 s · / (home) 0.57 s
Same fonts, same JavaScript, same host. The difference is entirely the signed-in frame's server work.

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.

The cause, in the framework's own words

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.

Click-ins (soft navigation): the skeleton is instant; the data is not

Production tonight, from the dashboard, after the rail's prefetch settled. Times from the click.

Click toSkeleton on screenReal rows on screenWhat the wait is
Tenants38 ms · 87 ms3.8 s · 4.0 sone "active tenants" question per building for every building in view, then identity and occupancy maps
Properties57 ms · 65 ms4.2 s · 5.0 sfour questions per building (units, work orders, conversations, leases) × every building
Conversationsno 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.

Fresh opens per page, production walk-through (two runs each)

PageFirst paintReal contentReading
Dashboard1.7 s · 1.1 s2.5 s · 1.4 sframe waited on the layout; numbers come from a 30 s per-instance cache
Tenants0.84 s · 0.66 s4.0 s · 4.5 sskeleton fine; server read is the wait
Conversations3.1 s · 0.55 s3.1 s · 0.6 sfirst run hit a cold server instance; no skeleton file
Work orders0.73 s · 0.56 s0.73 s · 0.56 sthe index works — rows arrive with the frame
Prospects4.2 s · 2.4 s13.4 s · >12 sthe prospects lane's page; whole read forced by the mirror company
Settings0.37 s · 0.53 s1.5 s · 1.1 sfine
Properties0.52 s · 0.36 s4.1 s · 4.6 sskeleton 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.

Pages ranked by views × slowness (beacon, 15 days, non-bot)

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.

PageViewsFresh / click-inUsers / cos.First paint p50p95Largest paint p50p95
/agents (staff monitor)414387 / 274 / 22.9 s8.1 s4.9 s13.5 s
/dashboard342172 / 17024 / 72.0 s7.6 s3.5 s14.0 s
/leasing/prospects303108 / 1957 / 26.8 s13.1 s7.0 s14.4 s
/leasing/prospects/[id]22821 / 2076 / 24.2 s10.0 s4.3 s11.0 s
/people/[id]/conversation17696 / 809 / 43.5 s9.2 s4.2 s9.6 s
/conversations135111 / 245 / 32.2 s8.2 s4.2 s10.9 s
/admin/companies/[id]11973 / 464 / 01.7 s8.0 s3.3 s8.1 s
/admin/companies10413 / 914 / 03.2 s4.1 s4.5 s7.0 s
/leasing (hub)612 / 599 / 38.4 s (n=2)—1.4 s—
/settings569 / 4716 / 63.4 s7.5 s1.5 s7.5 s
/maintenance/work-orders/[id]5049 / 12 / 0no paint rows recorded — cause not established; this row folds in the retired /maintenance/[displayId] path, which redirects and never paints
/properties233 / 208 / 33.5 s11.2 s11.5 s—
/leasing/tours241 / 235 / 25.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).

Server time per request (API rollups, 15 days)

EndpointRequestsp50p95Server-hoursWho waits
/api/dashboard/stats1,3835.7 s15.4 s2.2dashboard numbers on a cache miss (30 s TTL = poll interval, per instance)
/api/leasing/prospects4,0583.8 s11.8 s4.3prospects list, every 5 s per open tab
/api/leasing/stats5,9812.8 s12.2 s4.6dashboard, leasing hub, prospects — on a miss
/api/properties3992.9 s6.7 s0.3properties list, every 5 s per open tab
/api/conversations22,7470.77 s1.7 s4.8conversations list polling; reads every thread of every building in view, sorts in memory
/api/work-orders~6000.1–0.9 s1.9 s≈0after the index (from 19 Sep) — the shape to copy
/api/auth/me39,23189 ms435 ms1.0fine

Where the time goes, layer by layer

The plan, ranked

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.

#DoBuysCostsOwner / statusTouches production data?
1Fix 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:21ZNo
2Stream 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 openNo
3A 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
4Time-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 reviewNo
5Conversations 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 followsYes — driver runs: backfill --apply → dry-run reads 0 → create index → reader is live
6Tours 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.driverYes — driver runs
7Prospects: 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 laneYes — driver runs the backfill
8Router 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 mergesNo — but the guard is a hard precondition (a stale page under a new building pick is the spill you named)
9Server 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 openNo (keys carry the scope)
10Properties 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 splitYes if a summary row is written (driver runs)
11Turnovers 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 5Yes — driver runs
12Cold starts: function warmth/size, and the HEAD prerender double-boot.The 2.6 s boot on ~10% of opens.one investigation pass.app-speedNo

The production steps — green-lit by you on 23 Sep; the driver runs them

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.

ListIndexTodayDry-run countStep
Work orderswork-order-list-indexLive 4,383 rows, backfilled 22 Sepdone—
Prospectsprospect-list-indexLive, incomplete 962 rows; the mirror company is not in itpending (mirror rows ≈1,050 guest cards)#7 above
Conversationsconversation-list-index (CLIST)Dark stamped by writers since #10588, never created on prod, read by nothingread-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 / calendartour-calendar-index (TCAL)3 PRs open, resumingin the PRs#6 above
TurnoversnoneBase-table walk per buildingpending#11 above

What this does not say

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.

PropFlow Docs