Mobile-First Performance — Complete Guide
In this tutorial, you will learn about Mobile. We cover key concepts, practical examples, and best practices to help you master this topic.
Mobile-first performance optimizes critical rendering path, reduces JavaScript payloads, implements lazy loading, and measures Core Web Vitals for fast mobile experiences.
What You'll Learn
- Critical rendering path optimization
- JavaScript code splitting and tree shaking
- Lazy loading images and components
- Core Web Vitals (LCP, FID, CLS)
- Network optimization (CDN, compression)
- Caching strategies for mobile
- Performance measurement and monitoring
Why It Matters
- Mobile users expect sub-3-second load times
- 53% of mobile users abandon sites that take over 3 seconds
- Slow sites rank lower in Google search
- Core Web Vitals are a ranking factor
Real-World Use
- An e-commerce site achieving 1.2s LCP on 3G
- A news site with lazy-loaded images below the fold
- A PWA that loads instantly from cache
- A dashboard that ships 30KB JavaScript vs 300KB
flowchart LR A[Mobile Performance] --> B[CRP] A --> C[JavaScript] A --> D[Images] A --> E[Network] B --> F[Critical CSS] C --> G[Code splitting] D --> H[Lazy loading] E --> I[CDN + caching]
Critical Rendering Path
The critical rendering path is the sequence of steps the browser takes to render the first pixel. Optimizing it is the highest-impact performance work.
Code Example: Critical CSS Inlining
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mobile-First Performance</title>
<!-- Critical CSS (inlined, < 14KB) -->
<style>
/* Above-the-fold styles only */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1f2937; background: #fff; line-height: 1.6; }
.hero { padding: 2rem 1rem; text-align: center; min-height: 60vh; display: flex; flex-direction: column; justify-content: center; }
.hero h1 { font-size: 2rem; font-weight: 700; margin-bottom: 1rem; }
.hero p { font-size: 1.125rem; color: #6b7280; max-width: 480px; margin: 0 auto; }
.btn { display: inline-block; padding: 0.875rem 1.75rem; background: #3b82f6; color: #fff; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 1.5rem; min-height: 48px; }
@media (max-width: 480px) { .hero h1 { font-size: 1.5rem; } }
</style>
<!-- Non-critical CSS (loaded asynchronously) -->
<link rel="preload" href="/styles/full.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles/full.css"></noscript>
</head>
<body>
<div class="hero">
<h1>Mobile-First Performance</h1>
<p>Optimizing the critical rendering path for instant mobile load times.</p>
<a href="#" class="btn">Get Started</a>
</div>
<!-- Defer non-critical JavaScript -->
<script defer src="/scripts/app.js"></script>
<script>
// Critical inline scripts only (e.g., analytics, font loading)
if ('IntersectionObserver' in window) {
// Lazy loading setup runs early
}
</script>
</body>
</html>
Expected output: The page renders above-the-fold content immediately with inlined critical CSS (under 14KB). Full CSS loads asynchronously without blocking rendering. JavaScript is deferred. The hero section appears in the first paint without waiting for external resources.
JavaScript Optimization
JavaScript is the most expensive resource on mobile. Reducing it has the biggest performance impact.
Code Example: Code Splitting and Lazy Loading
// app.js — main bundle (loaded with defer)
// Static imports (critical, loaded upfront)
import { initializeAnalytics } from './analytics.js';
import { setupNavigation } from './nav.js';
// Dynamic imports (loaded on demand)
document.getElementById('open-gallery')?.addEventListener('click', async () => {
const { Gallery } = await import('./gallery.js');
new Gallery(document.getElementById('gallery-container'));
});
document.getElementById('open-chart')?.addEventListener('click', async () => {
const { Chart } = await import('./chart.js');
new Chart(document.getElementById('chart-container'));
});
// Intersection Observer for component lazy loading
const lazyComponents = document.querySelectorAll('[data-lazy-component]');
const componentObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const component = entry.target;
const componentName = component.dataset.lazyComponent;
import(`./components/${componentName}.js`)
.then(module => {
module.default.init(component);
componentObserver.unobserve(component);
})
.catch(err => {
console.warn(`Failed to load component: ${componentName}`, err);
componentObserver.unobserve(component);
});
}
});
}, {
rootMargin: '200px'
});
lazyComponents.forEach(comp => componentObserver.observe(comp));
// Preload critical next-view components
const preloadComponents = () => {
const links = document.querySelectorAll('[data-preload]');
links.forEach(link => {
const rel = document.createElement('link');
rel.rel = 'prefetch';
rel.href = link.dataset.preload;
document.head.appendChild(rel);
});
};
// Preload after initial interaction (idle callback)
if ('requestIdleCallback' in window) {
requestIdleCallback(preloadComponents);
} else {
setTimeout(preloadComponents, 2000);
}
Expected output: Critical JavaScript loads synchronously in the initial bundle. Non-critical components like Gallery and Chart only load when the user interacts with them or when they scroll near the viewport. The initial bundle stays under 50KB.
Image Optimization
Images are the largest assets on most pages. Optimizing them is essential for mobile performance.
Code Example: Responsive and Optimized Images
<!-- Modern responsive image with WebP and AVIF -->
<picture>
<source
type="image/avif"
srcset="
/images/hero-400.avif 400w,
/images/hero-800.avif 800w,
/images/hero-1200.avif 1200w
"
sizes="(max-width: 480px) 100vw, 800px"
>
<source
type="image/webp"
srcset="
/images/hero-400.webp 400w,
/images/hero-800.webp 800w,
/images/hero-1200.webp 1200w
"
sizes="(max-width: 480px) 100vw, 800px"
>
<img
src="/images/hero-800.jpg"
srcset="
/images/hero-400.jpg 400w,
/images/hero-800.jpg 800w,
/images/hero-1200.jpg 1200w
"
sizes="(max-width: 480px) 100vw, 800px"
alt="Hero image"
width="800"
height="450"
loading="lazy"
decoding="async"
fetchpriority="high"
>
</picture>
<!-- Inline SVG for icons (no network request) -->
<svg class="icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<!-- Image CDN with transformation parameters -->
<img
src="https://cdn.dodatech.com/images/product-123.jpg?w=400&q=75&format=webp"
srcset="
https://cdn.dodatech.com/images/product-123.jpg?w=400&q=75&format=webp 400w,
https://cdn.dodatech.com/images/product-123.jpg?w=800&q=75&format=webp 800w
"
sizes="(max-width: 480px) 100vw, 400px"
alt="Product image"
width="400"
height="400"
loading="lazy"
decoding="async"
>
<!-- Lazy loading background images -->
<div class="lazy-bg" data-bg="/images/section-bg.webp" style="aspect-ratio: 16/9;">
<div class="lazy-bg-placeholder" style="background: #f3f4f6; width: 100%; height: 100%;"></div>
</div>
<script>
document.querySelectorAll('.lazy-bg').forEach(el => {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = new Image();
img.onload = () => {
el.style.backgroundImage = `url(${el.dataset.bg})`;
el.querySelector('.lazy-bg-placeholder')?.remove();
};
img.src = el.dataset.bg;
observer.unobserve(el);
}
});
}, { rootMargin: '200px' });
observer.observe(el);
});
</script>
Expected output: Images are served in modern formats (AVIF, WebP) with JPEG fallback. Responsive srcset serves the correct size for each viewport. Images are lazy loaded with 200px root margin. Background images use Intersection Observer for lazy loading. CDN handles resizing and format conversion.
Core Web Vitals Measurement
Measuring performance is essential for improvement. Use the Performance API for real-user monitoring.
Code Example: Performance Measurement
// Core Web Vitals measurement
function measureWebVitals() {
const metrics = {};
// Largest Contentful Paint
if ('PerformanceObserver' in window) {
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
metrics.LCP = lastEntry.startTime;
console.log('LCP:', lastEntry.startTime.toFixed(0), 'ms');
// Send to analytics
sendMetric('LCP', lastEntry.startTime);
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
// First Input Delay
const fidObserver = new PerformanceObserver((list) => {
const entry = list.getEntries()[0];
metrics.FID = entry.processingStart - entry.startTime;
console.log('FID:', metrics.FID.toFixed(0), 'ms');
sendMetric('FID', metrics.FID);
});
fidObserver.observe({ type: 'first-input', buffered: true });
// Cumulative Layout Shift
let clsValue = 0;
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value;
}
}
metrics.CLS = clsValue;
console.log('CLS:', clsValue.toFixed(3));
sendMetric('CLS', clsValue);
});
clsObserver.observe({ type: 'layout-shift', buffered: true });
// Time to First Byte
const navigationObserver = new PerformanceObserver((list) => {
const entry = list.getEntries()[0];
metrics.TTFB = entry.responseStart - entry.requestStart;
console.log('TTFB:', metrics.TTFB.toFixed(0), 'ms');
sendMetric('TTFB', metrics.TTFB);
});
navigationObserver.observe({ type: 'navigation', buffered: true });
}
}
function sendMetric(name, value) {
// Send to your analytics endpoint
const payload = {
name,
value: Math.round(value),
url: window.location.pathname,
device: navigator.userAgent,
connection: navigator.connection?.effectiveType || 'unknown'
};
if ('sendBeacon' in navigator) {
navigator.sendBeacon('/api/metrics', JSON.stringify(payload));
} else {
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify(payload),
keepalive: true
});
}
}
// Start measurement when page is interactive
if (document.readyState === 'complete') {
measureWebVitals();
} else {
window.addEventListener('load', measureWebVitals);
}
// Navigation Timing API for detailed timing
window.addEventListener('load', () => {
const perfData = performance.getEntriesByType('navigation')[0];
const timing = {
dnsLookup: perfData.domainLookupEnd - perfData.domainLookupStart,
tcpConnection: perfData.connectEnd - perfData.connectStart,
ttfb: perfData.responseStart - perfData.requestStart,
domContentLoaded: perfData.domContentLoadedEventEnd - perfData.navigationStart,
fullLoad: perfData.loadEventEnd - perfData.navigationStart
};
console.log('Navigation Timing:', timing);
});
Expected output: The script measures LCP, FID, CLS, TTFB in real time. Metrics are sent to the analytics endpoint via sendBeacon for reliable delivery. Navigation timing provides detailed breakdown of DNS, TCP, TTFB, DOM content, and full load time.
Common Mistakes
- No performance budget — Teams ship code without limits, leading to bloated bundles. Set a 100KB JavaScript budget for mobile.
- Loading all images eagerly — Every image makes a network request on page load, competing for bandwidth with critical resources.
- Render-blocking JavaScript — Scripts without defer or async block the critical rendering path, delaying the first paint.
- No compression — Uncompressed HTML, CSS, and JavaScript are 3-5x larger than gzip/brotli compressed versions.
- No CDN — Serving assets from a single origin limits parallel downloads and increases latency for distant users.
- Too many font weights — Each font weight is a separate download. Use variable fonts or limit to 2-3 weights.
- No caching strategy — Returning users download the same assets on every visit, wasting bandwidth and time.
Practice Questions
- What is the critical rendering path? The sequence of steps the browser takes from receiving HTML to painting the first pixel: HTML parsing, CSSOM construction, render tree building, layout, and paint.
- How does lazy loading improve mobile performance? It defers loading of below-fold images and components until the user scrolls near them, reducing initial page weight and bandwidth contention.
- What is Cumulative Layout Shift (CLS)? A Core Web Vital metric that measures unexpected layout shifts during page load. Caused by images without dimensions, ads, embeds, and dynamic content.
- What is the 14KB rule for critical CSS? The first 14KB of CSS can be delivered in one TCP round trip. Inlining critical CSS within this limit ensures the first paint happens on the first round trip.
- What is a performance budget? A set of limits for metrics like bundle size (100KB JS), load time (3s on 3G), and Core Web Vitals thresholds (LCP under 2.5s, FID under 100ms, CLS under 0.1).
Challenge
Build a performance monitoring dashboard for a mobile site. Include: (1) real-time LCP, FID, CLS, TTFB displays using Performance API, (2) a performance budget checker that flags when bundles exceed 100KB, (3) a simulated 3G network test using the Network Information API (navigator.connection), (4) a request Waterfall chart showing all resources with load times, (5) recommendations panel that suggests fixes for poor metrics (e.g., "LCP too high — optimize hero image"), (6) exportable performance report.
FAQ
Mini Project
Build a performance-optimized landing page that achieves the following: (1) LCP under 1.5s on simulated 3G (use Chrome DevToolsk "DevTools" >}} throttling), (2) CLS under 0.05, (3) initial JavaScript bundle under 50KB, (4) 100 Lighthouse performance score, (5) inlined critical CSS under 14KB, (6) deferred full CSS, (7) lazy-loaded images below the fold with srcset, (8) WebP/AVIF format support with JPEG fallback, (9) code-split JavaScript with dynamic imports for non-critical components, (10) service worker caching strategy for repeat visits.
What's Next
Continue with Lesson 16: Mobile-First Touch Events to handle touch interactions and gestures on mobile devices.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro