Performance & Perceived Speed
Performance is a UX issue, not just an engineering issue. A 1-second delay in page load time reduces conversions by 7% (Akamai). A page that takes over 3 seconds to load loses 53% of mobile visitors (Google). Speed is the most important UX feature you'll never see in a design mockup.
Core Web Vitals
Google's Core Web Vitals are the three metrics that matter most for user experience. They affect search rankings and directly correlate with user satisfaction and conversion.
| Metric | Target | Measures | User Experience Impact |
|---|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | Loading performance: when does the main content appear? | "Is this page loading?" |
| INP (Interaction to Next Paint) | < 200ms | Responsiveness: how fast does the page react to input? | "Did my click do anything?" |
| CLS (Cumulative Layout Shift) | < 0.1 | Visual stability: does content jump around? | "I was about to click that and it moved!" |
Measuring Core Web Vitals
| Tool | Type | When to Use |
|---|---|---|
| Chrome DevTools → Performance | Lab data | During development |
| Lighthouse | Lab data | Automated audits, CI/CD integration |
| PageSpeed Insights | Lab + Field data | Quick check with real-user data from CrUX |
| Web Vitals Chrome Extension | Lab data | Real-time monitoring during development |
| Google Search Console | Field data | Monitoring across entire site over time |
| Chrome UX Report (CrUX) | Field data | Real user data at scale |
| web-vitals JS library | Field data | Custom analytics integration |
// Measure Core Web Vitals with the web-vitals library
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(metric => sendToAnalytics('LCP', metric.value));
onINP(metric => sendToAnalytics('INP', metric.value));
onCLS(metric => sendToAnalytics('CLS', metric.value));
LCP: Largest Contentful Paint
The time it takes for the largest visible content element (image, heading, text block) to render.
Common causes of poor LCP:
| Cause | Fix |
|---|---|
| Slow server response | Use a CDN, optimize server-side rendering, upgrade hosting |
| Render-blocking CSS/JS | Inline critical CSS, defer non-critical JS |
| Large unoptimized images | Compress images, use modern formats (WebP/AVIF), proper sizing |
| Web fonts blocking render | Use font-display: swap, preload key fonts |
| Client-side rendering delay | Server-side render the critical content |
<!-- Preload the LCP image -->
<link rel="preload" as="image" href="/hero-image.webp">
<!-- Preload critical font -->
<link rel="preload" as="font" type="font/woff2"
href="/fonts/inter-var.woff2" crossorigin>
INP: Interaction to Next Paint
Measures the delay between a user interaction (click, tap, key press) and the next visual update. Replaced FID (First Input Delay) in 2024.
Common causes of poor INP:
| Cause | Fix |
|---|---|
| Long JavaScript tasks blocking main thread | Break tasks into smaller chunks using requestIdleCallback or setTimeout |
| Expensive re-renders | Optimize React/Vue rendering, use memo, virtualize long lists |
| Heavy event handlers | Debounce/throttle scroll and resize handlers |
| Large DOM | Reduce DOM nodes, use virtualization for large lists |
| Third-party scripts | Audit and remove unnecessary scripts, load async |
CLS: Cumulative Layout Shift
Measures how much page content unexpectedly shifts during the page lifecycle.
Common causes of CLS:
| Cause | Fix |
|---|---|
| Images without dimensions | Always set width and height attributes (or aspect-ratio in CSS) |
| Ads or embeds loading late | Reserve space with fixed dimensions |
| Web fonts causing text reflow | Use font-display: swap + size-adjust for close metrics |
| Dynamic content inserted above viewport | Insert below the fold, or use a placeholder |
| Late-loading CSS | Inline critical CSS, preload stylesheets |
<!-- Always specify image dimensions to prevent CLS -->
<img src="photo.webp" width="800" height="600" alt="Description"
style="max-width: 100%; height: auto;">
/* Reserve space with aspect-ratio */
.video-container {
aspect-ratio: 16 / 9;
width: 100%;
background: #f0f0f0;
}
Response Time Psychology
How users perceive different wait times:
| Duration | User Perception | Required Feedback | Example |
|---|---|---|---|
| < 100ms | Instant | None | Typing, button hover |
| 100-300ms | Slight delay | Button state change | Click response, toggle |
| 300ms-1s | Noticeable | Subtle indicator | Content loading, spinner |
| 1-3s | User is waiting | Progress indicator or skeleton | Page load, API response |
| 3-5s | Attention wanders | Progress bar + estimate | File upload, search |
| 5-10s | Frustration | Progress bar + time estimate + allow cancel | Large file upload |
| > 10s | Task abandoned | Background processing + notification | Video processing, export |
The Doherty Threshold
When system response is under 400ms, users and computers establish a conversational rhythm. Productivity increases dramatically. Above 400ms, users lose their flow state. Target < 400ms for all common interactions.
Perceived Performance Techniques
You can't always make things actually faster. But you can make them feel faster.
1. Optimistic UI
Show the result immediately, sync with the server in the background.
User clicks "Like":
┌────────────────────────┐
│ ♡ 42 likes │ User clicks heart
└────────────────────────┘
↓ (instant)
┌────────────────────────┐
│ ♥ 43 likes │ Immediately updated (no spinner)
└────────────────────────┘
↓ (background)
Server saves. If it fails → revert to ♡ 42 + show error toast.
Use for: Likes, favorites, toggles, adding to lists, marking as read. Don't use for: Payments, deleting data, sending messages.
2. Skeleton Screens
Show the structure of the content while it loads. Better than spinners because they set expectations about the layout.
Loading... vs. Skeleton:
┌────────────────────────┐ ┌────────────────────────┐
│ │ │ ░░░░░░░░░░░░░░░░░░░░ │
│ │ │ │
│ ⟳ Loading... │ │ ░░░░░░░░░░░░░░░░░░░░░ │
│ │ │ ░░░░░░░░░░░░░░░░░░░░░ │
│ │ │ ░░░░░░░░░░░░ │
│ │ │ │
│ │ │ ░░░░░░░░ │
└────────────────────────┘ └────────────────────────┘
"I have no idea what's coming" "I can see what's about to appear"
Rules for skeletons:
- Match the actual content shape (text lines, image placeholders, card outlines)
- Use a shimmer/wave animation (not pulsing; it's distracting)
- Don't show skeletons for content that loads in < 300ms (unnecessary visual noise)
- Replace skeleton with content seamlessly (no flash)
3. Progressive Loading
Load and display content in priority order:
1. First: Text content (fastest, most important)
2. Second: Above-fold images
3. Third: Below-fold images (lazy loaded)
4. Fourth: Interactive features, animations
5. Last: Analytics, non-essential third-party scripts
4. Preloading and Prefetching
Anticipate what the user will do next and load it before they click.
<!-- Preload: high priority, needed for current page -->
<link rel="preload" href="/critical.css" as="style">
<link rel="preload" href="/hero.webp" as="image">
<!-- Prefetch: lower priority, needed for likely next page -->
<link rel="prefetch" href="/dashboard">
<!-- Preconnect: establish connection to third-party origin -->
<link rel="preconnect" href="https://api.example.com">
<!-- DNS Prefetch: resolve DNS for third-party origin -->
<link rel="dns-prefetch" href="https://analytics.example.com">
Smart prefetching:
- Prefetch the most likely next page (e.g., after login → prefetch dashboard)
- Prefetch on hover for navigation links (200ms delay gives ~300ms head start)
- Don't prefetch everything. It wastes bandwidth on mobile
5. Instant Navigation Feedback
Even if the page takes time to load, acknowledge the click immediately:
| Pattern | Implementation |
|---|---|
| Navigation link | Highlight immediately on click, show loading bar at top of page |
| Button submission | Disable button + show spinner within 50ms of click |
| Tab switch | Switch tab indicator immediately, load content in the tab area |
| Search | Show results container immediately, populate as results arrive |
6. Progress Indicators
For operations > 3 seconds, show progress:
| Type | When to Use | Implementation |
|---|---|---|
| Determinate (percentage) | When you know the total work | File upload: "45%, 2.3 of 5.1 MB" |
| Indeterminate (spinner) | When you don't know how long it'll take | API calls, search queries |
| Stepped | Multi-phase operations | "Step 2 of 4: Processing images..." |
| Fake progress | When there's no real progress info | Start at 0%, quickly jump to 60%, slowly to 90%, hold, jump to 100% on completion |
Image Optimization
Images are typically the heaviest assets on a page and the biggest opportunity for performance gains.
Format Selection
| Format | Best For | Browser Support | Size vs. JPEG |
|---|---|---|---|
| JPEG | Photos, complex images | Universal | Baseline |
| PNG | Graphics with transparency | Universal | Larger than JPEG for photos |
| WebP | Photos and graphics | 97%+ | 25-35% smaller than JPEG |
| AVIF | Photos and graphics | 92%+ | 50% smaller than JPEG |
| SVG | Icons, logos, illustrations | Universal | Tiny for simple graphics |
<!-- Use picture element for format fallback -->
<picture>
<source srcset="photo.avif" type="image/avif">
<source srcset="photo.webp" type="image/webp">
<img src="photo.jpg" alt="Description" width="800" height="600"
loading="lazy" decoding="async">
</picture>
Responsive Images
Serve different sizes based on viewport:
<img
srcset="photo-400w.webp 400w,
photo-800w.webp 800w,
photo-1200w.webp 1200w,
photo-1600w.webp 1600w"
sizes="(max-width: 600px) 100vw,
(max-width: 1000px) 50vw,
33vw"
src="photo-800w.webp"
alt="Description"
width="800" height="600"
loading="lazy"
decoding="async"
>
Image Loading Strategy
| Position | Loading Strategy |
|---|---|
| Above the fold (hero, LCP) | Eager load, preload, no lazy loading |
| Just below the fold | Lazy load with loading="lazy" |
| Far below the fold | Lazy load, consider lower quality |
| Thumbnails | Serve tiny sizes (200-400px wide) |
| Background/decorative | Lazy load, consider CSS-only alternatives |
JavaScript and Bundle Optimization
| Technique | Description | Impact |
|---|---|---|
| Code splitting | Split bundle by route/feature, load only what's needed | Major reduction in initial load |
| Tree shaking | Remove unused code during build | 10-50% bundle reduction |
| Dynamic imports | Load features only when needed | Faster initial load |
| Lazy loading routes | Only load route code when navigated to | Faster initial load |
| Dependency audit | Replace heavy libraries with lighter alternatives | Can save 100KB+ |
| Compression | Use Brotli or gzip for text assets | 60-80% transfer size reduction |
// Dynamic import — only load chart library when user visits analytics page
const loadChartLibrary = () => import('./charts');
// Route-level code splitting in React
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));
Font Optimization
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900;
font-display: swap; /* Show fallback immediately */
unicode-range: U+0000-00FF; /* Only load Latin characters */
}
| Strategy | Impact |
|---|---|
| Use variable fonts (single file for all weights) | 1 request instead of 3-5 |
| Subset to needed character ranges | 50-80% smaller font files |
| Self-host instead of Google Fonts | Eliminate third-party request |
Use font-display: swap | No invisible text during load |
| Preload the primary font | Font available sooner |
| Use system font stack as fallback | Instant text rendering |
Performance Budget
Set explicit limits and fail builds that exceed them:
| Resource | Budget |
|---|---|
| Total page weight | < 1.5 MB (mobile), < 3 MB (desktop) |
| JavaScript | < 300 KB (compressed) |
| CSS | < 100 KB (compressed) |
| Images (above fold) | < 200 KB total |
| Web fonts | < 100 KB total |
| LCP | < 2.5 seconds |
| INP | < 200 ms |
| CLS | < 0.1 |
| Time to Interactive | < 3.8 seconds |
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| No image dimensions specified | CLS as images load and push content | Always set width/height on img tags |
| Loading all JS upfront | Massive initial bundle, slow load | Code split by route, lazy load features |
| Serving desktop images on mobile | 3× more data than needed | Use srcset with responsive image sizes |
| Too many third-party scripts | Each script adds DNS lookup + download + execution | Audit scripts quarterly, remove unnecessary ones |
| No caching strategy | Every visit downloads everything again | Set appropriate Cache-Control headers |
| Using GIFs for animations | GIFs are enormous | Use MP4/WebM video or CSS animations |
| Spinner for everything | Doesn't set expectations, feels slow | Use skeleton screens for content loading |
| Not measuring real user performance | Lab data ≠ real experience | Use web-vitals library + real user monitoring |
Key Takeaways
- Core Web Vitals (LCP < 2.5s, INP < 200ms, CLS < 0.1) are the minimum targets. Measure with real user data, not just lab data.
- Perceived speed matters as much as actual speed. Use skeleton screens, optimistic UI, and progressive loading.
- Respond to every user action within 100ms. If the backend takes longer, show feedback immediately and sync in the background.
- Images are the biggest opportunity: use modern formats (WebP/AVIF), responsive sizes, and lazy loading.
- Set a performance budget and enforce it in CI/CD. Performance degrades unless you actively protect it.
- Preload critical resources (LCP image, key font), prefetch likely next pages.
- Every millisecond matters: 100ms delay = 7% lower conversion. Performance is a business metric, not just a technical one.