name: perceived-performance description: Make web UIs feel instant even when operations take time — skeleton screens, optimistic UI, loading states, and navigation feedback. Use when a page feels slow or unresponsive, shows a blank screen while loading data, has clicks or form submissions with no immediate feedback, page transitions feel laggy, or images are slow and hurting LCP. Also use when the user asks about loading.tsx, Suspense streaming, useOptimistic, useFormStatus, SWR, prefetching, virtual scrolling, startTransition, or auth fetching making pages feel slow.
Progressive Web UI
Perceived performance is about making the UI feel fast, not just measuring fast. A page that loads in 2s but shows a skeleton immediately feels faster than one that loads in 1.5s but shows a blank screen the whole time.
When the user shares code:
- Read it to understand what users currently see during loading and waiting states
- Identify which technique(s) apply using the decision guide below
- Explain what to change, why it improves perceived speed, and how to implement it with concrete code
Core Principles
1. Separate "first view" from everything else
Always distinguish:
- What is required for the first paint
- What can load after the shell is visible
- What can wait for user interaction
Do not treat all data as equally urgent.
2. Structure before data
The page shell (layout, nav slots, header frame) should be sent to the browser before any data fetch completes. Users form speed impressions in the first 100–200ms. Showing structure immediately reduces perceived wait even if actual load time is identical.
3. Never let a click go unacknowledged
Every interaction (click, tap, navigation) must produce a visible response in the first frame. If the real operation takes time, show an intermediate state immediately.
4. Fix the structure, not just the loading UI
loading.tsx and skeletons improve appearance, but they don't fix over-fetching or sequential data dependencies. Always address the root cause:
- Sequential
awaitchains → parallelize withPromise.all - Single large fetch → split into first-view vs. deferred
- Full profile fetch in nav → use a lightweight nav identity helper
5. Navigation must feel fast too
Perceived speed isn't only about initial load. Page transitions, repeated visits, and interactions must feel equally fast:
- Prefetch routes before the user clicks
- Show
loading.tsximmediately on navigation start - Use SWR/cache for instant return visits
Decision Guide
| Symptom | Technique |
|---|---|
| Blank screen while data loads | Skeleton screens / Suspense streaming |
| Button click has no immediate response | Optimistic UI / instant feedback |
| Page transition doesn't visually start after click | loading.tsx + prefetch |
| Heavy component slows initial render | Dynamic import |
| Spinner appears on every visit to the same data | SWR stale-while-revalidate |
| One slow API blocks an entire page | Suspense streaming (parallel async Server Components) |
| Auth/profile fetch delays nav or page shell | Auth layout separation (see references/auth-performance.md) |
| Images cause layout shift or feel slow | next/image with priority / blur placeholder |
| Font swap causes flash of unstyled text | next/font |
| Form submission has no visual feedback | useFormStatus / useActionState |
| Long list causes scroll jank or slow render | Virtual scrolling (TanStack Virtual / react-window) |
| Non-urgent state update blocks user input | startTransition for deferred rendering |
Analysis Workflow
Step 1: Identify what users see first
What renders in 0–200ms? If it's blank or a spinner, structural changes are needed before styling fixes.
Step 2: Classify data by urgency
- First view — must be present for the page to make sense
- Deferred — can stream in after the shell is visible
- On-demand — load only after user interaction
Step 3: Fix data dependencies
- Sequential
await→Promise.allor separate async Server Components - Over-fetching on initial load → split API or paginate
- Full profile in nav → separate nav identity helper
Step 4: Apply progressive rendering
Choose based on what's needed:
loading.tsx— instant navigation skeleton (whole page)<Suspense>— granular streaming (per section)useOptimistic— instant mutation feedbackdynamic()— defer heavy component bundle- SWR — instant return visits from cache
Step 5: Validate perceived performance
- Does something meaningful appear immediately (< 200ms)?
- Is the user informed during all loading states?
- Is the UI responsive before full data arrives?
- Does interaction produce feedback in the first frame?
Output Format
When applying this skill, respond in this structure:
## What's happening now
- [describe what users currently experience]
## Root cause
- [what in the code causes the slow feeling]
## What to change and why
- [change 1]: [why this improves perceived speed]
- [change 2]: ...
## Implementation
[code]
## Notes
- [any caveats, edge cases, or follow-up improvements]
References
For implementation details with full code examples, see:
references/nextjs-patterns.md— Skeleton screens, optimistic UI, Suspense streaming, prefetch, dynamic import, SWR, images, fonts, interaction feedback,useFormStatus, virtual scrolling,startTransitionreferences/auth-performance.md— Auth layout separation, nav identity helpers, request/process warm-up, fallback state design
Read the relevant reference when implementing a specific technique.
Anti-Patterns to Fix
Always detect and address:
- Awaiting full profile/auth in top-level layout → delays every page
- Sequential
awaitchains for independent data → unnecessary serialization loading.tsxadded without fixing the underlying over-fetch- Rendering entire datasets on initial load → paginate or virtualize
- No visual feedback on click until operation completes → acknowledge immediately
- Different auth sources in nav vs. page → canonical username inconsistency
- Form submission state managed only after server responds → use
useFormStatusfor instant pending state - Rendering all items in a long list on mount → virtualize (only render visible rows)
- Expensive state update (filter/sort/search) blocking user input → wrap in
startTransition