Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Reference-grade guide to performance as a UX concern — Core Web Vitals (LCP/INP/CLS with concrete budgets and fixes), perceived-performance technique (optimistic UI, skeletons vs spinners, instant feedback, prefetch on intent), loading-state and image/font design, and field-vs-lab measurement at p75.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-12 | ✗→✓ | ▲ Improved | 282% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 260% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 200% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 225% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 237% | 0% |
Speed is not an engineering metric that happens after design — it is a design material. Every layout decision, image choice, font load, and interaction reserves or spends a latency budget the user feels. Treat performance like contrast or hierarchy: a property you design for, measure, and defend.
Latency is the most consistent driver of conversion, trust, and retention that exists across every product category.
| Threshold | Feels like | Design implication | |---|---|---| | 0–100ms | Instant; direct manipulation | Every tap/click/keypress MUST produce visible feedback inside this window | | ~400ms (Doherty threshold) | The pace at which users stay productive and engaged | Target sub-400ms for the common-case round trip; below this, usage and satisfaction rise together | | ~1s | Noticeable but keeps flow of thought | Keep navigation/transitions under 1s or the user's mental context starts to fray | | ~10s | Attention lost; user switches tasks | Beyond this, hold attention with determinate progress or abandon the synchronous model |
The Doherty threshold (IBM, 1982) is the headline: when the system responds in under 400ms, the human and machine fall into a fast feedback loop and the user does more work, not less. Above it, the human slows to the machine's pace.
Three metrics, each with a "good / needs-improvement / poor" band. Pass = the 75th-percentile (p75) real-user value is in the "good" band. Lab tools (Lighthouse) approximate; field data (CrUX/RUM) decides.
Time until the largest above-the-fold element (usually a hero image, video poster, or headline block) renders. It is a loading-perception metric, not a "fully loaded" metric.
<link rel="preload"> the LCP image with fetchpriority="high"; never lazy-load the LCP image (loading="eager"); inline critical CSS, defer the rest; serve the right image size via srcset/sizes; avoid client-only rendering for above-the-fold content (SSR/SSG/streaming). Make the LCP element discoverable in the initial HTML.html<!-- LCP hero: discoverable, eager, prioritized, correctly sized --> <link rel="preload" as="image" href="/hero-1280.avif" fetchpriority="high"> <img src="/hero-1280.avif" srcset="/hero-640.avif 640w, /hero-1280.avif 1280w, /hero-2560.avif 2560w" sizes="100vw" width="1280" height="720" loading="eager" fetchpriority="high" alt="">
INP replaced FID as a Core Web Vital in March 2024. Where FID measured only input delay of the first interaction, INP measures the full latency of every interaction — input delay + processing time + presentation delay — and reports the worst (near-worst) one across the visit. It is the responsiveness metric: does the UI react when I poke it?
await scheduler.yield() / setTimeout/isInputPending); show feedback before doing work (set pending state synchronously, compute after a paint); debounce/throttle high-frequency handlers; move heavy compute to web workers; useTransition/startTransition in React to keep input responsive; virtualize long lists; avoid layout thrash (batch reads then writes). Budget: keep any single interaction's handler work under ~200ms end-to-end; aim for <50ms main-thread blocks.js// Feedback first, heavy work after a paint — keeps INP low button.addEventListener('click', () => { button.setAttribute('aria-busy', 'true'); // synchronous, visible next paint requestAnimationFrame(async () => { // let the paint happen await scheduler.yield(); // yield between chunks of work const result = doExpensiveWork(); // now the heavy part render(result); button.removeAttribute('aria-busy'); }); });
Sum of unexpected layout shifts — content jumping after it's already visible. The most design-owned vital: almost every cause is a missing dimension reservation.
aspect-ratio), ads/embeds/banners injected with no reserved slot, web fonts causing FOUT/FOIT reflow when the fallback and final font have different metrics, content inserted above existing content (cookie bars, "you may also like"), dynamically sized components that grow after load.width/height attributes or aspect-ratio on media so the browser reserves the box; reserve fixed-size slots for ads/embeds/skeletons; skeletons must match the final layout's dimensions so swap-in causes zero shift; control font swap with font-display: optional or swap plus size-adjust/@font-face metric overrides (ascent-override, descent-override, size-adjust) to match fallback metrics; never insert content above the fold after paint; use min-height on containers that fill asynchronously; trigger user-driven expansions with transform, not layout.css/* Reserve the box before content arrives → zero CLS */ .media { aspect-ratio: 16 / 9; width: 100%; } /* image/video slot */ .ad-slot { min-height: 250px; } /* reserve ad space */ @font-face { /* fallback matches metrics */ font-family: "Brand"; src: url(/brand.woff2) format("woff2"); font-display: optional; size-adjust: 102%; ascent-override: 90%; descent-override: 22%; }
Users don't have stopwatches; they have feelings. A 2s wait with immediate feedback and a skeleton feels faster than a 1s wait staring at a frozen, unresponsive UI. Engineer the perception, not only the milliseconds.
Update the interface immediately as if the action succeeded, then reconcile with the server in the background.
jsx// React 19: optimistic update reconciles automatically on settle/error const [optimistic, addOptimistic] = useOptimistic(messages, (cur, m) => [...cur, m]); async function send(text) { addOptimistic({ text, pending: true }); // instant, 0ms perceived try { await api.send(text); } // reconciles with real state on resolve catch { toast('Couldn't send — tap to retry'); } // auto-rolls back on throw }
| Pattern | Use when | Why | |---|---|---| | Instant feedback only (state change, ripple, button press) | < 100ms | No loader needed; just react | | Spinner / indeterminate | ~100ms–1s, unknown duration, single small region | Cheap, communicates "working"; jarring if it flashes — delay showing it ~200ms so fast responses never flash a spinner | | Skeleton screen | Loading structured layout (cards, lists, profiles), duration > ~300ms | Communicates what is coming and where; reserves space → zero CLS; feels faster than a spinner because the brain pre-loads the shape | | Determinate progress bar / percentage | Known, longer operations > ~3–10s (uploads, exports, installs, multi-step jobs) | Reduces anxiety by bounding the wait; show real progress, never fake-stall at 99% |
Rule: spinners say "wait," skeletons say "here's what's coming," progress bars say "and here's how long." Match the message to the situation.
Every interactive element must acknowledge input within 100ms — button depresses, row highlights, tab switches active state — before the underlying work finishes. Set the pending/active state synchronously on the event, then start async work. A button that does nothing for 300ms after a click feels broken even if the result arrives quickly.
Show the most important content first; stream the rest. Server-render and stream HTML (React Server Components / streaming SSR / Suspense) so the user reads the headline while the comments hydrate. Render above-the-fold immediately, defer below-the-fold. Partial content beats a blank screen every time.
hover, mousedown, touchstart, or viewport-entry — the click then resolves instantly. (<link rel="prefetch">, route prefetching, IntersectionObserver.) hover gives ~100–300ms of head start before the click; mousedown gives ~80ms but never wastes bandwidth on accidental hovers.jslink.addEventListener('mouseenter', () => prefetch(link.href), { once: true }); link.addEventListener('mousedown', () => prefetch(link.href), { once: true });
Loading is a first-class screen, not an afterthought. Design the empty, loading, error, and partial states with the same care as the success state.
prefers-reduced-motion: reduce (cross-ref the interaction-and-motion skill).Media is usually the largest, most controllable performance lever — and it's a designer's call.
srcset + sizes so each device downloads an appropriately sized asset — never a 2000px image into a 360px slot.loading="lazy"); eager-load the LCP/hero image (loading="eager" + fetchpriority="high" + preload). Lazy-loading the hero is a classic LCP-killer.width/height or aspect-ratio) to prevent CLS.font-display: swap (show fallback immediately, swap when ready — risk of FOUT shift) or optional (use fallback, only swap if the font is already cached — best for CLS). Avoid the default block/FOIT (invisible text up to 3s).<link rel="preload" as="font" crossorigin> the critical web font so it loads early.size-adjust / ascent-override / descent-override so the swap doesn't reflow text (kills CLS from font swap).react-window/virtual). Mounting 1,000 rows wrecks INP and scroll.content-visibility: auto to skip rendering offscreen sections.Assume the network is flaky, not absent-or-perfect.
web-vitals library to collect LCP/INP/CLS from real sessions, segmented by device class and connection. Throttle to a mid-tier mobile + slow 4G when testing — not your fast laptop on fiber.jsimport { onLCP, onINP, onCLS } from 'web-vitals'; const send = (m) => navigator.sendBeacon('/rum', JSON.stringify(m)); // device-class tagged server-side onLCP(send); onINP(send); onCLS(send);
transform and opacity. These run on the compositor thread, off the main thread, and don't trigger layout or paint — so they stay at 60fps (120 on ProMotion) even when JS is busy.width, height, top/left, margin, box-shadow directly — they force layout/paint every frame and cause jank.will-change: transform sparingly (it costs memory); remove it after.scroll/resize handlers doing layout work, animating layout properties.Other measured skills in the registry, with their headline benchmark lift.